From 67105ee9c0bb49a29c2069036f5a8dac422b9195 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Fri, 27 Sep 2024 17:06:14 +0900 Subject: [PATCH 01/29] [CLNP-5043] Migrate MessageSearchProvider (#1216) ## Overview This PR refactors the MessageSearch state management system, introducing a custom store solution and several hooks for improved performance, maintainability, and type safety. ## New Files - `src/utils/storeManager.ts`: Implements a custom store creation utility - `src/contexts/_MessageSearchContext.tsx`: Provides the MessageSearch context and store \w new state mgmt logic. It's just temporal name. - `src/hooks/useStore.ts`: A generic hook for accessing and updating store state - `src/hooks/useMessageSearchStore.ts`: A specialized hook for MessageSearch state management - `src/components/MessageSearchManager.tsx`: Manages MessageSearch state and side effects - `src/hooks/useMessageSearchActions.ts`: Manages action handlers ## Updated Hooks - `useSetChannel`: Now uses `useMessageSearchStore` directly - `useSearchStringEffect`: Refactored to work with the new store - `useGetSearchMessages`: Updated to utilize the new state management system - `useScrollCallback`: Adapted to work with the custom store ## Key Changes 1. Introduced a custom store solution to replace the previous reducer-based state management. 2. Implemented `useStore` hook for type-safe and efficient state access and updates. 3. Created `MessageSearchManager` to centralize state management logic. 4. Refactored existing hooks to work with the new store system. 5. Improved type safety throughout the MessageSearch module. --- package.json | 1 + src/hooks/useStore.ts | 49 ++++ .../components/MessageSearchUI/index.tsx | 39 ++- .../context/MessageSearchProvider.tsx | 231 ++++++++++-------- .../context/hooks/useGetSearchedMessages.ts | 112 ++++----- .../context/hooks/useMessageSearch.ts | 79 ++++++ .../context/hooks/useScrollCallback.ts | 35 +-- .../context/hooks/useSearchStringEffect.ts | 48 ++-- .../context/hooks/useSetChannel.ts | 32 +-- src/utils/storeManager.ts | 29 +++ yarn.lock | 10 + 11 files changed, 424 insertions(+), 241 deletions(-) create mode 100644 src/hooks/useStore.ts create mode 100644 src/modules/MessageSearch/context/hooks/useMessageSearch.ts create mode 100644 src/utils/storeManager.ts diff --git a/package.json b/package.json index 7d1e610c78..2a7e25974d 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "ts-pattern": "^4.2.2", "typedoc": "^0.25.13", "typescript": "^5.4.5", + "use-sync-external-store": "^1.2.2", "vite": "^5.1.5", "vite-plugin-svgr": "^4.2.0" }, diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts new file mode 100644 index 0000000000..ccd906318b --- /dev/null +++ b/src/hooks/useStore.ts @@ -0,0 +1,49 @@ +import { useContext, useRef, useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { type Store } from '../utils/storeManager'; + +type StoreSelector = (state: T) => U; + +/** + * A generic hook for accessing and updating store state + * @param StoreContext + * @param selector + * @param initialState + */ +export function useStore( + StoreContext: React.Context | null>, + selector: StoreSelector, + initialState: T, +) { + const store = useContext(StoreContext); + if (!store) { + throw new Error('useStore must be used within a StoreProvider'); + } + // Ensure the stability of the selector function using useRef + const selectorRef = useRef(selector); + selectorRef.current = selector; + /** + * useSyncExternalStore - a new API introduced in React18 + * but we're using a shim for now since it's only available in 18 >= version. + * useSyncExternalStore simply tracks changes in an external store that is not dependent on React + * through useState and useEffect + * and helps with re-rendering and state sync through the setter of useState + */ + const state = useSyncExternalStore( + store.subscribe, + () => selectorRef.current(store.getState()), + () => selectorRef.current(initialState), + ); + + const updateState = useCallback((updates: Partial) => { + store.setState((prevState) => ({ + ...prevState, + ...updates, + })); + }, [store]); + + return { + state, + updateState, + }; +} diff --git a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx index 6ba415f879..fb54b86e3b 100644 --- a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx +++ b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx @@ -3,7 +3,7 @@ import type { FileMessage, UserMessage } from '@sendbird/chat/message'; import './index.scss'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import { useMessageSearchContext } from '../../context/MessageSearchProvider'; +import useMessageSearch from '../../context/hooks/useMessageSearch'; import MessageSearchItem from '../../../../ui/MessageSearchItem'; import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder'; @@ -34,28 +34,27 @@ export const MessageSearchUI: React.FC = ({ renderSearchItem, }: MessageSearchUIProps) => { const { - isInvalid, - searchString, - requestString, - currentChannel, - retryCount, - setRetryCount, - loading, - scrollRef, - hasMoreResult, - onScroll, - allMessages, - onResultClick, - selectedMessageId, - setSelectedMessageId, - } = useMessageSearchContext(); + state: { + isInvalid, + searchString, + requestString, + currentChannel, + loading, + scrollRef, + hasMoreResult, + onScroll, + allMessages, + onResultClick, + selectedMessageId, + }, + actions: { + setSelectedMessageId, + handleRetryToConnect, + }, + } = useMessageSearch(); const { stringSet } = useContext(LocalizationContext); - const handleRetryToConnect = () => { - setRetryCount(retryCount + 1); - }; - const handleOnScroll = (e) => { const scrollElement = e.target; const { diff --git a/src/modules/MessageSearch/context/MessageSearchProvider.tsx b/src/modules/MessageSearch/context/MessageSearchProvider.tsx index 37a14ff27b..5f4fea3844 100644 --- a/src/modules/MessageSearch/context/MessageSearchProvider.tsx +++ b/src/modules/MessageSearch/context/MessageSearchProvider.tsx @@ -1,26 +1,19 @@ -import React, { - useRef, - useState, - useReducer, -} from 'react'; -import { SendbirdError } from '@sendbird/chat'; -import type { MessageSearchQuery } from '@sendbird/chat/message'; +import React, { createContext, useRef, useContext, useCallback, useEffect } from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; +import { MessageSearchQuery } from '@sendbird/chat/message'; +import { ClientSentMessages } from '../../../types'; +import { SendbirdError } from '@sendbird/chat'; import type { MessageSearchQueryParams } from '@sendbird/chat/lib/__definition'; import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; -import { ClientSentMessages } from '../../../types'; - -import messageSearchReducer from './dux/reducers'; -import messageSearchInitialState, { State as MessageSearchReducerState } from './dux/initialState'; import useSetChannel from './hooks/useSetChannel'; import useGetSearchMessages from './hooks/useGetSearchedMessages'; -import useScrollCallback, { - CallbackReturn as UseScrollCallbackType, -} from './hooks/useScrollCallback'; +import useScrollCallback from './hooks/useScrollCallback'; import useSearchStringEffect from './hooks/useSearchStringEffect'; import { CoreMessageType } from '../../../utils'; +import { createStore } from '../../../utils/storeManager'; +import { useStore } from '../../../hooks/useStore'; export interface MessageSearchProviderProps { channelUrl: string; @@ -31,125 +24,146 @@ export interface MessageSearchProviderProps { onResultClick?(message: ClientSentMessages): void; } -interface MessageSearchProviderInterface extends MessageSearchProviderProps { - requestString?: string; - retryCount: number; - setRetryCount: React.Dispatch>; - selectedMessageId: number; - setSelectedMessageId: React.Dispatch>; - messageSearchDispatcher: (props: { type: string, payload: any }) => void; - scrollRef: React.RefObject; - allMessages: MessageSearchReducerState['allMessages']; +export interface MessageSearchState extends MessageSearchProviderProps { + channelUrl: string; + allMessages: ClientSentMessages[]; loading: boolean; isInvalid: boolean; - currentChannel: GroupChannel; - currentMessageSearchQuery: MessageSearchQuery; + initialized: boolean; + currentChannel: GroupChannel | null; + currentMessageSearchQuery: MessageSearchQuery | null; hasMoreResult: boolean; - onScroll: UseScrollCallbackType; - handleRetryToConnect: () => void; - handleOnScroll: (e: React.BaseSyntheticEvent) => void; + retryCount: number; + selectedMessageId: number | null; + requestString: string; + onScroll?: ReturnType; + handleOnScroll?: (e: React.BaseSyntheticEvent) => void; + scrollRef?: React.RefObject; } -const MessageSearchContext = React.createContext(null); - -const MessageSearchProvider: React.FC = (props: MessageSearchProviderProps) => { - const { - // message search props - channelUrl, - searchString, - messageSearchQuery, - onResultLoaded, - onResultClick, - } = props; - - const globalState = useSendbirdStateContext(); - - // hook variables - const [retryCount, setRetryCount] = useState(0); // this is a trigger flag for activating useGetSearchMessages - const [selectedMessageId, setSelectedMessageId] = useState(0); - const [messageSearchStore, messageSearchDispatcher] = useReducer(messageSearchReducer, messageSearchInitialState); - const { - allMessages, - loading, - isInvalid, - currentChannel, - currentMessageSearchQuery, - hasMoreResult, - } = messageSearchStore; - - const logger = globalState?.config?.logger; - const sdk = globalState?.stores?.sdkStore?.sdk; - const sdkInit = globalState?.stores?.sdkStore?.initialized; - const scrollRef = useRef(null); - const handleOnScroll = (e: React.BaseSyntheticEvent) => { - const scrollElement = e.target as HTMLDivElement; - const { - scrollTop, - scrollHeight, - clientHeight, - } = scrollElement; +const initialState: MessageSearchState = { + channelUrl: '', + allMessages: [], + loading: false, + isInvalid: false, + initialized: false, + currentChannel: null, + currentMessageSearchQuery: null, + messageSearchQuery: null, + hasMoreResult: false, + retryCount: 0, + selectedMessageId: null, + searchString: '', + requestString: '', +}; - if (!hasMoreResult) { - return; - } - if (scrollTop + clientHeight >= scrollHeight) { - onScroll(() => { - // after load more searched messages - }); - } - }; +export const MessageSearchContext = createContext> | null>(null); + +const MessageSearchManager: React.FC = ({ + channelUrl, + searchString, + messageSearchQuery, + onResultLoaded, + onResultClick, +}) => { + const { state, updateState } = useMessageSearchStore(); + const { config, stores } = useSendbirdStateContext(); + const sdk = stores?.sdkStore?.sdk; + const sdkInit = stores?.sdkStore?.initialized; + const { logger } = config; + const scrollRef = useRef(null); useSetChannel( { channelUrl, sdkInit }, - { sdk, logger, messageSearchDispatcher }, + { sdk, logger }, ); - const requestString = useSearchStringEffect({ searchString: searchString ?? '' }, { messageSearchDispatcher }); + const requestString = useSearchStringEffect( + { searchString: searchString ?? '' }, + ); useGetSearchMessages( - { currentChannel, channelUrl, requestString, messageSearchQuery, onResultLoaded, retryCount }, - { sdk, logger, messageSearchDispatcher }, + { + currentChannel: state.currentChannel, + channelUrl, + requestString, + messageSearchQuery, + onResultLoaded, + }, + { sdk, logger }, ); const onScroll = useScrollCallback( - { currentMessageSearchQuery, hasMoreResult, onResultLoaded }, - { logger, messageSearchDispatcher }, + { onResultLoaded }, + { logger }, ); - const handleRetryToConnect = () => { - setRetryCount(retryCount + 1); - }; - return ( - { + const scrollElement = e.target as HTMLDivElement; + const { scrollTop, scrollHeight, clientHeight } = scrollElement; + + if (!state.hasMoreResult) { + return; + } + if (scrollTop + clientHeight >= scrollHeight) { + onScroll(() => { + // after load more searched messages + }); + } + }, [state.hasMoreResult, onScroll]); + + useEffect(() => { + updateState({ channelUrl, searchString, - requestString, messageSearchQuery, - onResultLoaded, onResultClick, - retryCount, - setRetryCount, - selectedMessageId, - setSelectedMessageId, - messageSearchDispatcher, - allMessages, - loading, - isInvalid, - currentChannel, - currentMessageSearchQuery, - hasMoreResult, onScroll, - scrollRef, - handleRetryToConnect, handleOnScroll, - }}> - {props?.children} + scrollRef, + requestString, + }); + }, [channelUrl, searchString, messageSearchQuery, onResultClick, updateState, requestString]); + + return null; +}; + +const createMessageSearchStore = () => createStore(initialState); +const InternalMessageSearchProvider: React.FC> = ({ children }) => { + const storeRef = useRef(createMessageSearchStore()); + + return ( + + {children} ); }; +const MessageSearchProvider: React.FC = ({ + children, + channelUrl, + searchString, + messageSearchQuery, + onResultLoaded, + onResultClick, +}) => { + + return ( + + + {children} + + ); +}; + const useMessageSearchContext = () => { - const context = React.useContext(MessageSearchContext); + const context = useContext(MessageSearchContext); if (!context) throw new Error('MessageSearchContext not found. Use within the MessageSearch module.'); return context; }; @@ -157,4 +171,13 @@ const useMessageSearchContext = () => { export { MessageSearchProvider, useMessageSearchContext, + MessageSearchManager, +}; + +/** + * A specialized hook for MessageSearch state management + * @returns {ReturnType>} + */ +const useMessageSearchStore = () => { + return useStore(MessageSearchContext, state => state, initialState); }; diff --git a/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts b/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts index 0378e7a174..f79a942821 100644 --- a/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts +++ b/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts @@ -1,14 +1,12 @@ -import { useEffect } from 'react'; - +import { useEffect, useCallback } from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { MessageSearchQueryParams } from '@sendbird/chat/lib/__definition'; - import type { SendbirdError } from '@sendbird/chat'; - import type { Logger } from '../../../../lib/SendbirdState'; -import * as messageActionTypes from '../dux/actionTypes'; import { CoreMessageType } from '../../../../utils'; import { SdkStore } from '../../../../lib/types'; +import useMessageSearch from '../hooks/useMessageSearch'; +import { ClientSentMessages } from '../../../../types'; enum MessageSearchOrder { SCORE = 'score', @@ -24,75 +22,63 @@ interface MainProps { messages?: Array, error?: SendbirdError, ) => void; - retryCount: number; } interface ToolProps { sdk: SdkStore['sdk']; logger: Logger; - messageSearchDispatcher: (props: { type: string, payload: any }) => void; } function useGetSearchedMessages( - { currentChannel, channelUrl, requestString, messageSearchQuery, onResultLoaded, retryCount }: MainProps, - { sdk, logger, messageSearchDispatcher }: ToolProps, + { currentChannel, channelUrl, requestString, messageSearchQuery, onResultLoaded }: MainProps, + { sdk, logger }: ToolProps, ): void { + const { + state: { retryCount }, + actions: { + startMessageSearch, + getSearchedMessages, + setQueryInvalid, + startGettingSearchedMessages, + }, + } = useMessageSearch(); + + const handleSearchError = useCallback((error: SendbirdError) => { + logger.warning('MessageSearch | useGetSearchedMessages: failed getting search messages.', error); + setQueryInvalid(); + if (onResultLoaded && typeof onResultLoaded === 'function') { + onResultLoaded(undefined, error); + } + }, [logger, setQueryInvalid, onResultLoaded]); + useEffect(() => { - messageSearchDispatcher({ - type: messageActionTypes.START_MESSAGE_SEARCH, - payload: null, - }); - if (sdk && channelUrl && sdk.createMessageSearchQuery && currentChannel) { - if (requestString) { - currentChannel.refresh() - .then((channel) => { - const inputSearchMessageQueryObject: MessageSearchQueryParams = { - order: MessageSearchOrder.TIMESTAMP, - channelUrl, - messageTimestampFrom: channel.invitedAt, - keyword: requestString, - ...messageSearchQuery, - }; - const createdQuery = sdk.createMessageSearchQuery(inputSearchMessageQueryObject); - createdQuery.next().then((messages) => { - logger.info('MessageSearch | useGetSearchedMessages: succeeded getting messages', messages); - messageSearchDispatcher({ - type: messageActionTypes.GET_SEARCHED_MESSAGES, - payload: { - messages, - createdQuery, - }, - }); - if (onResultLoaded && typeof onResultLoaded === 'function') { - onResultLoaded(messages as CoreMessageType[], undefined); - } - }).catch((error) => { - logger.warning('MessageSearch | useGetSearchedMessages: failed getting search messages.', error); - messageSearchDispatcher({ - type: messageActionTypes.SET_QUERY_INVALID, - payload: null, - }); - if (onResultLoaded && typeof onResultLoaded === 'function') { - onResultLoaded(undefined, error); - } - }); - messageSearchDispatcher({ - type: messageActionTypes.START_GETTING_SEARCHED_MESSAGES, - payload: createdQuery, - }); - }) - .catch((error) => { - logger.warning('MessageSearch | useGetSearchedMessages: failed getting channel.', error); - messageSearchDispatcher({ - type: messageActionTypes.SET_QUERY_INVALID, - payload: null, - }); + startMessageSearch(); + if (sdk && channelUrl && sdk.createMessageSearchQuery && currentChannel && requestString) { + currentChannel.refresh() + .then((channel) => { + const inputSearchMessageQueryObject: MessageSearchQueryParams = { + order: MessageSearchOrder.TIMESTAMP, + channelUrl, + messageTimestampFrom: channel.invitedAt, + keyword: requestString, + ...messageSearchQuery, + }; + const createdQuery = sdk.createMessageSearchQuery(inputSearchMessageQueryObject); + startGettingSearchedMessages(createdQuery); + + createdQuery.next().then((messages) => { + logger.info('MessageSearch | useGetSearchedMessages: succeeded getting messages', messages); + getSearchedMessages(messages as ClientSentMessages[], createdQuery); if (onResultLoaded && typeof onResultLoaded === 'function') { - onResultLoaded(undefined, error); + onResultLoaded(messages as CoreMessageType[], undefined); } - }); - } else { - logger.info('MessageSearch | useGetSeasrchedMessages: search string is empty'); - } + }).catch(handleSearchError); + }) + .catch((error) => { + logger.warning('MessageSearch | useGetSearchedMessages: failed getting channel.', error); + handleSearchError(error); + }); + } else if (!requestString) { + logger.info('MessageSearch | useGetSearchedMessages: search string is empty'); } }, [channelUrl, messageSearchQuery, requestString, currentChannel, retryCount]); } diff --git a/src/modules/MessageSearch/context/hooks/useMessageSearch.ts b/src/modules/MessageSearch/context/hooks/useMessageSearch.ts new file mode 100644 index 0000000000..91bf3d4c22 --- /dev/null +++ b/src/modules/MessageSearch/context/hooks/useMessageSearch.ts @@ -0,0 +1,79 @@ +import { useContext, useMemo } from 'react'; +import type { GroupChannel } from '@sendbird/chat/groupChannel'; +import { MessageSearchQuery } from '@sendbird/chat/message'; + +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { ClientSentMessages } from '../../../../types'; +import { MessageSearchContext } from '../MessageSearchProvider'; + +const useMessageSearch = () => { + const store = useContext(MessageSearchContext); + if (!store) throw new Error('useMessageSearch must be used within a MessageSearchProvider'); + + const state = useSyncExternalStore(store.subscribe, store.getState); + const actions = useMemo(() => ({ + setCurrentChannel: (channel: GroupChannel) => store.setState(state => ({ + ...state, + currentChannel: channel, + initialized: true, + })), + + setChannelInvalid: () => store.setState(state => ({ + ...state, + currentChannel: null, + initialized: false, + })), + + getSearchedMessages: (messages: ClientSentMessages[], createdQuery: MessageSearchQuery) => { + store.setState(state => { + if (createdQuery && createdQuery.channelUrl === state.currentMessageSearchQuery?.channelUrl + && (createdQuery as any).key === (state.currentMessageSearchQuery as any).key) { + return { + ...state, + loading: false, + isInvalid: false, + allMessages: messages, + hasMoreResult: state.currentMessageSearchQuery.hasNext, + }; + } + return state; + }); + }, + + setQueryInvalid: () => store.setState(state => ({ ...state, isInvalid: true })), + + startMessageSearch: () => store.setState(state => ({ + ...state, + isInvalid: false, + loading: false, + })), + + startGettingSearchedMessages: (query: MessageSearchQuery) => store.setState(state => ({ + ...state, + loading: true, + currentMessageSearchQuery: query, + })), + + getNextSearchedMessages: (messages: ClientSentMessages[]) => store.setState(state => ({ + ...state, + allMessages: [...state.allMessages, ...messages], + hasMoreResult: state.currentMessageSearchQuery?.hasNext || false, + })), + + resetSearchString: () => store.setState(state => ({ ...state, allMessages: [] })), + + setSelectedMessageId: (messageId: number) => store.setState(state => ({ + ...state, + selectedMessageId: messageId, + })), + + handleRetryToConnect: () => store.setState(state => ({ + ...state, + retryCount: state.retryCount + 1, + })), + }), [store]); + + return { state, actions }; +}; + +export default useMessageSearch; diff --git a/src/modules/MessageSearch/context/hooks/useScrollCallback.ts b/src/modules/MessageSearch/context/hooks/useScrollCallback.ts index d7adeb416a..8798f61a97 100644 --- a/src/modules/MessageSearch/context/hooks/useScrollCallback.ts +++ b/src/modules/MessageSearch/context/hooks/useScrollCallback.ts @@ -1,29 +1,35 @@ -import type { SendbirdError } from '@sendbird/chat'; -import type { BaseMessage, MessageSearchQuery } from '@sendbird/chat/message'; import { useCallback } from 'react'; -import * as messageActionTypes from '../dux/actionTypes'; +import type { SendbirdError } from '@sendbird/chat'; +import type { BaseMessage } from '@sendbird/chat/message'; import { CoreMessageType } from '../../../../utils'; import { LoggerInterface } from '../../../../lib/Logger'; +import useMessageSearch from '../hooks/useMessageSearch'; +import { ClientSentMessages } from '../../../../types'; interface MainProps { - currentMessageSearchQuery: MessageSearchQuery | null; - hasMoreResult: boolean; onResultLoaded?: (messages?: Array | null, error?: SendbirdError | null) => void; } -type MessageSearchDispatcherType = { type: string; payload: any }; - interface ToolProps { logger: LoggerInterface; - messageSearchDispatcher: (payload: MessageSearchDispatcherType) => void; } export type CallbackReturn = (callback: (...args: [messages: BaseMessage[], error: null] | [messages: null, error: any]) => void) => void; function useScrollCallback( - { currentMessageSearchQuery, hasMoreResult, onResultLoaded }: MainProps, - { logger, messageSearchDispatcher }: ToolProps, + { onResultLoaded }: MainProps, + { logger }: ToolProps, ): CallbackReturn { + const { + state: { + currentMessageSearchQuery, + hasMoreResult, + }, + actions: { + getNextSearchedMessages, + }, + } = useMessageSearch(); + return useCallback((cb) => { if (!hasMoreResult) { logger.warning('MessageSearch | useScrollCallback: no more searched results', hasMoreResult); @@ -33,10 +39,7 @@ function useScrollCallback( .next() .then((messages) => { logger.info('MessageSearch | useScrollCallback: succeeded getting searched messages', messages); - messageSearchDispatcher({ - type: messageActionTypes.GET_NEXT_SEARCHED_MESSAGES, - payload: messages, - }); + getNextSearchedMessages(messages as ClientSentMessages[]); cb(messages, null); if (onResultLoaded && typeof onResultLoaded === 'function') { onResultLoaded(messages as CoreMessageType[], null); @@ -52,9 +55,7 @@ function useScrollCallback( } else { logger.warning('MessageSearch | useScrollCallback: no currentMessageSearchQuery'); } - }, - [currentMessageSearchQuery, hasMoreResult], - ); + }, [currentMessageSearchQuery, hasMoreResult]); } export default useScrollCallback; diff --git a/src/modules/MessageSearch/context/hooks/useSearchStringEffect.ts b/src/modules/MessageSearch/context/hooks/useSearchStringEffect.ts index 7995546bcb..95ba669fd8 100644 --- a/src/modules/MessageSearch/context/hooks/useSearchStringEffect.ts +++ b/src/modules/MessageSearch/context/hooks/useSearchStringEffect.ts @@ -1,38 +1,42 @@ -import { useState, useEffect } from 'react'; -import * as messageActionTypes from '../dux/actionTypes'; +import { useState, useEffect, useCallback } from 'react'; +import useMessageSearch from '../hooks/useMessageSearch'; interface DynamicParams { searchString: string; } -interface StaticParams { - messageSearchDispatcher: (param: { type: string, payload: any }) => void; -} - const DEBOUNCING_TIME = 500; -function useSearchStringEffect( - { searchString }: DynamicParams, - { messageSearchDispatcher }: StaticParams, -): string { +function useSearchStringEffect({ searchString }: DynamicParams): string { const [requestString, setRequestString] = useState(''); const [debouncingTimer, setDebouncingTimer] = useState | null>(null); - useEffect(() => { - clearTimeout(debouncingTimer ?? undefined); + + const { actions: { resetSearchString } } = useMessageSearch(); + + const handleSearchStringChange = useCallback(() => { if (searchString) { - setDebouncingTimer( - setTimeout(() => { - setRequestString(searchString); - }, DEBOUNCING_TIME), - ); + setRequestString(searchString); } else { setRequestString(''); - messageSearchDispatcher({ - type: messageActionTypes.RESET_SEARCH_STRING, - payload: '', - }); + resetSearchString(); } - }, [searchString]); + }, [searchString, resetSearchString]); + + useEffect(() => { + if (debouncingTimer) { + clearTimeout(debouncingTimer); + } + + const timer = setTimeout(handleSearchStringChange, DEBOUNCING_TIME); + setDebouncingTimer(timer); + + return () => { + if (timer) { + clearTimeout(timer); + } + }; + }, [searchString, handleSearchStringChange]); + return requestString; } diff --git a/src/modules/MessageSearch/context/hooks/useSetChannel.ts b/src/modules/MessageSearch/context/hooks/useSetChannel.ts index 30db090897..eef0ead99a 100644 --- a/src/modules/MessageSearch/context/hooks/useSetChannel.ts +++ b/src/modules/MessageSearch/context/hooks/useSetChannel.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import type { Logger } from '../../../../lib/SendbirdState'; -import * as messageActionTypes from '../dux/actionTypes'; import { SdkStore } from '../../../../lib/types'; +import useMessageSearch from '../hooks/useMessageSearch'; interface MainProps { channelUrl: string; @@ -10,27 +10,29 @@ interface MainProps { interface ToolProps { sdk: SdkStore['sdk']; logger: Logger; - messageSearchDispatcher: (param: { type: string, payload: any }) => void; } function useSetChannel( { channelUrl, sdkInit }: MainProps, - { sdk, logger, messageSearchDispatcher }: ToolProps, + { sdk, logger }: ToolProps, ): void { + const { + actions: { + setCurrentChannel, + setChannelInvalid, + }, + } = useMessageSearch(); + useEffect(() => { - if (channelUrl && sdkInit && (sdk?.groupChannel)) { - sdk.groupChannel.getChannel(channelUrl).then((groupChannel) => { - logger.info('MessageSearch | useSetChannel group channel', groupChannel); - messageSearchDispatcher({ - type: messageActionTypes.SET_CURRENT_CHANNEL, - payload: groupChannel, - }); - }).catch(() => { - messageSearchDispatcher({ - type: messageActionTypes.CHANNEL_INVALID, - payload: null, + if (channelUrl && sdkInit && sdk?.groupChannel) { + sdk.groupChannel.getChannel(channelUrl) + .then((groupChannel) => { + logger.info('MessageSearch | useSetChannel group channel', groupChannel); + setCurrentChannel(groupChannel); + }) + .catch(() => { + setChannelInvalid(); }); - }); } }, [channelUrl, sdkInit]); } diff --git a/src/utils/storeManager.ts b/src/utils/storeManager.ts new file mode 100644 index 0000000000..ce4fbae6f2 --- /dev/null +++ b/src/utils/storeManager.ts @@ -0,0 +1,29 @@ +// Referrence: https://github.com/pmndrs/zustand +export type Store = { + getState: () => T; + setState: (partial: Partial | ((state: T) => Partial)) => void; + subscribe: (listener: () => void) => () => void; +}; + +/** + * A custom store creation utility + */ +export function createStore(initialState: T): Store { + const state = { ...initialState }; + const listeners = new Set<() => void>(); + + const setState = (partial: Partial | ((state: T) => Partial)) => { + const nextState = typeof partial === 'function' ? partial(state) : partial; + Object.assign(state, nextState); + listeners.forEach((listener) => listener()); + }; + + return { + getState: () => state, + setState, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/yarn.lock b/yarn.lock index 740d12dcf1..8dc3d3e077 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2825,6 +2825,7 @@ __metadata: ts-pattern: ^4.2.2 typedoc: ^0.25.13 typescript: ^5.4.5 + use-sync-external-store: ^1.2.2 vite: ^5.1.5 vite-plugin-svgr: ^4.2.0 peerDependencies: @@ -14974,6 +14975,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.2.2": + version: 1.2.2 + resolution: "use-sync-external-store@npm:1.2.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: fe07c071c4da3645f112c38c0e57beb479a8838616ff4e92598256ecce527f2888c08febc7f9b2f0ce2f0e18540ba3cde41eb2035e4fafcb4f52955037098a81 + languageName: node + linkType: hard + "use@npm:^3.1.0": version: 3.1.1 resolution: "use@npm:3.1.1" From 77f53959872076c13f525bd9057eced1856f74b5 Mon Sep 17 00:00:00 2001 From: Baek EunSeo Date: Fri, 11 Oct 2024 14:10:39 +0900 Subject: [PATCH 02/29] refactor: ChannelSettingsProvider migration (#1229) [CLNP-5044](https://sendbird.atlassian.net/browse/CLNP-5044) ChannelSettingsProvider migration - Created a hook `useChannelSettings` for replacing `useChannelSettingsContext` - Created hooks `useSetChannel` and `useChannelHandler` to separate the logics from the ChannelSettingsProvider --- rollup.module-exports.mjs | 2 +- .../components/ChannelProfile/index.tsx | 9 +- .../ChannelSettingsUI/MenuListByRole.tsx | 4 +- .../ChannelSettingsUI/hooks/useMenuItems.tsx | 4 +- .../components/ChannelSettingsUI/index.tsx | 31 ++- .../components/EditDetailsModal/index.tsx | 14 +- .../components/LeaveChannel/index.tsx | 4 +- .../ModerationPanel/AddOperatorsModal.tsx | 4 +- .../ModerationPanel/BannedUserList.tsx | 4 +- .../ModerationPanel/BannedUsersModal.tsx | 14 +- .../ModerationPanel/InviteUsersModal.tsx | 4 +- .../components/ModerationPanel/MemberList.tsx | 7 +- .../ModerationPanel/MembersModal.tsx | 4 +- .../ModerationPanel/MutedMemberList.tsx | 4 +- .../ModerationPanel/MutedMembersModal.tsx | 11 +- .../ModerationPanel/OperatorList.tsx | 13 +- .../ModerationPanel/OperatorsModal.tsx | 4 +- .../components/ModerationPanel/index.tsx | 4 +- .../components/UserPanel/index.tsx | 19 +- .../context/ChannelSettingsProvider.tsx | 251 ++++++++---------- .../context/hooks/useChannelHandler.ts | 60 +++++ .../context/hooks/useSetChannel.ts | 63 +++++ src/modules/ChannelSettings/context/index.tsx | 2 + src/modules/ChannelSettings/context/types.ts | 47 ++++ .../context/useChannelSettings.ts | 33 +++ src/modules/ChannelSettings/index.tsx | 5 +- src/utils/storeManager.ts | 4 +- 27 files changed, 393 insertions(+), 232 deletions(-) create mode 100644 src/modules/ChannelSettings/context/hooks/useChannelHandler.ts create mode 100644 src/modules/ChannelSettings/context/hooks/useSetChannel.ts create mode 100644 src/modules/ChannelSettings/context/index.tsx create mode 100644 src/modules/ChannelSettings/context/types.ts create mode 100644 src/modules/ChannelSettings/context/useChannelSettings.ts diff --git a/rollup.module-exports.mjs b/rollup.module-exports.mjs index 7e76c0d4ef..bea9e1cc55 100644 --- a/rollup.module-exports.mjs +++ b/rollup.module-exports.mjs @@ -57,7 +57,7 @@ export default { // ChannelSettings ChannelSettings: 'src/modules/ChannelSettings/index.tsx', - 'ChannelSettings/context': 'src/modules/ChannelSettings/context/ChannelSettingsProvider.tsx', + 'ChannelSettings/context': 'src/modules/ChannelSettings/context/index.tsx', 'ChannelSettings/hooks/useMenuList': 'src/modules/ChannelSettings/components/ChannelSettingsUI/hooks/useMenuItems.tsx', 'ChannelSettings/components/ChannelProfile': 'src/modules/ChannelSettings/components/ChannelProfile/index.tsx', 'ChannelSettings/components/ChannelSettingsUI': 'src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx', diff --git a/src/modules/ChannelSettings/components/ChannelProfile/index.tsx b/src/modules/ChannelSettings/components/ChannelProfile/index.tsx index cc8cec5183..e5439d2ba2 100644 --- a/src/modules/ChannelSettings/components/ChannelProfile/index.tsx +++ b/src/modules/ChannelSettings/components/ChannelProfile/index.tsx @@ -1,9 +1,9 @@ import './channel-profile.scss'; import React, { useState, useContext, useMemo } from 'react'; -import { LocalizationContext } from '../../../../lib/LocalizationContext'; +import useChannelSettings from '../../context/useChannelSettings'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; +import { LocalizationContext } from '../../../../lib/LocalizationContext'; import ChannelAvatar from '../../../../ui/ChannelAvatar'; import TextButton from '../../../../ui/TextButton'; @@ -11,12 +11,11 @@ import Label, { LabelTypography, LabelColors, } from '../../../../ui/Label'; - import EditDetailsModal from '../EditDetailsModal'; const ChannelProfile: React.FC = () => { const state = useSendbirdStateContext(); - const channelSettingStore = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const { stringSet } = useContext(LocalizationContext); const [showModal, setShowModal] = useState(false); @@ -25,8 +24,6 @@ const ChannelProfile: React.FC = () => { const isOnline = state?.config?.isOnline; const disabled = !isOnline; - const channel = channelSettingStore?.channel; - const channelName = useMemo(() => { if (channel?.name && channel.name !== 'Group Channel') { return channel.name; diff --git a/src/modules/ChannelSettings/components/ChannelSettingsUI/MenuListByRole.tsx b/src/modules/ChannelSettings/components/ChannelSettingsUI/MenuListByRole.tsx index 25f2894120..a0400b68c9 100644 --- a/src/modules/ChannelSettings/components/ChannelSettingsUI/MenuListByRole.tsx +++ b/src/modules/ChannelSettings/components/ChannelSettingsUI/MenuListByRole.tsx @@ -8,8 +8,8 @@ import Icon from '../../../../ui/Icon'; import { isOperator } from '../../../Channel/context/utils'; import MenuItem from './MenuItem'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import useMenuItems from './hooks/useMenuItems'; +import useChannelSettings from '../../context/useChannelSettings'; interface MenuListByRoleProps { menuItems: ReturnType; @@ -17,7 +17,7 @@ interface MenuListByRoleProps { export const MenuListByRole = ({ menuItems, }: MenuListByRoleProps) => { - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const menuItemsByRole = isOperator(channel) ? menuItems.operator : menuItems.nonOperator; // State to track the open accordion key const [openAccordionKey, setOpenAccordionKey] = useState(null); diff --git a/src/modules/ChannelSettings/components/ChannelSettingsUI/hooks/useMenuItems.tsx b/src/modules/ChannelSettings/components/ChannelSettingsUI/hooks/useMenuItems.tsx index 556ebb87da..fba1692ebf 100644 --- a/src/modules/ChannelSettings/components/ChannelSettingsUI/hooks/useMenuItems.tsx +++ b/src/modules/ChannelSettings/components/ChannelSettingsUI/hooks/useMenuItems.tsx @@ -11,9 +11,9 @@ import { IconColors, IconTypes, IconProps } from '../../../../../ui/Icon'; import Badge from '../../../../../ui/Badge'; import { Toggle } from '../../../../../ui/Toggle'; import { LabelColors, LabelTypography, type LabelProps } from '../../../../../ui/Label'; -import { useChannelSettingsContext } from '../../../context/ChannelSettingsProvider'; import { MenuItemAction, type MenuItemActionProps } from '../MenuItem'; +import useChannelSettings from '../../../context/useChannelSettings'; const kFormatter = (num: number): string | number => { return Math.abs(num) > 999 @@ -55,7 +55,7 @@ const commonLabelProps = { export const useMenuItems = (): MenuItems => { const [frozen, setFrozen] = useState(false); const { stringSet } = useContext(LocalizationContext); - const { channel, renderUserListItem } = useChannelSettingsContext(); + const { state: { channel, renderUserListItem } } = useChannelSettings(); // work around for // https://sendbird.slack.com/archives/G01290GCDCN/p1595922832000900 diff --git a/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx b/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx index 93df565555..4eb515350b 100644 --- a/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx +++ b/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx @@ -1,23 +1,24 @@ import './channel-settings-ui.scss'; -import React, { ReactNode, useContext, useState } from 'react'; +import React, { ReactNode, useState } from 'react'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; +import useChannelSettings from '../../context/useChannelSettings'; +import { useLocalization } from '../../../../lib/LocalizationContext'; +import useMenuItems from './hooks/useMenuItems'; +import { deleteNullish, classnames } from '../../../../utils/utils'; import { ChannelSettingsHeader, ChannelSettingsHeaderProps } from './ChannelSettingsHeader'; import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; -import { LocalizationContext } from '../../../../lib/LocalizationContext'; import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; +import { UserListItemProps } from '../../../../ui/UserListItem'; + import ChannelProfile from '../ChannelProfile'; import LeaveChannelModal from '../LeaveChannel'; -import { deleteNullish, classnames } from '../../../../utils/utils'; import MenuItem from './MenuItem'; import MenuListByRole from './MenuListByRole'; -import useMenuItems from './hooks/useMenuItems'; -import { UserListItemProps } from '../../../../ui/UserListItem'; interface ModerationPanelProps { menuItems: ReturnType; @@ -37,7 +38,6 @@ export interface ChannelSettingsUIProps { } const ChannelSettingsUI = (props: ChannelSettingsUIProps) => { - const { channel, invalidChannel, onCloseClick, loading } = useChannelSettingsContext(); const { renderHeader = (props: ChannelSettingsHeaderProps) => , renderLeaveChannel, @@ -46,14 +46,22 @@ const ChannelSettingsUI = (props: ChannelSettingsUIProps) => { renderPlaceholderError, renderPlaceholderLoading, } = deleteNullish(props); + const { + config: { isOnline }, + } = useSendbirdStateContext(); + const { + state: { + channel, + invalidChannel, + onCloseClick, + loading, + }, + } = useChannelSettings(); + const { stringSet } = useLocalization(); const menuItems = useMenuItems(); - const state = useSendbirdStateContext(); const [showLeaveChannelModal, setShowLeaveChannelModal] = useState(false); - const isOnline = state?.config?.isOnline; - const { stringSet } = useContext(LocalizationContext); - if (loading) { if (renderPlaceholderLoading) return renderPlaceholderLoading(); return ; @@ -120,6 +128,7 @@ const ChannelSettingsUI = (props: ChannelSettingsUIProps) => { }; export default ChannelSettingsUI; +/** NOTE: For exportation */ export { OperatorList } from '../ModerationPanel/OperatorList'; export { MemberList } from '../ModerationPanel/MemberList'; export { MutedMemberList } from '../ModerationPanel/MutedMemberList'; diff --git a/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx b/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx index 5c3a7433d8..62f9579033 100644 --- a/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx +++ b/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useContext } from 'react'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import useChannelSettings from '../../context/useChannelSettings'; import Modal from '../../../../ui/Modal'; import Input, { InputLabel } from '../../../../ui/Input'; @@ -26,11 +26,13 @@ const EditDetails: React.FC = (props: EditDetailsProps) => { } = props; const { - channel, - onChannelModified, - onBeforeUpdateChannel, - setChannelUpdateId, - } = useChannelSettingsContext(); + state: { + channel, + onChannelModified, + onBeforeUpdateChannel, + setChannelUpdateId, + }, + } = useChannelSettings(); const title = channel?.name; const state = useSendbirdStateContext(); diff --git a/src/modules/ChannelSettings/components/LeaveChannel/index.tsx b/src/modules/ChannelSettings/components/LeaveChannel/index.tsx index 786cbabe79..9302dbb671 100644 --- a/src/modules/ChannelSettings/components/LeaveChannel/index.tsx +++ b/src/modules/ChannelSettings/components/LeaveChannel/index.tsx @@ -4,7 +4,6 @@ import React from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import { noop } from '../../../../utils/utils'; import Modal from '../../../../ui/Modal'; @@ -15,6 +14,7 @@ import Label, { LabelTypography, LabelColors, } from '../../../../ui/Label'; +import useChannelSettings from '../../context/useChannelSettings'; export type LeaveChannelProps = { onSubmit: () => void; @@ -27,7 +27,7 @@ const LeaveChannel: React.FC = (props: LeaveChannelProps) => onCancel = noop, } = props; - const { channel, onLeaveChannel } = useChannelSettingsContext(); + const { state: { channel, onLeaveChannel } } = useChannelSettings(); const { stringSet } = useLocalization(); const state = useSendbirdStateContext(); const logger = state?.config?.logger; diff --git a/src/modules/ChannelSettings/components/ModerationPanel/AddOperatorsModal.tsx b/src/modules/ChannelSettings/components/ModerationPanel/AddOperatorsModal.tsx index 1fad41b2b8..46ddd14568 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/AddOperatorsModal.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/AddOperatorsModal.tsx @@ -14,9 +14,9 @@ import Label, { } from '../../../../ui/Label'; import { ButtonTypes } from '../../../../ui/Button'; import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import { Member, MemberListQuery, OperatorFilter } from '@sendbird/chat/groupChannel'; import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; +import useChannelSettings from '../../context/useChannelSettings'; export interface AddOperatorsModalProps { onCancel(): void; @@ -34,7 +34,7 @@ export default function AddOperatorsModal({ const [memberQuery, setMemberQuery] = useState(null); const { stringSet } = useContext(LocalizationContext); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); useEffect(() => { const memberListQuery = channel?.createMemberListQuery({ diff --git a/src/modules/ChannelSettings/components/ModerationPanel/BannedUserList.tsx b/src/modules/ChannelSettings/components/ModerationPanel/BannedUserList.tsx index 64ba833033..838fe8b841 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/BannedUserList.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/BannedUserList.tsx @@ -17,9 +17,9 @@ Label, { import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; import BannedUsersModal from './BannedUsersModal'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; +import useChannelSettings from '../../context/useChannelSettings'; interface BannedUserListProps { renderUserListItem?: (props: UserListItemProps) => ReactNode; @@ -35,7 +35,7 @@ export const BannedUserList = ({ const [showModal, setShowModal] = useState(false); const { stringSet } = useContext(LocalizationContext); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const refreshList = useCallback(() => { if (!channel) { diff --git a/src/modules/ChannelSettings/components/ModerationPanel/BannedUsersModal.tsx b/src/modules/ChannelSettings/components/ModerationPanel/BannedUsersModal.tsx index 345c456865..8689c376c7 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/BannedUsersModal.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/BannedUsersModal.tsx @@ -4,15 +4,15 @@ import React, { useEffect, useState, } from 'react'; +import { BannedUserListQuery, BannedUserListQueryParams, RestrictedUser } from '@sendbird/chat'; -import Modal from '../../../../ui/Modal'; -import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; - -import { noop } from '../../../../utils/utils'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; +import useChannelSettings from '../../context/useChannelSettings'; import { useLocalization } from '../../../../lib/LocalizationContext'; -import { BannedUserListQuery, BannedUserListQueryParams, RestrictedUser } from '@sendbird/chat'; import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; +import { noop } from '../../../../utils/utils'; + +import Modal from '../../../../ui/Modal'; +import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; export interface BannedUsersModalProps { @@ -28,7 +28,7 @@ export function BannedUsersModal({ }: BannedUsersModalProps): ReactElement { const [members, setMembers] = useState([]); const [memberQuery, setMemberQuery] = useState(null); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const { stringSet } = useLocalization(); useEffect(() => { diff --git a/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx b/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx index 54a33bc69f..1a274ade7d 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx @@ -4,11 +4,11 @@ import { User } from '@sendbird/chat'; import Modal from '../../../../ui/Modal'; import { ButtonTypes } from '../../../../ui/Button'; import UserListItem, { type UserListItemProps } from '../../../../ui/UserListItem'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; import { UserListQuery } from '../../../../types'; +import useChannelSettings from '../../context/useChannelSettings'; type UserId = string; export interface InviteUsersModalProps { @@ -30,7 +30,7 @@ export function InviteUsersModal({ const sdk = state?.stores?.sdkStore?.sdk; const globalUserListQuery = state?.config?.userListQuery; - const { channel, overrideInviteUser, queries } = useChannelSettingsContext(); + const { state: { channel, overrideInviteUser, queries } } = useChannelSettings(); const { stringSet } = useLocalization(); const onScroll = useOnScrollPositionChangeDetector({ diff --git a/src/modules/ChannelSettings/components/ModerationPanel/MemberList.tsx b/src/modules/ChannelSettings/components/ModerationPanel/MemberList.tsx index 116d1c6077..baf86ba2f7 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/MemberList.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/MemberList.tsx @@ -10,7 +10,6 @@ import type { Member, MemberListQueryParams } from '@sendbird/chat/groupChannel' import { Role } from '@sendbird/chat'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import Button, { ButtonTypes, ButtonSizes } from '../../../../ui/Button'; import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; @@ -18,6 +17,7 @@ import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; import MembersModal from './MembersModal'; import { InviteUsersModal } from './InviteUsersModal'; +import useChannelSettings from '../../context/useChannelSettings'; interface MemberListProps { renderUserListItem?: (props: UserListItemProps & { index: number }) => ReactNode; @@ -31,10 +31,7 @@ export const MemberList = ({ const [hasNext, setHasNext] = useState(false); const [showAllMembers, setShowAllMembers] = useState(false); const [showInviteUsers, setShowInviteUsers] = useState(false); - const { - channel, - forceUpdateUI, - } = useChannelSettingsContext(); + const { state: { channel, forceUpdateUI } } = useChannelSettings(); const { stringSet } = useContext(LocalizationContext); const isOperator = channel.myRole === Role.OPERATOR; diff --git a/src/modules/ChannelSettings/components/ModerationPanel/MembersModal.tsx b/src/modules/ChannelSettings/components/ModerationPanel/MembersModal.tsx index 7ae3eae9bb..b2673d2166 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/MembersModal.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/MembersModal.tsx @@ -12,10 +12,10 @@ import Modal from '../../../../ui/Modal'; import UserListItem, { type UserListItemProps } from '../../../../ui/UserListItem'; import { noop } from '../../../../utils/utils'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; +import useChannelSettings from '../../context/useChannelSettings'; export interface MembersModalProps { onCancel(): void; @@ -31,7 +31,7 @@ export function MembersModal({ const [members, setMembers] = useState([]); const [memberQuery, setMemberQuery] = useState(null); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const { stringSet } = useContext(LocalizationContext); useEffect(() => { diff --git a/src/modules/ChannelSettings/components/ModerationPanel/MutedMemberList.tsx b/src/modules/ChannelSettings/components/ModerationPanel/MutedMemberList.tsx index 3a33846c57..9cf84efedd 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/MutedMemberList.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/MutedMemberList.tsx @@ -7,7 +7,6 @@ import React, { } from 'react'; import type { Member, MemberListQueryParams } from '@sendbird/chat/groupChannel'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import { useLocalization } from '../../../../lib/LocalizationContext'; import Button, { ButtonTypes, ButtonSizes } from '../../../../ui/Button'; @@ -15,6 +14,7 @@ import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; import MutedMembersModal from './MutedMembersModal'; +import useChannelSettings from '../../context/useChannelSettings'; interface MutedMemberListProps { renderUserListItem?: (props: UserListItemProps) => ReactNode; @@ -29,7 +29,7 @@ export const MutedMemberList = ({ const [showModal, setShowModal] = useState(false); const { stringSet } = useLocalization(); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const refreshList = useCallback(() => { if (!channel) { diff --git a/src/modules/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx b/src/modules/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx index 2f356af6cd..9267fef5b5 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/MutedMembersModal.tsx @@ -4,14 +4,15 @@ import React, { useEffect, useState, } from 'react'; +import { Member, MemberListQuery, MemberListQueryParams } from '@sendbird/chat/groupChannel'; + +import useChannelSettings from '../../context/useChannelSettings'; +import { useLocalization } from '../../../../lib/LocalizationContext'; +import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; import Modal from '../../../../ui/Modal'; import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; import { noop } from '../../../../utils/utils'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; -import { useLocalization } from '../../../../lib/LocalizationContext'; -import { Member, MemberListQuery, MemberListQueryParams } from '@sendbird/chat/groupChannel'; -import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; export interface MutedMembersModalProps { @@ -28,7 +29,7 @@ export function MutedMembersModal({ const [members, setMembers] = useState([]); const [memberQuery, setMemberQuery] = useState(null); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const { stringSet } = useLocalization(); useEffect(() => { diff --git a/src/modules/ChannelSettings/components/ModerationPanel/OperatorList.tsx b/src/modules/ChannelSettings/components/ModerationPanel/OperatorList.tsx index 9b3fc4efe5..b4b00c28a3 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/OperatorList.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/OperatorList.tsx @@ -3,18 +3,17 @@ import React, { useEffect, useState, useCallback, - useContext, ReactNode, } from 'react'; import type { OperatorListQueryParams, User } from '@sendbird/chat'; -import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; +import useChannelSettings from '../../context/useChannelSettings'; +import { useLocalization } from '../../../../lib/LocalizationContext'; -import Button, { ButtonTypes, ButtonSizes } from '../../../../ui/Button'; import UserListItemMenu from '../../../../ui/UserListItemMenu/UserListItemMenu'; - +import Button, { ButtonTypes, ButtonSizes } from '../../../../ui/Button'; import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; + import OperatorsModal from './OperatorsModal'; import AddOperatorsModal from './AddOperatorsModal'; @@ -30,8 +29,8 @@ export const OperatorList = ({ const [showMore, setShowMore] = useState(false); const [showAdd, setShowAdd] = useState(false); const [hasNext, setHasNext] = useState(false); - const { stringSet } = useContext(LocalizationContext); - const { channel } = useChannelSettingsContext(); + const { stringSet } = useLocalization(); + const { state: { channel } } = useChannelSettings(); const refreshList = useCallback(() => { if (!channel) { diff --git a/src/modules/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx b/src/modules/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx index dcac012fb2..54e3625b28 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/OperatorsModal.tsx @@ -9,11 +9,11 @@ import React, { import Modal from '../../../../ui/Modal'; import UserListItem, { UserListItemProps } from '../../../../ui/UserListItem'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import { OperatorListQuery, OperatorListQueryParams, User } from '@sendbird/chat'; import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; import { UserListItemMenu } from '../../../../ui/UserListItemMenu'; +import useChannelSettings from '../../context/useChannelSettings'; export interface OperatorsModalProps { onCancel?(): void; @@ -29,7 +29,7 @@ export function OperatorsModal({ const [operators, setOperators] = useState([]); const [operatorQuery, setOperatorQuery] = useState(null); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); const { stringSet } = useContext(LocalizationContext); useEffect(() => { diff --git a/src/modules/ChannelSettings/components/ModerationPanel/index.tsx b/src/modules/ChannelSettings/components/ModerationPanel/index.tsx index f4b0060576..809eb69088 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/index.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/index.tsx @@ -22,7 +22,7 @@ import MemberList from './MemberList'; import BannedUserList from './BannedUserList'; import MutedMemberList from './MutedMemberList'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; +import useChannelSettings from '../../context/useChannelSettings'; const kFormatter = (num: number): string | number => { return Math.abs(num) > 999 @@ -39,7 +39,7 @@ export default function ModerationPanel(): ReactElement { const [frozen, setFrozen] = useState(false); const { stringSet } = useContext(LocalizationContext); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); // work around for // https://sendbird.slack.com/archives/G01290GCDCN/p1595922832000900 diff --git a/src/modules/ChannelSettings/components/UserPanel/index.tsx b/src/modules/ChannelSettings/components/UserPanel/index.tsx index 01c7558e01..092b258cf1 100644 --- a/src/modules/ChannelSettings/components/UserPanel/index.tsx +++ b/src/modules/ChannelSettings/components/UserPanel/index.tsx @@ -1,18 +1,15 @@ import './user-panel.scss'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; + +import useChannelSettings from '../../context/useChannelSettings'; +import { useLocalization } from '../../../../lib/LocalizationContext'; -import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import -Label, { - LabelTypography, - LabelColors, -} from '../../../../ui/Label'; -import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import Badge from '../../../../ui/Badge'; +import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; +import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import MemberList from '../ModerationPanel/MemberList'; -import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider'; const kFormatter = (num: number): string|number => { return Math.abs(num) > 999 @@ -21,9 +18,9 @@ const kFormatter = (num: number): string|number => { }; const UserPanel: React.FC = () => { - const { stringSet } = useContext(LocalizationContext); + const { stringSet } = useLocalization(); const [showAccordion, setShowAccordion] = useState(false); - const { channel } = useChannelSettingsContext(); + const { state: { channel } } = useChannelSettings(); return (
; - metaDataKeyFilter?: string; - metaDataValuesFilter?: Array; -} +import useSetChannel from './hooks/useSetChannel'; +import { useStore } from '../../../hooks/useStore'; +import { useChannelHandler } from './hooks/useChannelHandler'; -interface ChannelSettingsQueries { - applicationUserListQuery?: ApplicationUserListQuery; -} +import uuidv4 from '../../../utils/uuid'; +import { classnames } from '../../../utils/utils'; +import { createStore } from '../../../utils/storeManager'; +import { UserProfileProvider } from '../../../lib/UserProfileContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; + +export const ChannelSettingsContext = createContext> | null>(null); + +const initialState: ChannelSettingsState = { + // Props + channelUrl: '', + onCloseClick: undefined, + onLeaveChannel: undefined, + onChannelModified: undefined, + onBeforeUpdateChannel: undefined, + renderUserListItem: undefined, + queries: {}, + overrideInviteUser: undefined, + // Managed states + channel: null, + loading: false, + invalidChannel: false, + forceUpdateUI: () => { }, + setChannelUpdateId: () => { }, +}; -type OverrideInviteUserType = { - users: Array; - onClose: () => void; - channel: GroupChannel; +/** + * @returns {ReturnType>} + */ +const useChannelSettingsStore = () => { + return useStore(ChannelSettingsContext, state => state, initialState); }; -interface CommonChannelSettingsProps { - channelUrl: string; - onCloseClick?(): void; - onLeaveChannel?(): void; - onChannelModified?(channel: GroupChannel): void; - onBeforeUpdateChannel?(currentTitle: string, currentImg: File | null, data: string | undefined): GroupChannelUpdateParams; - overrideInviteUser?(params: OverrideInviteUserType): void; - queries?: ChannelSettingsQueries; - renderUserListItem?: (props: UserListItemProps) => ReactNode; -} -export interface ChannelSettingsContextProps extends - CommonChannelSettingsProps, - Pick { - children?: React.ReactElement; - className?: string; -} +const ChannelSettingsManager = ({ + channelUrl, + onCloseClick, + onLeaveChannel, + onChannelModified, + overrideInviteUser, + onBeforeUpdateChannel, + queries, + renderUserListItem, +}: ChannelSettingsContextProps) => { + const { state } = useSendbird(); + const { config, stores } = state; + const { updateState } = useChannelSettingsStore(); + const { logger } = config; + const { sdk, initialized } = stores?.sdkStore ?? {}; -interface ChannelSettingsProviderInterface extends CommonChannelSettingsProps { - setChannelUpdateId(uniqId: string): void; - forceUpdateUI(): void; - channel: GroupChannel | null; - loading: boolean; - invalidChannel: boolean; -} + const [channelUpdateId, setChannelUpdateId] = useState(() => uuidv4()); + const forceUpdateUI = useCallback(() => setChannelUpdateId(uuidv4()), []); -const ChannelSettingsContext = React.createContext(null); + const dependencies = [channelUpdateId]; + useSetChannel({ + channelUrl, + sdk, + logger, + initialized, + dependencies, + }); + useChannelHandler({ + sdk, + channelUrl, + logger, + forceUpdateUI, + dependencies, + }); -const ChannelSettingsProvider = (props: ChannelSettingsContextProps) => { - const { - children, - className, + useEffect(() => { + updateState({ + channelUrl, + onCloseClick, + onLeaveChannel, + onChannelModified, + onBeforeUpdateChannel, + renderUserListItem, + queries, + overrideInviteUser, + forceUpdateUI, + setChannelUpdateId, + }); + }, [ channelUrl, onCloseClick, onLeaveChannel, onChannelModified, - overrideInviteUser, onBeforeUpdateChannel, - queries, renderUserListItem, - } = props; - const { config, stores } = useSendbirdStateContext(); - const { sdkStore } = stores; - const { logger } = config; - const [channelHandlerId, setChannelHandlerId] = useState(); - - // hack to keep track of channel updates by triggering useEffect - const [channelUpdateId, setChannelUpdateId] = useState(() => uuidv4()); - const forceUpdateUI = () => setChannelUpdateId(uuidv4()); - - const { - response: channel = null, - loading, - error, - refresh, - } = useAsyncRequest( - async () => { - logger.info('ChannelSettings: fetching channel'); - - if (!channelUrl) { - logger.warning('ChannelSettings: channel url is required'); - return; - } else if (!sdkStore.initialized || !sdkStore.sdk) { - logger.warning('ChannelSettings: SDK is not initialized'); - return; - } else if (!sdkStore.sdk.groupChannel) { - logger.warning('ChannelSettings: GroupChannelModule is not specified in the SDK'); - return; - } - - try { - if (channelHandlerId) { - if (sdkStore.sdk?.groupChannel?.removeGroupChannelHandler) { - logger.info('ChannelSettings: Removing message reciver handler', channelHandlerId); - sdkStore.sdk.groupChannel.removeGroupChannelHandler(channelHandlerId); - } else if (sdkStore.sdk?.groupChannel) { - logger.error('ChannelSettings: Not found the removeGroupChannelHandler'); - } - - setChannelHandlerId(undefined); - } - - // FIXME :: refactor below code by new state management protocol - const channel = await sdkStore.sdk.groupChannel.getChannel(channelUrl); - const channelHandler: GroupChannelHandler = { - onUserLeft: (channel, user) => { - if (compareIds(channel?.url, channelUrl)) { - logger.info('ChannelSettings: onUserLeft', { channel, user }); - refresh(); - } - }, - onUserBanned: (channel, user) => { - if (compareIds(channel?.url, channelUrl) && channel.isGroupChannel()) { - logger.info('ChannelSettings: onUserBanned', { channel, user }); - refresh(); - } - }, - }; + queries, + overrideInviteUser, + forceUpdateUI, + ]); - const newChannelHandlerId = uuidv4(); - sdkStore.sdk.groupChannel?.addGroupChannelHandler(newChannelHandlerId, new GroupChannelHandler(channelHandler)); - setChannelHandlerId(newChannelHandlerId); + return null; +}; - return channel; - } catch (error) { - logger.error('ChannelSettings: fetching channel error:', error); - throw error; - } - }, - { - resetResponseOnRefresh: true, - persistLoadingIfNoResponse: true, - deps: [sdkStore.initialized, sdkStore.sdk.groupChannel], - }, +const createChannelSettingsStore = () => createStore(initialState); +const InternalChannelSettingsProvider = ({ children }) => { + const storeRef = useRef(createChannelSettingsStore()); + return ( + + {children} + ); +}; - useEffect(() => { - refresh(); - }, [channelUrl, channelUpdateId]); - +const ChannelSettingsProvider = (props: ChannelSettingsContextProps) => { + const { children, className } = props; return ( - + + -
{children}
+
+ {children} +
-
+ ); }; const useChannelSettingsContext = () => { const context = React.useContext(ChannelSettingsContext); if (!context) throw new Error('ChannelSettingsContext not found. Use within the ChannelSettings module'); - return context; }; + export { ChannelSettingsProvider, useChannelSettingsContext }; diff --git a/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts b/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts new file mode 100644 index 0000000000..b225a4b712 --- /dev/null +++ b/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { GroupChannelHandler } from '@sendbird/chat/groupChannel'; + +import uuidv4 from '../../../../utils/uuid'; +import compareIds from '../../../../utils/compareIds'; +import type { SdkStore } from '../../../../lib/types'; +import type { Logger } from '../../../../lib/SendbirdState'; + +interface UseChannelHandlerProps { + sdk: SdkStore['sdk']; + channelUrl: string; + logger: Logger; + forceUpdateUI: () => void; + dependencies?: any[]; +} + +export const useChannelHandler = ({ + sdk, + channelUrl, + logger, + forceUpdateUI, + dependencies = [], +}: UseChannelHandlerProps) => { + const [channelHandlerId, setChannelHandlerId] = useState(null); + + useEffect(() => { + if (!sdk || !sdk.groupChannel) { + logger.warning('ChannelSettings: SDK or GroupChannelModule is not available'); + return; + } + + const channelHandler = new GroupChannelHandler({ + onUserLeft: (channel, user) => { + if (compareIds(channel?.url, channelUrl)) { + logger.info('ChannelSettings: onUserLeft', { channel, user }); + forceUpdateUI(); + } + }, + onUserBanned: (channel, user) => { + if (compareIds(channel?.url, channelUrl) && channel.isGroupChannel()) { + logger.info('ChannelSettings: onUserBanned', { channel, user }); + forceUpdateUI(); + } + }, + }); + + const newChannelHandlerId = uuidv4(); + sdk.groupChannel.addGroupChannelHandler(newChannelHandlerId, channelHandler); + setChannelHandlerId(newChannelHandlerId); + + return () => { + if (sdk.groupChannel && channelHandlerId) { + logger.info('ChannelSettings: Removing message receiver handler', channelHandlerId); + sdk.groupChannel.removeGroupChannelHandler(channelHandlerId); + } + }; + }, [sdk, channelUrl, logger, ...dependencies]); + + return null; +}; diff --git a/src/modules/ChannelSettings/context/hooks/useSetChannel.ts b/src/modules/ChannelSettings/context/hooks/useSetChannel.ts new file mode 100644 index 0000000000..37b26a2923 --- /dev/null +++ b/src/modules/ChannelSettings/context/hooks/useSetChannel.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import type { Logger } from '../../../../lib/SendbirdState'; +import { SdkStore } from '../../../../lib/types'; +import useChannelSettings from '../useChannelSettings'; + +interface Props { + channelUrl: string; + sdk: SdkStore['sdk']; + logger: Logger; + initialized: boolean; + dependencies?: any[]; +} + +function useSetChannel({ + channelUrl, + sdk, + logger, + initialized, + dependencies = [], +}: Props) { + const { + actions: { + setChannel, + setInvalid, + setLoading, + }, + } = useChannelSettings(); + const logAndStopLoading = (message): void => { + logger.warning(message); + setLoading(false); + return null; + }; + + useEffect(() => { + if (!channelUrl) { + return logAndStopLoading('ChannelSettings: channel url is required'); + } + if (!initialized || !sdk) { + return logAndStopLoading('ChannelSettings: SDK is not initialized'); + } + if (!sdk.groupChannel) { + return logAndStopLoading('ChannelSettings: GroupChannelModule is not specified in the SDK'); + } + + if (channelUrl && sdk?.groupChannel) { + setLoading(true); + sdk.groupChannel.getChannel(channelUrl) + .then((groupChannel) => { + logger.info('ChannelSettings | useSetChannel: fetched group channel', groupChannel); + setChannel(groupChannel); + }) + .catch((err) => { + logger.error('ChannelSettings | useSetChannel: failed fetching channel', err); + setInvalid(true); + }) + .finally(() => { + setLoading(false); + }); + } + }, [channelUrl, initialized, ...dependencies]); +} + +export default useSetChannel; diff --git a/src/modules/ChannelSettings/context/index.tsx b/src/modules/ChannelSettings/context/index.tsx new file mode 100644 index 0000000000..f8196b1ff3 --- /dev/null +++ b/src/modules/ChannelSettings/context/index.tsx @@ -0,0 +1,2 @@ +export { ChannelSettingsProvider, useChannelSettingsContext } from './ChannelSettingsProvider'; +export type { ChannelSettingsContextProps } from './types'; diff --git a/src/modules/ChannelSettings/context/types.ts b/src/modules/ChannelSettings/context/types.ts new file mode 100644 index 0000000000..e3e1bb7865 --- /dev/null +++ b/src/modules/ChannelSettings/context/types.ts @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; +import type { GroupChannel, GroupChannelUpdateParams } from '@sendbird/chat/groupChannel'; +import type { UserListItemProps } from '../../../ui/UserListItem'; +import type { UserProfileProviderProps } from '../../../lib/UserProfileContext'; + +interface ApplicationUserListQuery { + limit?: number; + userIdsFilter?: Array; + metaDataKeyFilter?: string; + metaDataValuesFilter?: Array; +} + +interface ChannelSettingsQueries { + applicationUserListQuery?: ApplicationUserListQuery; +} + +type OverrideInviteUserType = { + users: Array; + onClose: () => void; + channel: GroupChannel; +}; + +interface CommonChannelSettingsProps { + channelUrl: string; + onCloseClick?(): void; + onLeaveChannel?(): void; + overrideInviteUser?(params: OverrideInviteUserType): void; + onChannelModified?(channel: GroupChannel): void; + onBeforeUpdateChannel?(currentTitle: string, currentImg: File | null, data: string | undefined): GroupChannelUpdateParams; + queries?: ChannelSettingsQueries; + renderUserListItem?: (props: UserListItemProps) => ReactNode; +} + +export interface ChannelSettingsState extends CommonChannelSettingsProps { + channel: GroupChannel | null; + loading: boolean; + invalidChannel: boolean; + forceUpdateUI(): void; + setChannelUpdateId(uniqId: string): void; +} + +export interface ChannelSettingsContextProps extends + CommonChannelSettingsProps, + Pick { + children?: React.ReactElement; + className?: string; +} diff --git a/src/modules/ChannelSettings/context/useChannelSettings.ts b/src/modules/ChannelSettings/context/useChannelSettings.ts new file mode 100644 index 0000000000..3d67202be2 --- /dev/null +++ b/src/modules/ChannelSettings/context/useChannelSettings.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import type { GroupChannel } from '@sendbird/chat/groupChannel'; + +import { useChannelSettingsContext } from './ChannelSettingsProvider'; +import { ChannelSettingsState } from './types'; + +export const useChannelSettings = () => { + const store = useChannelSettingsContext(); + if (!store) throw new Error('useChannelSettings must be used within a ChannelSettingsProvider'); + + const state: ChannelSettingsState = useSyncExternalStore(store.subscribe, store.getState); + const actions = useMemo(() => ({ + setChannel: (channel: GroupChannel) => store.setState(state => ({ + ...state, + channel, + })), + + setLoading: (loading: boolean) => store.setState((state): ChannelSettingsState => ({ + ...state, + loading, + })), + + setInvalid: (invalid: boolean) => store.setState((state): ChannelSettingsState => ({ + ...state, + invalidChannel: invalid, + })), + }), [store]); + + return { state, actions }; +}; + +export default useChannelSettings; diff --git a/src/modules/ChannelSettings/index.tsx b/src/modules/ChannelSettings/index.tsx index edf7a0fb04..7db161d92f 100644 --- a/src/modules/ChannelSettings/index.tsx +++ b/src/modules/ChannelSettings/index.tsx @@ -3,10 +3,7 @@ import ChannelSettingsUI, { ChannelSettingsUIProps, } from './components/ChannelSettingsUI'; -import { - ChannelSettingsProvider, - ChannelSettingsContextProps, -} from './context/ChannelSettingsProvider'; +import { ChannelSettingsProvider, ChannelSettingsContextProps } from './context/index'; interface ChannelSettingsProps extends ChannelSettingsContextProps, Omit { } diff --git a/src/utils/storeManager.ts b/src/utils/storeManager.ts index ce4fbae6f2..166a3c853b 100644 --- a/src/utils/storeManager.ts +++ b/src/utils/storeManager.ts @@ -9,12 +9,12 @@ export type Store = { * A custom store creation utility */ export function createStore(initialState: T): Store { - const state = { ...initialState }; + let state = { ...initialState }; const listeners = new Set<() => void>(); const setState = (partial: Partial | ((state: T) => Partial)) => { const nextState = typeof partial === 'function' ? partial(state) : partial; - Object.assign(state, nextState); + state = { ...state, ...nextState }; listeners.forEach((listener) => listener()); }; From 4689d795b65867d547494157a5e3a53b29663a83 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Mon, 14 Oct 2024 11:04:22 +0900 Subject: [PATCH 03/29] [CLNP-5043] Add unit tests for improved MessageSearch module (#1228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://sendbird.atlassian.net/browse/CLNP-5043 Two things are handled based on what I mentioned in https://sendbird.atlassian.net/wiki/spaces/UIKitreact/pages/2511765635/UIKit+React+new+State+Management+Method+Proposal#4.1-Unit-Test - [x] Added unit tests for `useMessageSearch` hook and new `MessageSearchProvider` - [x] Added integration tests for `MessageSearchUI` component So the MessageSearch module test coverage has been changed **from** File --------------------------------------------------| % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s Screenshot 2024-10-08 at 2 36 55 PM **to** after note that it used to be like 0%, but now the test coverage of the newly added files is almost 100%; green 🟩. ### Checklist Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If unsure, ask the members. This is a reminder of what we look for before merging your code. - [x] **All tests pass locally with my changes** - [x] **I have added tests that prove my fix is effective or that my feature works** - [ ] **Public components / utils / props are appropriately exported** - [ ] I have added necessary documentation (if appropriate) --- .../MessageSearchUI.integration.test.tsx | 151 ++++++++++ .../components/MessageSearchUI/index.tsx | 4 +- .../context/MessageSearchProvider.tsx | 4 +- .../__test__/MessageSearchProvider.spec.tsx | 281 ++++++++++++++++++ .../__test__/useMessageSearch.spec.tsx | 131 ++++++++ .../MessageSearch/context/dux/actionTypes.ts | 9 - .../MessageSearch/context/dux/initialState.ts | 28 -- .../MessageSearch/context/dux/reducers.ts | 90 ------ .../context/hooks/useMessageSearch.ts | 6 +- 9 files changed, 570 insertions(+), 134 deletions(-) create mode 100644 src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx create mode 100644 src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx create mode 100644 src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx delete mode 100644 src/modules/MessageSearch/context/dux/actionTypes.ts delete mode 100644 src/modules/MessageSearch/context/dux/initialState.ts delete mode 100644 src/modules/MessageSearch/context/dux/reducers.ts diff --git a/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx b/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx new file mode 100644 index 0000000000..5b7b074dc1 --- /dev/null +++ b/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import MessageSearchUI from '../components/MessageSearchUI'; +import { LocalizationContext } from '../../../lib/LocalizationContext'; +import * as useMessageSearchModule from '../context/hooks/useMessageSearch'; + +jest.mock('../context/hooks/useMessageSearch'); + +const mockStringSet = { + SEARCH_IN: 'Search in', + SEARCH_PLACEHOLDER: 'Search', + SEARCHING: 'Searching...', + NO_SEARCHED_MESSAGE: 'No results found', + NO_TITLE: 'No title', + PLACE_HOLDER__RETRY_TO_CONNECT: 'Retry', +}; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + isQueryInvalid: false, + searchString: '', + requestString: '', + currentChannel: null, + loading: false, + scrollRef: { current: null }, + hasMoreResult: false, + onScroll: jest.fn(), + allMessages: [], + onResultClick: jest.fn(), + selectedMessageId: null, +}; + +const defaultMockActions = { + setSelectedMessageId: jest.fn(), + handleRetryToConnect: jest.fn(), +}; + +describe('MessageSearchUI Integration Tests', () => { + const mockUseMessageSearch = useMessageSearchModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}) => { + mockUseMessageSearch.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders initial state correctly', () => { + renderComponent(); + expect(screen.getByText('Search in')).toBeInTheDocument(); + }); + + it('displays loading state when search is in progress', () => { + renderComponent({ loading: true, searchString: 'test query', requestString: 'test query' }); + expect(screen.getByText(mockStringSet.SEARCHING)).toBeInTheDocument(); + }); + + it('displays search results when available', () => { + renderComponent({ + allMessages: [ + { messageId: 1, message: 'Message 1', sender: { nickname: 'Sender 1' } }, + { messageId: 2, message: 'Message 2', sender: { nickname: 'Sender 2' } }, + ], + searchString: 'test query', + }); + expect(screen.getByText('Message 1')).toBeInTheDocument(); + expect(screen.getByText('Message 2')).toBeInTheDocument(); + }); + + it('handles no results state', () => { + renderComponent({ allMessages: [], searchString: 'no results query', requestString: 'no results query' }); + expect(screen.getByText(mockStringSet.NO_SEARCHED_MESSAGE)).toBeInTheDocument(); + }); + + it('handles error state and retry', async () => { + const handleRetryToConnect = jest.fn(); + renderComponent( + { isQueryInvalid: true, searchString: 'error query', requestString: 'error query' }, + { handleRetryToConnect }, + ); + expect(screen.getByText(mockStringSet.PLACE_HOLDER__RETRY_TO_CONNECT)).toBeInTheDocument(); + + const retryButton = screen.getByText('Retry'); + fireEvent.click(retryButton); + + expect(handleRetryToConnect).toHaveBeenCalled(); + }); + + it('triggers loading more messages when scrolled near bottom', async () => { + const onScroll = jest.fn(); + const loadMoreMessages = jest.fn(); + const { container } = renderComponent({ + allMessages: [{ messageId: 1, message: 'Message 1' }], + hasMoreResult: true, + onScroll, + }); + + const scrollContainer = container.firstChild as Element; + + // define scroll container properties + Object.defineProperty(scrollContainer, 'scrollHeight', { configurable: true, value: 300 }); + Object.defineProperty(scrollContainer, 'clientHeight', { configurable: true, value: 500 }); + Object.defineProperty(scrollContainer, 'scrollTop', { configurable: true, value: 450 }); + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + + if (scrollTop + clientHeight >= scrollHeight - 1) { + loadMoreMessages(); + } + }; + + fireEvent.scroll(scrollContainer); + handleScroll({ currentTarget: scrollContainer } as React.UIEvent); + + expect(loadMoreMessages).toHaveBeenCalled(); + }); + + it('handles message click', () => { + const setSelectedMessageId = jest.fn(); + const onResultClick = jest.fn(); + renderComponent( + { + allMessages: [{ messageId: 1, message: 'Message 1', sender: { nickname: 'Sender 1' } }], + searchString: 'Message 1', + onResultClick, + }, + { setSelectedMessageId }, + ); + + const message = screen.getByText('Message 1'); + fireEvent.click(message); + + expect(setSelectedMessageId).toHaveBeenCalledWith(1); + expect(onResultClick).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx index fb54b86e3b..e81c497604 100644 --- a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx +++ b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx @@ -35,7 +35,7 @@ export const MessageSearchUI: React.FC = ({ }: MessageSearchUIProps) => { const { state: { - isInvalid, + isQueryInvalid, searchString, requestString, currentChannel, @@ -83,7 +83,7 @@ export const MessageSearchUI: React.FC = ({ return stringSet.NO_TITLE; }; - if (isInvalid && searchString && requestString) { + if (isQueryInvalid && searchString && requestString) { return renderPlaceHolderError?.() || (
({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + createMessageSearchQuery: jest.fn(() => ({ + next: jest.fn().mockResolvedValue([{ messageId: 1 }]), + })), + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +jest.mock('../hooks/useSetChannel', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => Promise.resolve()), +})); + +jest.mock('../hooks/useGetSearchedMessages', () => ({ + __esModule: true, + default: jest.fn(() => jest.fn()), +})); + +jest.mock('../hooks/useScrollCallback', () => ({ + __esModule: true, + default: jest.fn(() => jest.fn()), +})); + +jest.mock('../hooks/useSearchStringEffect', () => ({ + __esModule: true, + default: jest.fn(() => ''), +})); + +describe('MessageSearchProvider', () => { + const initialState = { + allMessages: [], + loading: false, + isQueryInvalid: false, + initialized: false, + currentChannel: null, + currentMessageSearchQuery: null, + hasMoreResult: false, + channelUrl: 'test-channel', + searchString: undefined, + retryCount: 0, + selectedMessageId: null, + handleOnScroll: expect.any(Function), + onScroll: expect.any(Function), + onResultClick: undefined, + messageSearchQuery: undefined, + scrollRef: { current: null }, + requestString: '', + }; + + it('provides the correct initial state', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearchContext(), { wrapper }); + + expect(result.current.getState()).toEqual(expect.objectContaining(initialState)); + }); + + it('updates state correctly when props change', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearchContext(), { wrapper }); + + expect(result.current.getState().channelUrl).toBe('test-channel'); + + await act(async () => { + result.current.setState({ channelUrl: 'new-channel' }); + await waitFor(() => { + const newState = result.current.getState(); + expect(newState.channelUrl).toBe('new-channel'); + // Verify other states remain unchanged + expect(newState.allMessages).toEqual(initialState.allMessages); + expect(newState.loading).toBe(initialState.loading); + expect(newState.isQueryInvalid).toBe(initialState.isQueryInvalid); + }); + }); + }); + + it('provides correct actions through useMessageSearch hook', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + expect(result.current.actions).toHaveProperty('setCurrentChannel'); + expect(result.current.actions).toHaveProperty('getSearchedMessages'); + expect(result.current.actions).toHaveProperty('startMessageSearch'); + expect(result.current.actions).toHaveProperty('setQueryInvalid'); + expect(result.current.actions).toHaveProperty('startGettingSearchedMessages'); + expect(result.current.actions).toHaveProperty('getNextSearchedMessages'); + expect(result.current.actions).toHaveProperty('resetSearchString'); + }); + + it('updates state correctly when setCurrentChannel is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + const newChannel = { url: 'test-channel' }; + + await act(async () => { + result.current.actions.setCurrentChannel(newChannel as any); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.currentChannel).toEqual(newChannel); + expect(updatedState.initialized).toBe(true); + // Verify other states remain unchanged + expect(updatedState.loading).toBe(initialState.loading); + expect(updatedState.isQueryInvalid).toBe(initialState.isQueryInvalid); + expect(updatedState.allMessages).toEqual(initialState.allMessages); + expect(updatedState.hasMoreResult).toBe(initialState.hasMoreResult); + }); + }); + }); + + it('updates state correctly when startMessageSearch is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + await act(async () => { + result.current.actions.startMessageSearch(); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.loading).toBe(false); + expect(updatedState.isQueryInvalid).toBe(false); + // Verify other states remain unchanged + expect(updatedState.allMessages).toEqual(initialState.allMessages); + expect(updatedState.currentChannel).toBe(initialState.currentChannel); + expect(updatedState.hasMoreResult).toBe(initialState.hasMoreResult); + }); + }); + }); + + it('updates state correctly when getSearchedMessages is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + const messages = [{ messageId: 1 }, { messageId: 2 }]; + const mockQuery = { channelUrl: 'test-channel', hasNext: true }; + + await act(async () => { + result.current.actions.startGettingSearchedMessages(mockQuery as any); + result.current.actions.getSearchedMessages(messages as any, mockQuery as any); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.allMessages).toEqual(messages); + expect(updatedState.loading).toBe(false); + expect(updatedState.hasMoreResult).toBe(true); + expect(updatedState.currentMessageSearchQuery).toEqual(mockQuery); + // Verify other states remain unchanged + expect(updatedState.isQueryInvalid).toBe(initialState.isQueryInvalid); + expect(updatedState.initialized).toBe(initialState.initialized); + }); + }); + }); + + it('calls onResultLoaded when search results are loaded', async () => { + const onResultLoaded = jest.fn(); + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + await act(async () => { + result.current.actions.setCurrentChannel({ url: 'test-channel' } as any); + result.current.actions.startMessageSearch(); + const mockQuery = { + channelUrl: 'test-channel', + key: 'test-key', + hasNext: true, + }; + result.current.actions.startGettingSearchedMessages(mockQuery as any); + + await waitFor(() => { + expect(result.current.state.loading).toBe(true); + expect(result.current.state.currentMessageSearchQuery).toEqual(mockQuery); + }); + + const messages = [{ messageId: 1 }]; + result.current.actions.getSearchedMessages(messages as any, mockQuery as any); + + await waitFor(() => { + expect(result.current.state.loading).toBe(false); + expect(result.current.state.allMessages).toEqual(messages); + expect(result.current.state.hasMoreResult).toBe(true); + }); + }); + }); + + it('updates state correctly when setQueryInvalid is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + await act(async () => { + result.current.actions.setQueryInvalid(); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.isQueryInvalid).toBe(true); + // Verify other states remain unchanged + expect(updatedState.allMessages).toEqual(initialState.allMessages); + expect(updatedState.loading).toBe(initialState.loading); + expect(updatedState.currentChannel).toBe(initialState.currentChannel); + }); + }); + }); + + it('updates state correctly when resetSearchString is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + const mockQuery = { channelUrl: 'test-channel', hasNext: false } as MessageSearchQuery; + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + await act(async () => { + result.current.actions.startGettingSearchedMessages(mockQuery); + result.current.actions.getSearchedMessages([{ messageId: 1 }] as any, {} as any); + result.current.actions.resetSearchString(); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.allMessages).toEqual([]); + // Verify other states remain unchanged + expect(updatedState.isQueryInvalid).toBe(initialState.isQueryInvalid); + expect(updatedState.currentChannel).toBe(initialState.currentChannel); + }); + }); + }); +}); diff --git a/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx b/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx new file mode 100644 index 0000000000..a2fd2abc22 --- /dev/null +++ b/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { MessageSearchProvider } from '../../context/MessageSearchProvider'; +import useMessageSearch from '../../context/hooks/useMessageSearch'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { MessageSearchQuery } from '@sendbird/chat/message'; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + createMessageSearchQuery: jest.fn(() => ({ + next: jest.fn().mockResolvedValue([{ messageId: 1 }]), + })), + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +describe('useMessageSearch', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + it('provides initial state', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + expect(result.current.state).toEqual(expect.objectContaining({ + allMessages: [], + loading: false, + isQueryInvalid: false, + initialized: false, + currentChannel: null, + currentMessageSearchQuery: null, + hasMoreResult: false, + })); + }); + + it('updates state when setCurrentChannel is called', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + act(() => { + result.current.actions.setCurrentChannel({ url: 'test-channel' } as GroupChannel); + }); + + expect(result.current.state.currentChannel).toEqual({ url: 'test-channel' }); + expect(result.current.state.initialized).toBe(true); + }); + + it('updates state when startMessageSearch is called', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + act(() => { + result.current.actions.startMessageSearch(); + }); + + expect(result.current.state.isQueryInvalid).toBe(false); + expect(result.current.state.loading).toBe(false); + }); + + it('updates state when getSearchedMessages is called', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + const messages = [{ messageId: 1 }, { messageId: 2 }]; + const mockQuery = { channelUrl: 'test-channel', hasNext: true } as MessageSearchQuery; + + act(() => { + result.current.actions.startGettingSearchedMessages(mockQuery); + result.current.actions.getSearchedMessages(messages as any, mockQuery); + }); + + expect(result.current.state.allMessages).toEqual(messages); + expect(result.current.state.loading).toBe(false); + expect(result.current.state.hasMoreResult).toBe(true); + }); + + it('updates state when setQueryInvalid is called', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + act(() => { + result.current.actions.setQueryInvalid(); + }); + + expect(result.current.state.isQueryInvalid).toBe(true); + }); + + it('updates state when getNextSearchedMessages is called', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + const initialMessages = [{ messageId: 1 }]; + const newMessages = [{ messageId: 2 }, { messageId: 3 }]; + const mockQuery = { channelUrl: 'test-channel', hasNext: false } as MessageSearchQuery; + + act(() => { + result.current.actions.startGettingSearchedMessages(mockQuery); + result.current.actions.getSearchedMessages(initialMessages as any, mockQuery); + result.current.actions.getNextSearchedMessages(newMessages as any); + }); + + expect(result.current.state.allMessages).toEqual([...initialMessages, ...newMessages]); + expect(result.current.state.hasMoreResult).toBe(false); + }); + + it('updates state when resetSearchString is called', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + const mockQuery = { channelUrl: 'test-channel', hasNext: false } as MessageSearchQuery; + + act(() => { + result.current.actions.startGettingSearchedMessages(mockQuery); + result.current.actions.getSearchedMessages([{ messageId: 1 }] as any, {} as any); + result.current.actions.resetSearchString(); + }); + + expect(result.current.state.allMessages).toEqual([]); + }); + + it('updates state when handleRetryToConnect is called', () => { + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + act(() => { + result.current.actions.handleRetryToConnect(); + }); + + expect(result.current.state.retryCount).toBe(1); + }); +}); diff --git a/src/modules/MessageSearch/context/dux/actionTypes.ts b/src/modules/MessageSearch/context/dux/actionTypes.ts deleted file mode 100644 index e5b0188434..0000000000 --- a/src/modules/MessageSearch/context/dux/actionTypes.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const GET_SEARCHED_MESSAGES = 'GET_SEARCHED_MESSAGES'; -export const GET_NEXT_SEARCHED_MESSAGES = 'GET_NEXT_SEARCHED_MESSAGES'; -export const START_MESSAGE_SEARCH = 'START_MESSAGE_SEARCH'; -export const START_GETTING_SEARCHED_MESSAGES = 'START_GETTING_SEARCHED_MESSAGES'; -export const SET_QUERY_INVALID = 'SET_QUERY_INVALID'; - -export const SET_CURRENT_CHANNEL = 'SET_CURRENT_CHANNEL'; -export const CHANNEL_INVALID = 'CHANNEL_INVALID'; -export const RESET_SEARCH_STRING = 'RESET_SEARCH_STRING'; diff --git a/src/modules/MessageSearch/context/dux/initialState.ts b/src/modules/MessageSearch/context/dux/initialState.ts deleted file mode 100644 index 9809d1254d..0000000000 --- a/src/modules/MessageSearch/context/dux/initialState.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { GroupChannel } from '@sendbird/chat/groupChannel'; -import { MessageSearchQuery } from '@sendbird/chat/message'; -import { - ClientFileMessage, - ClientUserMessage, -} from '../../../../types'; - -export interface State { - allMessages: Array; - loading: boolean; - isInvalid: boolean; - initialized: boolean; - currentChannel: GroupChannel; - currentMessageSearchQuery: MessageSearchQuery; - hasMoreResult: boolean; -} - -const initialState: State = { - allMessages: [], - loading: false, - isInvalid: false, - initialized: false, - currentChannel: null, - currentMessageSearchQuery: null, - hasMoreResult: false, -}; - -export default initialState; diff --git a/src/modules/MessageSearch/context/dux/reducers.ts b/src/modules/MessageSearch/context/dux/reducers.ts deleted file mode 100644 index a177ecb337..0000000000 --- a/src/modules/MessageSearch/context/dux/reducers.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { State as initialStateInterface } from './initialState'; -import type { MessageSearchQuery } from '@sendbird/chat/message'; -import * as actionTypes from './actionTypes'; - -interface MessageSearchQueryType extends MessageSearchQuery { - key?: string; -} -interface ActionInterface { - type: string; - /* eslint-disable @typescript-eslint/no-explicit-any */ - payload?: any; -} - -export default function reducer( - state: initialStateInterface, - action: ActionInterface, -): initialStateInterface { - switch (action.type) { - case actionTypes.SET_CURRENT_CHANNEL: { - const currentChannel = action.payload; - return { - ...state, - currentChannel, - initialized: true, - }; - } - case actionTypes.CHANNEL_INVALID: { - return { - ...state, - currentChannel: null, - initialized: false, - }; - } - case actionTypes.GET_SEARCHED_MESSAGES: { - const { messages, createdQuery } = action.payload; - if ( - createdQuery - && createdQuery.channelUrl === (state?.currentMessageSearchQuery as MessageSearchQueryType).channelUrl - && createdQuery.key === (state?.currentMessageSearchQuery as MessageSearchQueryType).key - ) { - return { - ...state, - loading: false, - isInvalid: false, - allMessages: [...messages], - hasMoreResult: (state?.currentMessageSearchQuery as MessageSearchQueryType).hasNext, - }; - } - return { ...state }; - } - case actionTypes.SET_QUERY_INVALID: { - return { - ...state, - isInvalid: true, - }; - } - case actionTypes.START_MESSAGE_SEARCH: { - return { - ...state, - isInvalid: false, - loading: false, - }; - } - case actionTypes.START_GETTING_SEARCHED_MESSAGES: { - const currentMessageSearchQuery = action.payload; - return { - ...state, - loading: true, - currentMessageSearchQuery, - }; - } - case actionTypes.GET_NEXT_SEARCHED_MESSAGES: { - const messages = action.payload; - return { - ...state, - allMessages: [...state.allMessages, ...messages], - hasMoreResult: (state?.currentMessageSearchQuery as MessageSearchQuery).hasNext, - }; - } - case actionTypes.RESET_SEARCH_STRING: { - return { - ...state, - allMessages: [], - }; - } - default: { - return state; - } - } -} diff --git a/src/modules/MessageSearch/context/hooks/useMessageSearch.ts b/src/modules/MessageSearch/context/hooks/useMessageSearch.ts index 91bf3d4c22..14eab43384 100644 --- a/src/modules/MessageSearch/context/hooks/useMessageSearch.ts +++ b/src/modules/MessageSearch/context/hooks/useMessageSearch.ts @@ -31,7 +31,7 @@ const useMessageSearch = () => { return { ...state, loading: false, - isInvalid: false, + isQueryInvalid: false, allMessages: messages, hasMoreResult: state.currentMessageSearchQuery.hasNext, }; @@ -40,11 +40,11 @@ const useMessageSearch = () => { }); }, - setQueryInvalid: () => store.setState(state => ({ ...state, isInvalid: true })), + setQueryInvalid: () => store.setState(state => ({ ...state, isQueryInvalid: true })), startMessageSearch: () => store.setState(state => ({ ...state, - isInvalid: false, + isQueryInvalid: false, loading: false, })), From 1e980b8f3d76d1e5293fd20614b0a61ae167a488 Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Fri, 18 Oct 2024 08:57:16 +0900 Subject: [PATCH 04/29] refactor: GroupChannelList migration (#1231) This is a part of state management migration. This PR migrate 'GroupChannelListProvider' and related files to the new style. * Created `useGroupChannelList()` hook. It'll replace the previous `useGroupChannelContext()` hook. --- .../components/AddGroupChannel/index.tsx | 10 +++++-- .../components/GroupChannelListItem/index.tsx | 9 +++++-- .../components/GroupChannelListUI/index.tsx | 26 ++++++++++--------- .../context/useGroupChannelList.ts | 14 ++++++++++ 4 files changed, 43 insertions(+), 16 deletions(-) create mode 100644 src/modules/GroupChannelList/context/useGroupChannelList.ts diff --git a/src/modules/GroupChannelList/components/AddGroupChannel/index.tsx b/src/modules/GroupChannelList/components/AddGroupChannel/index.tsx index 3e6e48feea..920f85bf0a 100644 --- a/src/modules/GroupChannelList/components/AddGroupChannel/index.tsx +++ b/src/modules/GroupChannelList/components/AddGroupChannel/index.tsx @@ -1,10 +1,16 @@ import React, { useState } from 'react'; import AddGroupChannelView from './AddGroupChannelView'; -import { useGroupChannelListContext } from '../../context/GroupChannelListProvider'; +import { useGroupChannelList } from '../../context/useGroupChannelList'; export const AddGroupChannel = () => { const [createChannelVisible, setCreateChannelVisible] = useState(false); - const { onChannelCreated, onBeforeCreateChannel, onCreateChannelClick } = useGroupChannelListContext(); + const { + state: { + onChannelCreated, + onBeforeCreateChannel, + onCreateChannelClick, + }, + } = useGroupChannelList(); return ( { const { config } = useSendbirdStateContext(); const { stringSet } = useLocalization(); - const { isTypingIndicatorEnabled = false, isMessageReceiptStatusEnabled = false } = useGroupChannelListContext(); + const { + state: { + isTypingIndicatorEnabled = false, + isMessageReceiptStatusEnabled = false, + }, + } = useGroupChannelList(); const userId = config.userId; const isMessageStatusEnabled = isMessageReceiptStatusEnabled diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx index 65bdb98950..2136b03589 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx @@ -2,7 +2,6 @@ import './index.scss'; import React from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; -import { useGroupChannelListContext } from '../../context/GroupChannelListProvider'; import { GroupChannelListUIView } from './GroupChannelListUIView'; import GroupChannelPreviewAction from '../GroupChannelPreviewAction'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; @@ -10,6 +9,7 @@ import { GroupChannelListItem } from '../GroupChannelListItem'; import AddGroupChannel from '../AddGroupChannel'; import { GroupChannelListItemBasicProps } from '../GroupChannelListItem/GroupChannelListItemView'; import { noop } from '../../../../utils/utils'; +import { useGroupChannelList } from '../../context/useGroupChannelList'; interface GroupChannelItemProps extends GroupChannelListItemBasicProps {} @@ -25,17 +25,19 @@ export const GroupChannelListUI = (props: GroupChannelListUIProps) => { const { renderHeader, renderChannelPreview, renderPlaceHolderError, renderPlaceHolderLoading, renderPlaceHolderEmptyList } = props; const { - onChannelSelect, - onThemeChange, - allowProfileEdit, - typingChannelUrls, - groupChannels, - initialized, - selectedChannelUrl, - loadMore, - onUserProfileUpdated, - scrollRef, - } = useGroupChannelListContext(); + state: { + onChannelSelect, + onThemeChange, + allowProfileEdit, + typingChannelUrls, + groupChannels, + initialized, + selectedChannelUrl, + loadMore, + onUserProfileUpdated, + scrollRef, + }, + } = useGroupChannelList(); const { stores, config } = useSendbirdStateContext(); const { logger, isOnline } = config; diff --git a/src/modules/GroupChannelList/context/useGroupChannelList.ts b/src/modules/GroupChannelList/context/useGroupChannelList.ts new file mode 100644 index 0000000000..54543fd977 --- /dev/null +++ b/src/modules/GroupChannelList/context/useGroupChannelList.ts @@ -0,0 +1,14 @@ +import { GroupChannelListState, useGroupChannelListContext } from './GroupChannelListProvider'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useMemo } from 'react'; + +export const useGroupChannelList = () => { + const store = useGroupChannelListContext(); + if (!store) throw new Error('useChannelSettings must be used within a ChannelSettingsProvider'); + + const state: GroupChannelListState = useSyncExternalStore(store.subscribe, store.getState); + const actions = useMemo(() => ({ + }), [store]); + + return { state, actions }; +}; From 3bd9b40fa9549a0d43ecd297fad30b4c53093842 Mon Sep 17 00:00:00 2001 From: Baek EunSeo Date: Tue, 22 Oct 2024 14:04:59 +0900 Subject: [PATCH 05/29] refactor: Add tests for ChannelSettings migration (#1234) fix: Prevent destroy error by adding `AbortController` in `useSetChannel` - Added `AbortController` to cancel async operations when the component unmounts. - Ensured that state updates are skipped if the operation is aborted. - Prevented potential memory leaks and the `'destroy is not a function'` error. - Updated `useEffect` cleanup to properly handle pending async calls. feat: Add tests for ChannelSettings migration --- .../__test__/ChannelSettingsProvider.spec.tsx | 93 ++++++++++++++++++ .../ChannelSettingsUI.integration.test.tsx | 96 +++++++++++++++++++ .../__test__/useChannelHandler.spec.ts | 78 +++++++++++++++ .../__test__/useChannelSettings.spec.ts | 86 +++++++++++++++++ .../__test__/useSetChannel.spec.ts | 90 +++++++++++++++++ .../context/hooks/useChannelHandler.ts | 13 +-- .../context/hooks/useSetChannel.ts | 74 +++++++------- 7 files changed, 488 insertions(+), 42 deletions(-) create mode 100644 src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx create mode 100644 src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx create mode 100644 src/modules/ChannelSettings/__test__/useChannelHandler.spec.ts create mode 100644 src/modules/ChannelSettings/__test__/useChannelSettings.spec.ts create mode 100644 src/modules/ChannelSettings/__test__/useSetChannel.spec.ts diff --git a/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx new file mode 100644 index 0000000000..0438dd5dd7 --- /dev/null +++ b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { ChannelSettingsProvider, useChannelSettingsContext } from '../context/ChannelSettingsProvider'; +import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; +import { SendbirdSdkContext } from '../../../lib/SendbirdSdkContext'; + +jest.mock('../../../hooks/useSendbirdStateContext'); +jest.mock('../context/hooks/useSetChannel'); + +const mockLogger = { + warning: jest.fn(), + info: jest.fn(), + error: jest.fn(), +}; + +const initialState = { + channelUrl: 'test-channel', + onCloseClick: undefined, + onLeaveChannel: undefined, + onChannelModified: undefined, + onBeforeUpdateChannel: undefined, + renderUserListItem: undefined, + queries: undefined, + overrideInviteUser: undefined, + channel: null, + loading: false, + invalidChannel: false, + forceUpdateUI: expect.any(Function), + setChannelUpdateId: expect.any(Function), +}; + +describe('ChannelSettingsProvider', () => { + let wrapper; + + beforeEach(() => { + useSendbirdStateContext.mockReturnValue({ + stores: { sdkStore: { sdk: {}, initialized: true } }, + config: { logger: mockLogger }, + }); + + wrapper = ({ children }) => ( + + + {children} + + + ); + + jest.clearAllMocks(); + }); + + it('provides the correct initial state', () => { + const { result } = renderHook(() => useChannelSettingsContext(), { wrapper }); + + expect(result.current.getState()).toEqual(expect.objectContaining(initialState)); + }); + + it('logs a warning if SDK is not initialized', () => { + useSendbirdStateContext.mockReturnValue({ + stores: { sdkStore: { sdk: null, initialized: false } }, + config: { logger: mockLogger }, + }); + + renderHook(() => useChannelSettingsContext(), { wrapper }); + + expect(mockLogger.warning).toHaveBeenCalledWith('ChannelSettings: SDK or GroupChannelModule is not available'); + }); + + it('updates state correctly when setChannelUpdateId is called', async () => { + const { result } = renderHook(() => useChannelSettingsContext(), { wrapper }); + + await act(async () => { + result.current.setState({ channelUrl: 'new-channel' }); + await waitForStateUpdate(); + expect(result.current.getState().channelUrl).toBe('new-channel'); + }); + }); + + it('maintains other state values when channel changes', async () => { + const { result } = renderHook(() => useChannelSettingsContext(), { wrapper }); + + await act(async () => { + result.current.setState({ channel: { name: 'Updated Channel' } }); + await waitForStateUpdate(); + const updatedState = result.current.getState(); + expect(updatedState.channel).toEqual({ name: 'Updated Channel' }); + expect(updatedState.loading).toBe(false); + expect(updatedState.invalidChannel).toBe(false); + }); + }); + + const waitForStateUpdate = () => new Promise(resolve => { setTimeout(resolve, 0); }); +}); diff --git a/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx new file mode 100644 index 0000000000..d31914917e --- /dev/null +++ b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { render, screen } from '@testing-library/react'; + +import ChannelSettingsUI from '../components/ChannelSettingsUI'; +import { LocalizationContext } from '../../../lib/LocalizationContext'; +import * as useChannelSettingsModule from '../context/useChannelSettings'; +import { SendbirdSdkContext } from '../../../lib/SendbirdSdkContext'; + +jest.mock('../context/useChannelSettings'); + +const mockStringSet = { + CHANNEL_SETTING__HEADER__TITLE: 'Channel information', + CHANNEL_SETTING__OPERATORS__TITLE: 'Operators', + CHANNEL_SETTING__MEMBERS__TITLE: 'Members', + CHANNEL_SETTING__MUTED_MEMBERS__TITLE: 'Muted members', + CHANNEL_SETTING__BANNED_MEMBERS__TITLE: 'Banned users', + CHANNEL_SETTING__FREEZE_CHANNEL: 'Freeze Channel', + CHANNEL_SETTING__LEAVE_CHANNEL__TITLE: 'Leave channel', +}; +const mockChannelName = 'Test Channel'; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + channel: { name: mockChannelName, members: [], isBroadcast: false }, + loading: false, + invalidChannel: false, +}; + +const defaultMockActions = { + setChannel: jest.fn(), + setLoading: jest.fn(), + setInvalid: jest.fn(), +}; + +describe('ChannelSettings Integration Tests', () => { + const mockUseChannelSettings = useChannelSettingsModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}) => { + mockUseChannelSettings.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + return render( + + + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all necessary texts correctly', () => { + renderComponent(); + + expect(screen.getByText(mockChannelName)).toBeInTheDocument(); + expect(screen.getByText(mockStringSet.CHANNEL_SETTING__HEADER__TITLE)).toBeInTheDocument(); + expect(screen.getByText(mockStringSet.CHANNEL_SETTING__LEAVE_CHANNEL__TITLE)).toBeInTheDocument(); + }); + + it('does not display texts when loading or invalidChannel is true', () => { + // Case 1: loading = true + renderComponent({ loading: true }); + + expect(screen.queryByText(mockChannelName)).not.toBeInTheDocument(); + expect(screen.queryByText(mockStringSet.CHANNEL_SETTING__HEADER__TITLE)).not.toBeInTheDocument(); + expect(screen.queryByText(mockStringSet.CHANNEL_SETTING__LEAVE_CHANNEL__TITLE)).not.toBeInTheDocument(); + + // Clear the render for the next case + jest.clearAllMocks(); + renderComponent({ invalidChannel: true }); + + // Case 2: invalidChannel = true + expect(screen.queryByText(mockChannelName)).not.toBeInTheDocument(); + expect(screen.queryByText(mockStringSet.CHANNEL_SETTING__HEADER__TITLE)).toBeInTheDocument(); // render Header + expect(screen.queryByText(mockStringSet.CHANNEL_SETTING__LEAVE_CHANNEL__TITLE)).not.toBeInTheDocument(); + }); + + it('calls setChannel with the correct channel object', () => { + const setChannel = jest.fn(); + renderComponent({}, { setChannel }); + + const newChannel = { name: 'New Channel', members: [] }; + setChannel(newChannel); + + expect(setChannel).toHaveBeenCalledWith(newChannel); + }); +}); diff --git a/src/modules/ChannelSettings/__test__/useChannelHandler.spec.ts b/src/modules/ChannelSettings/__test__/useChannelHandler.spec.ts new file mode 100644 index 0000000000..b00992fe9b --- /dev/null +++ b/src/modules/ChannelSettings/__test__/useChannelHandler.spec.ts @@ -0,0 +1,78 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { GroupChannelHandler } from '@sendbird/chat/groupChannel'; +import { useChannelHandler } from '../context/hooks/useChannelHandler'; + +// jest.mock('../../../utils/uuid', () => ({ +// v4: jest.fn(() => 'mock-uuid'), +// })); + +const mockLogger = { + warning: jest.fn(), + info: jest.fn(), + error: jest.fn(), +}; + +const mockSdk = { + groupChannel: { + addGroupChannelHandler: jest.fn(), + removeGroupChannelHandler: jest.fn(), + }, +}; + +const mockForceUpdateUI = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useChannelHandler', () => { + it('logs a warning if SDK or groupChannel is not available', () => { + renderHook(() => useChannelHandler({ sdk: null, channelUrl: 'test-channel', logger: mockLogger, forceUpdateUI: mockForceUpdateUI }), + ); + + expect(mockLogger.warning).toHaveBeenCalledWith('ChannelSettings: SDK or GroupChannelModule is not available'); + }); + + it('adds and removes GroupChannelHandler correctly', () => { + const { unmount } = renderHook(() => useChannelHandler({ + sdk: mockSdk, + channelUrl: 'test-channel', + logger: mockLogger, + forceUpdateUI: mockForceUpdateUI, + }), + ); + + expect(mockSdk.groupChannel.addGroupChannelHandler).toHaveBeenCalledWith( + expect.any(String), + expect.any(GroupChannelHandler), + ); + + act(() => { + unmount(); + }); + + expect(mockSdk.groupChannel.removeGroupChannelHandler).toHaveBeenCalled(); + }); + + it('calls forceUpdateUI when a user leaves the channel', () => { + mockSdk.groupChannel.addGroupChannelHandler.mockImplementation((_, handler) => { + handler.onUserLeft({ url: 'test-channel' }, { userId: 'user1' }); + }); + + renderHook(() => useChannelHandler({ sdk: mockSdk, channelUrl: 'test-channel', logger: mockLogger, forceUpdateUI: mockForceUpdateUI }), + ); + + expect(mockForceUpdateUI).toHaveBeenCalled(); + }); + + it('calls forceUpdateUI when a user is banned from the channel', () => { + mockSdk.groupChannel.addGroupChannelHandler.mockImplementation((_, handler) => { + handler.onUserBanned({ url: 'test-channel', isGroupChannel: () => true }, { userId: 'user1' }); + }); + + renderHook(() => useChannelHandler({ sdk: mockSdk, channelUrl: 'test-channel', logger: mockLogger, forceUpdateUI: mockForceUpdateUI }), + ); + + expect(mockForceUpdateUI).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/ChannelSettings/__test__/useChannelSettings.spec.ts b/src/modules/ChannelSettings/__test__/useChannelSettings.spec.ts new file mode 100644 index 0000000000..9abb21d5c3 --- /dev/null +++ b/src/modules/ChannelSettings/__test__/useChannelSettings.spec.ts @@ -0,0 +1,86 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useChannelSettings } from '../context/useChannelSettings'; +import { useChannelSettingsContext } from '../context/ChannelSettingsProvider'; +import type { GroupChannel } from '@sendbird/chat/groupChannel'; + +jest.mock('../context/ChannelSettingsProvider', () => ({ + useChannelSettingsContext: jest.fn(), +})); + +const mockStore = { + getState: jest.fn(), + setState: jest.fn(), + subscribe: jest.fn(() => jest.fn()), +}; + +const mockChannel: GroupChannel = { + url: 'test-channel', + name: 'Test Channel', +} as GroupChannel; + +beforeEach(() => { + jest.clearAllMocks(); + (useChannelSettingsContext as jest.Mock).mockReturnValue(mockStore); +}); + +describe('useChannelSettings', () => { + it('throws an error if used outside of ChannelSettingsProvider', () => { + (useChannelSettingsContext as jest.Mock).mockReturnValueOnce(null); + + const { result } = renderHook(() => useChannelSettings()); + + expect(result.error).toEqual( + new Error('useChannelSettings must be used within a ChannelSettingsProvider'), + ); + }); + + it('returns the correct initial state', () => { + const initialState = { + channel: null, + loading: false, + invalidChannel: false, + }; + + mockStore.getState.mockReturnValue(initialState); + + const { result } = renderHook(() => useChannelSettings()); + + expect(result.current.state).toEqual(initialState); + }); + + it('calls setChannel with the correct channel object', () => { + const { result } = renderHook(() => useChannelSettings()); + + act(() => { + result.current.actions.setChannel(mockChannel); + }); + + expect(mockStore.setState).toHaveBeenCalledWith(expect.any(Function)); + const stateSetter = mockStore.setState.mock.calls[0][0]; + expect(stateSetter({})).toEqual({ channel: mockChannel }); + }); + + it('calls setLoading with the correct value', () => { + const { result } = renderHook(() => useChannelSettings()); + + act(() => { + result.current.actions.setLoading(true); + }); + + expect(mockStore.setState).toHaveBeenCalledWith(expect.any(Function)); + const stateSetter = mockStore.setState.mock.calls[0][0]; + expect(stateSetter({})).toEqual({ loading: true }); + }); + + it('calls setInvalid with the correct value', () => { + const { result } = renderHook(() => useChannelSettings()); + + act(() => { + result.current.actions.setInvalid(true); + }); + + expect(mockStore.setState).toHaveBeenCalledWith(expect.any(Function)); + const stateSetter = mockStore.setState.mock.calls[0][0]; + expect(stateSetter({})).toEqual({ invalidChannel: true }); + }); +}); diff --git a/src/modules/ChannelSettings/__test__/useSetChannel.spec.ts b/src/modules/ChannelSettings/__test__/useSetChannel.spec.ts new file mode 100644 index 0000000000..23340c34c3 --- /dev/null +++ b/src/modules/ChannelSettings/__test__/useSetChannel.spec.ts @@ -0,0 +1,90 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useChannelSettings from '../context/useChannelSettings'; +import useSetChannel from '../context/hooks/useSetChannel'; + +jest.mock('../context/useChannelSettings'); + +const mockLogger = { + warning: jest.fn(), + info: jest.fn(), + error: jest.fn(), +}; + +const mockSetChannel = jest.fn(); +const mockSetInvalid = jest.fn(); +const mockSetLoading = jest.fn(); + +const mockSdk = { + groupChannel: { + getChannel: jest.fn().mockResolvedValue({ name: 'Test Channel' }), + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + useChannelSettings.mockReturnValue({ + actions: { + setChannel: mockSetChannel, + setInvalid: mockSetInvalid, + setLoading: mockSetLoading, + }, + }); +}); + +describe('useSetChannel', () => { + it('logs a warning and stops loading if channelUrl is missing', () => { + const { unmount } = renderHook(() => useSetChannel({ channelUrl: '', sdk: mockSdk, logger: mockLogger, initialized: true }), + ); + + expect(mockLogger.warning).toHaveBeenCalledWith('ChannelSettings: channel url is required'); + expect(mockSetLoading).toHaveBeenCalledWith(false); + + unmount(); + }); + + it('logs a warning if SDK is not initialized', () => { + const { unmount } = renderHook(() => useSetChannel({ channelUrl: 'test-channel', sdk: null, logger: mockLogger, initialized: false }), + ); + + expect(mockLogger.warning).toHaveBeenCalledWith('ChannelSettings: SDK is not initialized'); + expect(mockSetLoading).toHaveBeenCalledWith(false); + + unmount(); + }); + + it('fetches channel successfully and sets it', async () => { + const { unmount } = renderHook(() => useSetChannel({ channelUrl: 'test-channel', sdk: mockSdk, logger: mockLogger, initialized: true }), + ); + + await act(async () => { + expect(mockSdk.groupChannel.getChannel).toHaveBeenCalledWith('test-channel'); + }); + + expect(mockSetChannel).toHaveBeenCalledWith({ name: 'Test Channel' }); + expect(mockLogger.info).toHaveBeenCalledWith( + 'ChannelSettings | useSetChannel: fetched group channel', + { name: 'Test Channel' }, + ); + expect(mockSetLoading).toHaveBeenCalledWith(false); + + unmount(); + }); + + it('logs an error if fetching the channel fails', async () => { + mockSdk.groupChannel.getChannel.mockRejectedValue(new Error('Failed to fetch channel')); + + const { unmount } = renderHook(() => useSetChannel({ channelUrl: 'test-channel', sdk: mockSdk, logger: mockLogger, initialized: true }), + ); + + await act(async () => {}); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'ChannelSettings | useSetChannel: failed fetching channel', + new Error('Failed to fetch channel'), + ); + expect(mockSetInvalid).toHaveBeenCalledWith(true); + expect(mockSetLoading).toHaveBeenCalledWith(false); + + unmount(); + }); +}); diff --git a/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts b/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts index b225a4b712..9866a3f860 100644 --- a/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts +++ b/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { GroupChannelHandler } from '@sendbird/chat/groupChannel'; import uuidv4 from '../../../../utils/uuid'; @@ -21,13 +21,12 @@ export const useChannelHandler = ({ forceUpdateUI, dependencies = [], }: UseChannelHandlerProps) => { - const [channelHandlerId, setChannelHandlerId] = useState(null); - useEffect(() => { if (!sdk || !sdk.groupChannel) { logger.warning('ChannelSettings: SDK or GroupChannelModule is not available'); return; } + const newChannelHandlerId = uuidv4(); const channelHandler = new GroupChannelHandler({ onUserLeft: (channel, user) => { @@ -44,14 +43,12 @@ export const useChannelHandler = ({ }, }); - const newChannelHandlerId = uuidv4(); sdk.groupChannel.addGroupChannelHandler(newChannelHandlerId, channelHandler); - setChannelHandlerId(newChannelHandlerId); return () => { - if (sdk.groupChannel && channelHandlerId) { - logger.info('ChannelSettings: Removing message receiver handler', channelHandlerId); - sdk.groupChannel.removeGroupChannelHandler(channelHandlerId); + if (sdk.groupChannel && newChannelHandlerId) { + logger.info('ChannelSettings: Removing message receiver handler', newChannelHandlerId); + sdk.groupChannel.removeGroupChannelHandler(newChannelHandlerId); } }; }, [sdk, channelUrl, logger, ...dependencies]); diff --git a/src/modules/ChannelSettings/context/hooks/useSetChannel.ts b/src/modules/ChannelSettings/context/hooks/useSetChannel.ts index 37b26a2923..53c98df135 100644 --- a/src/modules/ChannelSettings/context/hooks/useSetChannel.ts +++ b/src/modules/ChannelSettings/context/hooks/useSetChannel.ts @@ -18,46 +18,52 @@ function useSetChannel({ initialized, dependencies = [], }: Props) { - const { - actions: { - setChannel, - setInvalid, - setLoading, - }, - } = useChannelSettings(); - const logAndStopLoading = (message): void => { - logger.warning(message); - setLoading(false); - return null; - }; + const { actions: { setChannel, setInvalid, setLoading } } = useChannelSettings(); useEffect(() => { - if (!channelUrl) { - return logAndStopLoading('ChannelSettings: channel url is required'); - } - if (!initialized || !sdk) { - return logAndStopLoading('ChannelSettings: SDK is not initialized'); - } - if (!sdk.groupChannel) { - return logAndStopLoading('ChannelSettings: GroupChannelModule is not specified in the SDK'); - } - - if (channelUrl && sdk?.groupChannel) { - setLoading(true); - sdk.groupChannel.getChannel(channelUrl) - .then((groupChannel) => { + const controller = new AbortController(); + const { signal } = controller; + + const fetchChannel = async () => { + try { + if (!channelUrl) { + logger.warning('ChannelSettings: channel url is required'); + setLoading(false); + return; + } + if (!initialized || !sdk) { + logger.warning('ChannelSettings: SDK is not initialized'); + setLoading(false); + return; + } + if (!sdk.groupChannel) { + logger.warning('ChannelSettings: GroupChannelModule is not specified in the SDK'); + setLoading(false); + return; + } + + setLoading(true); + const groupChannel = await sdk.groupChannel.getChannel(channelUrl); + if (!signal.aborted) { logger.info('ChannelSettings | useSetChannel: fetched group channel', groupChannel); setChannel(groupChannel); - }) - .catch((err) => { - logger.error('ChannelSettings | useSetChannel: failed fetching channel', err); + } + } catch (error) { + if (!signal.aborted) { + logger.error('ChannelSettings | useSetChannel: failed fetching channel', error); setInvalid(true); - }) - .finally(() => { + } + } finally { + if (!signal.aborted) { setLoading(false); - }); - } - }, [channelUrl, initialized, ...dependencies]); + } + } + }; + + fetchChannel(); + + return () => controller.abort(); // Cleanup with AbortController + }, [channelUrl, initialized, sdk, ...dependencies]); } export default useSetChannel; From 0cc69d55ae6903114d45c7ec531c9578b9acc798 Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Wed, 23 Oct 2024 17:33:42 +0900 Subject: [PATCH 06/29] refactor: Add unit tests for new GroupChannelListProvider (#1233) This PR contains the unit tests for the recent refactoring of `GroupChannelListProvider`. * add test about the new provider and its state management logic itself. * add the integration test with `GroupChannelListUI` component. --------- Co-authored-by: Irene Ryu --- src/lib/UserProfileContext.tsx | 2 +- .../GroupChannelListUI.integration.test.tsx | 189 ++++++++++++++++ .../context/GroupChannelListProvider.tsx | 208 +++++++++++++----- .../GroupChannelListProvider.spec.tsx | 94 ++++++++ .../__tests__/useGroupChannelList.spec.tsx | 93 ++++++++ .../context/useGroupChannelList.ts | 2 +- 6 files changed, 528 insertions(+), 60 deletions(-) create mode 100644 src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx create mode 100644 src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx create mode 100644 src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx diff --git a/src/lib/UserProfileContext.tsx b/src/lib/UserProfileContext.tsx index 1bfd70d803..92f5d41042 100644 --- a/src/lib/UserProfileContext.tsx +++ b/src/lib/UserProfileContext.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { RenderUserProfileProps } from '../types'; -import { useSendbirdStateContext } from './Sendbird'; +import useSendbirdStateContext from '../hooks/useSendbirdStateContext'; interface UserProfileContextInterface { isOpenChannel: boolean; diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx new file mode 100644 index 0000000000..783750898f --- /dev/null +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx @@ -0,0 +1,189 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import GroupChannelListUI from '../index'; +import '@testing-library/jest-dom/extend-expect'; +import React from 'react'; +import { useGroupChannelList as useGroupChannelListModule } from '../../../context/useGroupChannelList'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + userStore: { + user: { + userId: ' test-user-id', + }, + }, + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { + logger: console, + userId: 'test-user-id', + groupChannel: { + enableMention: true, + }, + isOnline: true, + }, + })), +})); +jest.mock('../../../context/useGroupChannelList'); + +const mockStringSet = { + PLACE_HOLDER__NO_CHANNEL: 'No channels', + TYPING_INDICATOR__IS_TYPING: 'is typing...', + TYPING_INDICATOR__AND: 'and', + TYPING_INDICATOR__ARE_TYPING: 'are typing...', + TYPING_INDICATOR__MULTIPLE_TYPING: 'Several people are typing...', +}; + +const defaultMockState = { + className: '', + selectedChannelUrl: '', + disableAutoSelect: false, + allowProfileEdit: false, + isTypingIndicatorEnabled: false, + isMessageReceiptStatusEnabled: false, + onChannelSelect: undefined, + onChannelCreated: undefined, + onThemeChange: undefined, + onCreateChannelClick: undefined, + onBeforeCreateChannel: undefined, + onUserProfileUpdated: undefined, + typingChannelUrls: [], + refreshing: false, + initialized: false, + groupChannels: [], + refresh: null, + loadMore: null, +}; + +describe('GroupChannelListUI Integration Tests', () => { + + const renderComponent = (mockState = {}) => { + const mockUseGroupChannelList = useGroupChannelListModule as jest.Mock; + + mockUseGroupChannelList.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + }); + + return render( + + , + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('display loader if not initialized', () => { + const { container } = renderComponent(); + + expect(container.getElementsByClassName('sendbird-loader')[0]).toBeInTheDocument(); + }); + + it('display results when available', () => { + renderComponent({ + groupChannels: [ + { name: 'test-group-channel-1' }, + { name: 'test-group-channel-2' }, + ], + initialized: true, + }); + + expect(screen.getByText('test-group-channel-1')).toBeInTheDocument(); + expect(screen.getByText('test-group-channel-2')).toBeInTheDocument(); + }); + + it('handle no result', () => { + renderComponent({ + groupChannels: [], + initialized: true, + }); + + expect(screen.getByText(mockStringSet.PLACE_HOLDER__NO_CHANNEL)).toBeInTheDocument(); + }); + + it('handle selectedChannelUrl', () => { + const { container } = renderComponent({ + groupChannels: [ + { name: 'test-group-channel-1', url: 'test-group-channel-url-1' }, + { name: 'test-group-channel-2', url: 'test-group-channel-url-2' }, + ], + selectedChannelUrl: 'test-group-channel-url-2', + initialized: true, + }); + + const selected = container.getElementsByClassName('sendbird-channel-preview--active')[0]; + + expect(selected).toBeInTheDocument(); + expect(selected.getAttribute('tabindex')).toEqual('1'); + }); + + it('handle disableAutoSelect', () => { + const { container } = renderComponent({ + groupChannels: [ + { name: 'test-group-channel-1', url: 'test-group-channel-url-1' }, + { name: 'test-group-channel-2', url: 'test-group-channel-url-2' }, + ], + disableAutoSelect: true, + initialized: true, + }); + + const selected = container.getElementsByClassName('sendbird-channel-preview--active')[0]; + + expect(selected).toBeUndefined(); + }); + + it('handle allowProfileEdit', () => { + const { container } = renderComponent({ + allowProfileEdit: true, + initialized: true, + }); + + expect(container.getElementsByClassName('sendbird-channel-header--allow-edit')[0]).toBeInTheDocument(); + }); + + it('handle isTypingIndicatorEnabled', () => { + renderComponent({ + groupChannels: [ + { name: 'test-group-channel-1', url: 'test-group-channel-url-1', getTypingUsers: () => ['test-user-1', 'test-user-2'] }, + { name: 'test-group-channel-2', url: 'test-group-channel-url-2', getTypingUsers: () => ['test-user-2'] }, + ], + typingChannelUrls: ['test-group-channel-url-1', 'test-group-channel-url-2'], + selectedChannelUrl: 'test-group-channel-url-1', + isTypingIndicatorEnabled: true, + initialized: true, + }); + + expect(screen.getByText(mockStringSet.TYPING_INDICATOR__IS_TYPING, { exact: false })).toBeInTheDocument(); + expect(screen.getByText(mockStringSet.TYPING_INDICATOR__ARE_TYPING, { exact: false })).toBeInTheDocument(); + }); + + it('handle onChannelSelect', () => { + const onChannelSelect = jest.fn(); + + renderComponent({ + groupChannels: [ + { name: 'test-group-channel-1', url: 'test-group-channel-url-1' }, + { name: 'test-group-channel-2', url: 'test-group-channel-url-2' }, + ], + initialized: true, + onChannelSelect, + }); + + const channel = screen.getByText('test-group-channel-1'); + fireEvent.click(channel); + + expect(onChannelSelect).toHaveBeenCalledTimes(1); + }); + +}); diff --git a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx index 064f0fcf89..d07fe617fe 100644 --- a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx +++ b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx @@ -1,22 +1,26 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import type { User } from '@sendbird/chat'; import type { GroupChannel, GroupChannelCreateParams, GroupChannelFilterParams } from '@sendbird/chat/groupChannel'; import { GroupChannelCollectionParams, GroupChannelFilter } from '@sendbird/chat/groupChannel'; -import { useGroupChannelList, useGroupChannelHandler } from '@sendbird/uikit-tools'; +import { useGroupChannelList as useGroupChannelListDataSource, useGroupChannelHandler } from '@sendbird/uikit-tools'; import type { CHANNEL_TYPE } from '../../CreateChannel/types'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import { UserProfileProvider } from '../../../lib/UserProfileContext'; import type { UserProfileProviderProps } from '../../../lib/UserProfileContext'; import { useMarkAsDeliveredScheduler } from '../../../lib/hooks/useMarkAsDeliveredScheduler'; import useOnlineStatus from '../../../lib/hooks/useOnlineStatus'; -import { noop } from '../../../utils/utils'; -import type { SdkStore } from '../../../lib/types'; +import type { SdkStore } from '../../../lib/Sendbird/types'; import { PartialRequired } from '../../../utils/typeHelpers/partialRequired'; +import { createStore } from '../../../utils/storeManager'; +import { useStore } from '../../../hooks/useStore'; +import { noop } from '../../../utils/utils'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; +import useGroupChannelList from './useGroupChannelList'; +import useDeepCompareEffect from '../../../hooks/useDeepCompareEffect'; type OnCreateChannelClickParams = { users: Array; onClose: () => void; channelType: CHANNEL_TYPE }; -type ChannelListDataSource = ReturnType; +type ChannelListDataSource = ReturnType; export type ChannelListQueryParamsType = Omit & GroupChannelFilterParams; interface ContextBaseType { @@ -53,28 +57,59 @@ export interface GroupChannelListProviderProps extends children?: React.ReactNode; } -export const GroupChannelListContext = React.createContext(null); -export const GroupChannelListProvider = (props: GroupChannelListProviderProps) => { - const { - children, - className = '', - selectedChannelUrl, +export const GroupChannelListContext = React.createContext> | null>(null); - disableAutoSelect = false, - allowProfileEdit, - isTypingIndicatorEnabled, - isMessageReceiptStatusEnabled, +export interface GroupChannelListState extends GroupChannelListContextType { +} - channelListQueryParams, - onThemeChange, - onChannelSelect = noop, - onChannelCreated = noop, - onCreateChannelClick, - onBeforeCreateChannel, - onUserProfileUpdated, - } = props; - const globalStore = useSendbirdStateContext(); - const { config, stores } = globalStore; +const initialState: GroupChannelListState = { + className: '', + selectedChannelUrl: '', + disableAutoSelect: false, + allowProfileEdit: false, + isTypingIndicatorEnabled: false, + isMessageReceiptStatusEnabled: false, + onChannelSelect: () => {}, + onChannelCreated: () => {}, + onThemeChange: noop, + onCreateChannelClick: noop, + onBeforeCreateChannel: null, + onUserProfileUpdated: noop, + typingChannelUrls: [], + refreshing: false, + initialized: false, + groupChannels: [], + refresh: null, + loadMore: null, + scrollRef: { current: null }, +}; + +/** + * @returns {ReturnType>} + */ +export const useGroupChannelListStore = () => { + return useStore(GroupChannelListContext, state => state, initialState); +}; + +export const GroupChannelListManager: React.FC = ({ + className = '', + selectedChannelUrl = '', + + disableAutoSelect = false, + + channelListQueryParams, + onThemeChange, + onChannelSelect, + onChannelCreated, + onCreateChannelClick, + onBeforeCreateChannel, + onUserProfileUpdated, + ...props +}: GroupChannelListProviderProps) => { + const { state: sendbirdState } = useSendbird(); + const { config, stores } = sendbirdState; + const { state } = useGroupChannelList(); + const { updateState } = useGroupChannelListStore(); const { sdkStore } = stores; const sdk = sdkStore.sdk; @@ -83,7 +118,7 @@ export const GroupChannelListProvider = (props: GroupChannelListProviderProps) = const scrollRef = useRef(null); - const channelListDataSource = useGroupChannelList(sdk, { + const channelListDataSource = useGroupChannelListDataSource(sdk, { collectionCreator: getCollectionCreator(sdk, channelListQueryParams), markAsDelivered: (channels) => channels.forEach(scheduler.push), onChannelsDeleted: (channelUrls) => { @@ -104,60 +139,117 @@ export const GroupChannelListProvider = (props: GroupChannelListProviderProps) = // Recreates the GroupChannelCollection when `channelListQueryParams` change useEffect(() => { - refresh(); + refresh?.(); }, [ Object.keys(channelListQueryParams ?? {}).sort() .map((key: string) => `${key}=${encodeURIComponent(JSON.stringify(channelListQueryParams[key]))}`) .join('&'), ]); - const [typingChannelUrls, setTypingChannelUrls] = useState([]); + const { typingChannelUrls } = state; + useGroupChannelHandler(sdk, { onTypingStatusUpdated: (channel) => { const channelList = typingChannelUrls.filter((channelUrl) => channelUrl !== channel.url); if (channel.getTypingUsers()?.length > 0) { - setTypingChannelUrls(channelList.concat(channel.url)); + updateState({ + typingChannelUrls: (channelList.concat(channel.url)), + }); } else { - setTypingChannelUrls(channelList); + updateState({ + typingChannelUrls: (channelList), + }); } }, }); + const allowProfileEdit = props.allowProfileEdit ?? config.allowProfileEdit ?? true; + const isTypingIndicatorEnabled = props.isTypingIndicatorEnabled ?? config.groupChannelList.enableTypingIndicator ?? false; + const isMessageReceiptStatusEnabled = props.isMessageReceiptStatusEnabled ?? config.groupChannelList.enableMessageReceiptStatus ?? false; + + const eventHandlers = useMemo(() => ({ + onChannelSelect, + onChannelCreated, + onThemeChange, + onCreateChannelClick, + onBeforeCreateChannel, + onUserProfileUpdated, + }), [ + onChannelSelect, + onChannelCreated, + onThemeChange, + onCreateChannelClick, + onBeforeCreateChannel, + onUserProfileUpdated, + ]); + const configurations = useMemo(() => ({ + className, + selectedChannelUrl, + disableAutoSelect, + allowProfileEdit, + isTypingIndicatorEnabled, + isMessageReceiptStatusEnabled, + typingChannelUrls, + refreshing, + initialized, + refresh, + loadMore, + }), [ + className, + selectedChannelUrl, + disableAutoSelect, + allowProfileEdit, + isTypingIndicatorEnabled, + isMessageReceiptStatusEnabled, + typingChannelUrls, + refreshing, + initialized, + refresh, + loadMore, + scrollRef, + ]); + useDeepCompareEffect(() => { + updateState({ + ...eventHandlers, + ...configurations, + groupChannels, + }); + }, [ + configurations, + eventHandlers, + groupChannels.map(groupChannel => groupChannel.serialize()), + ]); + + return null; +}; + +const createGroupChannelListStore = () => createStore(initialState); +const InternalGroupChannelListProvider = ({ children }) => { + const storeRef = useRef(createGroupChannelListStore()); return ( - + + {children} + + ); +}; + +export const GroupChannelListProvider = (props: GroupChannelListProviderProps) => { + const { children, className } = props; + + return ( + +
{children}
-
+ ); }; +// Keep this function for backward compatibility. export const useGroupChannelListContext = () => { - const context = useContext(GroupChannelListContext); - if (!context) throw new Error('GroupChannelListContext not found. Use within the GroupChannelList module.'); - return context; + const { state, actions } = useGroupChannelList(); + return { ...state, ...actions }; }; function getCollectionCreator(sdk: SdkStore['sdk'], channelListQueryParams?: ChannelListQueryParamsType) { diff --git a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx new file mode 100644 index 0000000000..0032046233 --- /dev/null +++ b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { + GroupChannelListProvider, + useGroupChannelListContext, +} from '../GroupChannelListProvider'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +jest.mock('@sendbird/uikit-tools', () => ({ + ...jest.requireActual('@sendbird/uikit-tools'), + useGroupChannelList: jest.fn(() => ({ + refreshing: false, + initialized: true, + groupChannels: [{ url: 'test-groupchannel-url-1' }], + refresh: null, + loadMore: null, + })), +})); + +describe('GroupChannelListProvider', () => { + const initialState = { + className: '', + selectedChannelUrl: '', + disableAutoSelect: false, + allowProfileEdit: undefined, + isTypingIndicatorEnabled: undefined, + isMessageReceiptStatusEnabled: undefined, + onChannelSelect: expect.any(Function), + onChannelCreated: expect.any(Function), + onThemeChange: undefined, + onCreateChannelClick: undefined, + onBeforeCreateChannel: undefined, + onUserProfileUpdated: undefined, + typingChannelUrls: [], + refreshing: false, + initialized: true, + groupChannels: [{ url: 'test-groupchannel-url-1' }], + refresh: null, + loadMore: null, + }; + + it('provide the correct initial state', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useGroupChannelListContext(), { wrapper }); + + expect(result.current.getState()).toMatchObject(initialState); + }); + + it('update state correctly', async () => { + const channelListQueryParams = {} as any; + const wrapper = ({ children }) => ( + + {children} + + ); + + channelListQueryParams.prev = 42; + + const { result } = renderHook(() => useGroupChannelListContext(), { wrapper }); + expect(result.current.getState().className).toEqual('old-classname'); + + await act(async () => { + result.current.setState({ className: 'new-classname' }); + result.current.setState({ disableAutoSelect: true }); + await waitFor(() => { + const newState = result.current.getState(); + expect(newState.className).toEqual('new-classname'); + expect(newState.disableAutoSelect).toEqual(true); + }); + }); + }); + +}); diff --git a/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx b/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx new file mode 100644 index 0000000000..45e3af19d0 --- /dev/null +++ b/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx @@ -0,0 +1,93 @@ +import { GroupChannelListProvider, useGroupChannelListContext } from '../GroupChannelListProvider'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { useGroupChannelList } from '../useGroupChannelList'; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +jest.mock('@sendbird/uikit-tools', () => ({ + useGroupChannelList: jest.fn(() => ({ + refreshing: false, + initialized: false, + groupChannels: [], + refresh: null, + loadMore: null, + })), + useGroupChannelHandler: jest.fn(() => {}), +})); + +jest.mock('../GroupChannelListProvider', () => ({ + ...jest.requireActual('../GroupChannelListProvider'), + useGroupChannelListContext: jest.fn(), +})); + +const initialState = { + className: '', + selectedChannelUrl: '', + disableAutoSelect: false, + allowProfileEdit: false, + isTypingIndicatorEnabled: false, + isMessageReceiptStatusEnabled: false, + onChannelSelect: undefined, + onChannelCreated: undefined, + onThemeChange: undefined, + onCreateChannelClick: undefined, + onBeforeCreateChannel: undefined, + onUserProfileUpdated: undefined, + typingChannelUrls: [], + refreshing: false, + initialized: false, + groupChannels: [], + refresh: null, + loadMore: null, +}; + +const mockStore = { + getState: jest.fn(() => initialState), + setState: jest.fn(), + subscribe: jest.fn(() => jest.fn()), +}; + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('GroupChannelListProvider', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an error if used outside of GroupChannelListProvider', () => { + (useGroupChannelListContext as jest.Mock).mockReturnValue(null); + + expect(() => { + renderHook(() => useGroupChannelList(), { wrapper }); + }).toThrow(new Error('useGroupChannelList must be used within a GroupChannelListProvider')); + }); + + it('provide the correct initial state', () => { + (useGroupChannelListContext as jest.Mock).mockReturnValue(mockStore); + const { result } = renderHook(() => useGroupChannelList(), { wrapper }); + + expect(result.current.state).toEqual(expect.objectContaining(initialState)); + }); + +}); diff --git a/src/modules/GroupChannelList/context/useGroupChannelList.ts b/src/modules/GroupChannelList/context/useGroupChannelList.ts index 54543fd977..6219913a56 100644 --- a/src/modules/GroupChannelList/context/useGroupChannelList.ts +++ b/src/modules/GroupChannelList/context/useGroupChannelList.ts @@ -4,7 +4,7 @@ import { useMemo } from 'react'; export const useGroupChannelList = () => { const store = useGroupChannelListContext(); - if (!store) throw new Error('useChannelSettings must be used within a ChannelSettingsProvider'); + if (!store) throw new Error('useGroupChannelList must be used within a GroupChannelListProvider'); const state: GroupChannelListState = useSyncExternalStore(store.subscribe, store.getState); const actions = useMemo(() => ({ From 272079777a3424a7befe0ec59a91fb30db58c646 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Tue, 19 Nov 2024 11:30:45 +0900 Subject: [PATCH 07/29] [CLNP-5047] Migrate GroupChannelProvider to new state management pattern (#1246) Addresses https://sendbird.atlassian.net/browse/CLNP-5047 This PR migrates the GroupChannelProvider to use a new state management pattern. This change introduces a more predictable and maintainable way to manage channel state while maintaining backward compatibility. - Introduced new store-based state management - Separated concerns between state management and event handling - Added `useGroupChannel` hook for accessing state and actions - Optimized performance with proper memoization Old pattern: ```typescript const { someState, someHandler } = useGroupChannelContext(); ``` New pattern: ```typescript // For state and actions const { state, actions } = useGroupChannel(); // For handlers and props (backward compatibility) const { someHandler } = useGroupChannelContext(); ``` - More predictable state updates - Better separation of concerns - Enhanced performance through optimized renders - All existing functionality remains unchanged - Added tests for new hooks and state management - Verified backward compatibility - Tested with real-time updates and message handling - [x] useGroupChannelContext is kept for backward compatibility - [x] Unit & integration tests will be added for new hooks and state management Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If unsure, ask the members. This is a reminder of what we look for before merging your code. - [x] **All tests pass locally with my changes** - [x] **I have added tests that prove my fix is effective or that my feature works** - [ ] **Public components / utils / props are appropriately exported** - [ ] I have added necessary documentation (if appropriate) --- apps/testing/vite.config.ts | 5 + .../Channel/context/ChannelProvider.tsx | 2 +- .../hooks/useToggleReactionCallback.ts | 49 +- .../GroupChannelUIView.integration.test.tsx | 98 ++++ .../components/FileViewer/index.tsx | 7 +- .../components/GroupChannelUI/index.tsx | 3 +- .../components/Message/MessageView.tsx | 2 +- .../GroupChannel/components/Message/index.tsx | 52 +- .../components/MessageInputWrapper/index.tsx | 6 +- .../components/MessageList/index.tsx | 44 +- .../components/RemoveMessageModal/index.tsx | 4 +- .../context/GroupChannelProvider.tsx | 535 ++++++++---------- .../__test__/GroupChannelProvider.spec.tsx | 141 +++++ .../context/__test__/useGroupChannel.spec.tsx | 268 +++++++++ .../__test__/useMessageActions.spec.tsx | 186 ++++++ .../context/hooks/useGroupChannel.ts | 194 +++++++ .../context/hooks/useMessageActions.ts | 19 +- .../hooks/useToggleReactionCallback.ts | 40 -- src/modules/GroupChannel/context/types.ts | 112 ++++ src/modules/GroupChannel/index.tsx | 3 +- .../context/MessageSearchProvider.tsx | 11 +- .../__test__/MessageSearchProvider.spec.tsx | 30 +- .../ParentMessageInfoItem.tsx | 2 +- src/modules/Thread/context/ThreadProvider.tsx | 2 +- src/ui/FileMessageItemBody/index.tsx | 2 +- src/ui/MessageContent/MessageBody/index.tsx | 2 +- src/ui/MessageContent/index.tsx | 2 +- src/ui/MultipleFilesMessageItemBody/index.tsx | 2 +- 28 files changed, 1390 insertions(+), 433 deletions(-) create mode 100644 src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx create mode 100644 src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx create mode 100644 src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx create mode 100644 src/modules/GroupChannel/context/__test__/useMessageActions.spec.tsx create mode 100644 src/modules/GroupChannel/context/hooks/useGroupChannel.ts delete mode 100644 src/modules/GroupChannel/context/hooks/useToggleReactionCallback.ts create mode 100644 src/modules/GroupChannel/context/types.ts diff --git a/apps/testing/vite.config.ts b/apps/testing/vite.config.ts index 52a9ace08d..a6bca4296b 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 8c8d769e36..8027d44cee 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 ffa984357d..bbdfef315a 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 0000000000..b8d7511850 --- /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 a5219b6311..c6caa96bcc 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 06e2887c61..8ad95ca1f1 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 4ad40fc161..397312f563 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 96f0ff675c..c4425b3d91 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 af518d88ce..721eb1f2f3 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 7e8019df65..75289ff557 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 23cb445cc7..c0772fe68c 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 3c9e46389f..934077cbe2 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -1,116 +1,76 @@ -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 type { SendableMessageType } from '../../../utils'; -import { UserProfileProvider, UserProfileProviderProps } 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 { + useAsyncEffect, + useAsyncLayoutEffect, + useIIFE, + useGroupChannelMessages, +} from '@sendbird/uikit-tools'; + +import { UserProfileProvider } from '../../../lib/UserProfileContext'; +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'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; +import useDeepCompareEffect from '../../../hooks/useDeepCompareEffect'; + +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 - -export type OnBeforeHandler = (params: T) => T | Promise | void | 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, @@ -141,45 +101,42 @@ export const GroupChannelProvider = (props: GroupChannelProviderProps) => { filterEmojiCategoryIds, } = props; - // Global context - const { config, stores } = useSendbirdStateContext(); - + const { state, actions } = useGroupChannel(); + const { updateState } = useGroupChannelStore(); + const { state: { config, stores } } = useSendbird(); const { sdkStore } = stores; - const { markAsReadScheduler, logger } = config; + const { markAsReadScheduler, logger, pubSub } = 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 +144,203 @@ 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), + + if (pubSub?.subscribe === undefined) return; + 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, pubSub?.subscribe]); - // 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); + if (_animatedMessageId) { + actions.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 }); - } + // 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, + ]); - if (currentChannel && !messageDataSource.hasNext()) { - messageDataSource.resetNewMessages(); - if (!disableMarkAsRead) markAsReadScheduler.push(currentChannel); - } - }); + const renderProps = useMemo(() => ({ + renderUserMentionItem, + filterEmojiCategoryIds, + }), [renderUserMentionItem, filterEmojiCategoryIds]); - 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); - }); - } + const configurations = useMemo(() => ({ + isReactionEnabled: resolvedIsReactionEnabled, + isMessageGroupingEnabled, + isMultipleFilesMessageEnabled, + replyType: resolvedReplyType, + threadReplySelectType: resolvedThreadReplySelectType, + showSearchIcon: showSearchIcon ?? config.groupChannelSettings.enableMessageSearch, + disableMarkAsRead, + scrollBehavior, + }), [ + resolvedIsReactionEnabled, + isMessageGroupingEnabled, + isMultipleFilesMessageEnabled, + resolvedReplyType, + resolvedThreadReplySelectType, + showSearchIcon, + disableMarkAsRead, + scrollBehavior, + config.groupChannelSettings.enableMessageSearch, + ]); + + const scrollState = useMemo(() => ({ + scrollRef, + scrollPubSub, + scrollDistanceFromBottomRef, + scrollPositionRef, + isScrollBottomReached, + }), [ + scrollRef, + scrollPubSub, + scrollDistanceFromBottomRef, + scrollPositionRef, + isScrollBottomReached, + ]); + + useDeepCompareEffect(() => { + 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; +}; - clickHandler.activate(); - }, +const GroupChannelProvider: React.FC> = (props) => { + return ( + + + + {props.children} + + + ); +}; - const messageActions = useMessageActions({ ...props, ...messageDataSource, scrollToBottom, quoteMessage, replyType }); +/** + * A specialized hook for GroupChannel state management + * @returns {ReturnType>} + */ +const useGroupChannelStore = () => { + return useStore(GroupChannelContext, state => state, initialState); +}; - return ( - - - {children} - - - ); +// Keep this function for backward compatibility. +const useGroupChannelContext = () => { + const { state, actions } = useGroupChannel(); + return { ...state, ...actions }; }; -export const useGroupChannelContext = () => { - const context = useContext(GroupChannelContext); - if (!context) throw new Error('GroupChannelContext not found. Use within the GroupChannel module.'); - return context; +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 0000000000..c5adeb06f9 --- /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 0000000000..31ba50334f --- /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 0000000000..821fc009ec --- /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 0000000000..153f5a0a9f --- /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 a61d4da0a6..3c023b7a9b 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts +++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts @@ -20,10 +20,9 @@ import { VOICE_MESSAGE_FILE_NAME, VOICE_MESSAGE_MIME_TYPE, } from '../../../../utils/consts'; -import type { SendableMessageType, CoreMessageType } from '../../../../utils'; -import type { ReplyType } from '../../../../types'; -import type { GroupChannelProviderProps, OnBeforeHandler } from '../GroupChannelProvider'; +import type { CoreMessageType } from '../../../../utils'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import type { GroupChannelState, OnBeforeHandler } from '../types'; type MessageListDataSource = ReturnType; type MessageActions = { @@ -32,12 +31,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; @@ -64,6 +61,10 @@ export function useMessageActions(params: Params): MessageActions { sendMultipleFilesMessage, sendUserMessage, updateUserMessage, + updateFileMessage, + resendMessage, + deleteMessage, + resetNewMessages, scrollToBottom, quoteMessage, @@ -189,5 +190,9 @@ export function useMessageActions(params: Params): MessageActions { }, [buildInternalMessageParams, updateUserMessage, processParams], ), + 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 e9cb60d553..0000000000 --- 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 0000000000..0002a12227 --- /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 +export type OnBeforeHandler = (params: T) => T | Promise | void | 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 e062fbb34a..aaf89a917a 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 a526f924ca..8a45a19d67 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 1f7df34d14..ecd1df6935 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 97bb7fb068..3b55d524bf 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 a72a0d33fc..28c727c4c0 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 8290d5748d..47e784cda2 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 7b090e5861..bbc6791442 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 44bc6499ff..c91ec47bc5 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 c94b673c42..2ce03436ee 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'; From 6a88cc9e061d23f843eb9e0bb78ba4473b701acd Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Thu, 28 Nov 2024 15:00:31 +0900 Subject: [PATCH 08/29] [CLNP-5045] CreateChannelProvider Migration (#1243) Addresses https://sendbird.atlassian.net/browse/CLNP-5045 --- .../CreateChannelUI.integration.test.tsx | 112 ++++++++++++++++++ .../components/CreateChannelUI/index.tsx | 20 ++-- .../InviteUsers/__tests__/index.spec.tsx | 91 +++++++++++--- .../components/InviteUsers/index.tsx | 22 ++-- .../components/SelectChannelType.tsx | 24 ++-- .../context/CreateChannelProvider.tsx | 89 +++++++++----- .../__tests__/CreateChannelProvider.spec.tsx | 98 +++++++++++++++ .../__tests__/useCreateChannel.spec.tsx | 61 ++++++++++ .../CreateChannel/context/useCreateChannel.ts | 31 +++++ src/ui/Modal/index.tsx | 2 +- src/ui/UserListItem/index.tsx | 2 +- 11 files changed, 476 insertions(+), 76 deletions(-) create mode 100644 src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx create mode 100644 src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx create mode 100644 src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx create mode 100644 src/modules/CreateChannel/context/useCreateChannel.ts diff --git a/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx new file mode 100644 index 0000000000..37e0dc87a0 --- /dev/null +++ b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import * as useCreateChannelModule from '../../../context/useCreateChannel'; +import { CHANNEL_TYPE } from '../../../types'; +import { act, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; +import CreateChannelUI from '../index'; + +jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + userStore: { + user: { + userId: ' test-user-id', + }, + }, + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + createApplicationUserListQuery: () => ({ + next: () => Promise.resolve([{ userId: 'test-user-id' }]), + isLoading: false, + }), + }, + initialized: true, + }, + }, + config: { + logger: console, + userId: 'test-user-id', + groupChannel: { + enableMention: true, + }, + isOnline: true, + }, + })), +})); +jest.mock('../../../context/useCreateChannel'); + +const mockStringSet = { + MODAL__CREATE_CHANNEL__TITLE: 'CREATE_CHANNEL', + MODAL__INVITE_MEMBER__SELECTED: 'USERS_SELECTED', +}; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: undefined, + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; + +const defaultMockActions = { + setPageStep: jest.fn(), + setType: jest.fn(), +}; + +describe('CreateChannelUI Integration Tests', () => { + const mockUseCreateChannel = useCreateChannelModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}) => { + mockUseCreateChannel.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ` +
+ `; + }); + + it('display initial state correctly', () => { + renderComponent(); + + expect(screen.getByText('CREATE_CHANNEL')).toBeInTheDocument(); + }); + + it('display SelectChannelType when pageStep is 0', () => { + renderComponent({ pageStep: 0 }); + + expect(screen.getByText('CREATE_CHANNEL')).toBeInTheDocument(); + }); + + it('display InviteUsers when pageStep is 1', async () => { + await act(async () => { + renderComponent({ pageStep: 1 }); + }); + + expect(screen.getByText('0 USERS_SELECTED')).toBeInTheDocument(); + }); + +}); diff --git a/src/modules/CreateChannel/components/CreateChannelUI/index.tsx b/src/modules/CreateChannel/components/CreateChannelUI/index.tsx index e96258f2bc..8469efc7f6 100644 --- a/src/modules/CreateChannel/components/CreateChannelUI/index.tsx +++ b/src/modules/CreateChannel/components/CreateChannelUI/index.tsx @@ -2,10 +2,10 @@ import './create-channel-ui.scss'; import React from 'react'; -import { useCreateChannelContext } from '../../context/CreateChannelProvider'; import InviteUsers from '../InviteUsers'; import SelectChannelType from '../SelectChannelType'; +import useCreateChannel from '../../context/useCreateChannel'; export interface CreateChannelUIProps { onCancel?(): void; @@ -16,15 +16,19 @@ const CreateChannel: React.FC = (props: CreateChannelUIPro const { onCancel, renderStepOne } = props; const { - step, - setStep, - userListQuery, - } = useCreateChannelContext(); + state: { + pageStep, + userListQuery, + }, + actions: { + setPageStep, + }, + } = useCreateChannel(); return ( <> { - step === 0 && ( + pageStep === 0 && ( renderStepOne?.() || ( = (props: CreateChannelUIPro ) } { - step === 1 && ( + pageStep === 1 && ( { - setStep(0); + setPageStep(0); onCancel?.(); }} /> diff --git a/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx b/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx index 7cafa9820a..c16ff47bf6 100644 --- a/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx +++ b/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx @@ -1,21 +1,29 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import '@testing-library/jest-dom/matchers'; import InviteUsers from '../index'; import { ApplicationUserListQuery } from '@sendbird/chat'; -import { SendbirdSdkContext } from '../../../../../lib/SendbirdSdkContext'; -import { SendBirdState } from '../../../../../lib/types'; - -jest.mock('../../../context/CreateChannelProvider', () => ({ - useCreateChannelContext: jest.fn(() => ({ - onBeforeCreateChannel: jest.fn(), - onCreateChannel: jest.fn(), - overrideInviteUser: jest.fn(), - createChannel: jest.fn().mockResolvedValue({}), - type: 'group', +import { CHANNEL_TYPE } from '../../../types'; +import * as useCreateChannelModule from '../../../context/useCreateChannel'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, })), })); +jest.mock('../../../context/useCreateChannel'); // Mock createPortal function to render content directly without portal jest.mock('react-dom', () => ({ @@ -23,7 +31,60 @@ jest.mock('react-dom', () => ({ createPortal: (node) => node, })); +const mockStringSet = { + MODAL__CREATE_CHANNEL__TITLE: 'CREATE_CHANNEL', + MODAL__INVITE_MEMBER__SELECTED: 'USERS_SELECTED', + BUTTON__CREATE: 'CREATE', +}; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + sdk: undefined, + createChannel: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: undefined, + onBeforeCreateChannel: undefined, + step: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; + +const defaultMockActions = { + setStep: jest.fn(), + setType: jest.fn(), +}; + +const defaultMockInvitUserState = { + user: { userId: 'test-user-id' }, +}; + describe('InviteUsers', () => { + const mockUseCreateChannel = useCreateChannelModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}, mockInviteUsersState = {}) => { + mockUseCreateChannel.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + const inviteUserProps = { ...defaultMockInvitUserState, ...mockInviteUsersState }; + + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should enable the modal submit button when there is only the logged-in user is in the user list', async () => { const userListQuery = jest.fn( () => ({ @@ -32,13 +93,9 @@ describe('InviteUsers', () => { } as unknown as ApplicationUserListQuery), ); - render( - - - , - ); + renderComponent({}, {}, { userListQuery }); - expect(await screen.findByText('Create')).toBeEnabled(); + expect(await screen.findByText('CREATE')).toBeEnabled(); }); // TODO: add this case too diff --git a/src/modules/CreateChannel/components/InviteUsers/index.tsx b/src/modules/CreateChannel/components/InviteUsers/index.tsx index 2cb75a0b3c..04965d7264 100644 --- a/src/modules/CreateChannel/components/InviteUsers/index.tsx +++ b/src/modules/CreateChannel/components/InviteUsers/index.tsx @@ -4,7 +4,6 @@ import type { GroupChannelCreateParams } from '@sendbird/chat/groupChannel'; import './invite-users.scss'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import { useCreateChannelContext } from '../../context/CreateChannelProvider'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; import Modal from '../../../../ui/Modal'; @@ -15,6 +14,7 @@ import UserListItem from '../../../../ui/UserListItem'; import { createDefaultUserListQuery, filterUser, setChannelType } from './utils'; import { noop } from '../../../../utils/utils'; import { UserListQuery } from '../../../../types'; +import useCreateChannel from '../../context/useCreateChannel'; export interface InviteUsersProps { onCancel?: () => void; @@ -28,14 +28,18 @@ const InviteUsers: React.FC = ({ userListQuery, }: InviteUsersProps) => { const { - onCreateChannelClick, - onBeforeCreateChannel, - onChannelCreated, - createChannel, - onCreateChannel, - overrideInviteUser, - type, - } = useCreateChannelContext(); + state: { + onCreateChannelClick, + onBeforeCreateChannel, + onChannelCreated, + onCreateChannel, + overrideInviteUser, + type, + }, + actions: { + createChannel, + }, + } = useCreateChannel(); const globalStore = useSendbirdStateContext(); const userId = globalStore?.config?.userId; diff --git a/src/modules/CreateChannel/components/SelectChannelType.tsx b/src/modules/CreateChannel/components/SelectChannelType.tsx index 9394d7667e..9414aa9792 100644 --- a/src/modules/CreateChannel/components/SelectChannelType.tsx +++ b/src/modules/CreateChannel/components/SelectChannelType.tsx @@ -3,8 +3,6 @@ import React, { useContext } from 'react'; import * as sendbirdSelectors from '../../../lib/selectors'; import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; -import { useCreateChannelContext } from '../context/CreateChannelProvider'; - import { LocalizationContext } from '../../../lib/LocalizationContext'; import Label, { LabelColors, LabelTypography } from '../../../ui/Label'; import Icon, { IconTypes, IconColors } from '../../../ui/Icon'; @@ -16,6 +14,7 @@ import { isSuperGroupChannelEnabled, } from '../utils'; import { CHANNEL_TYPE } from '../types'; +import useCreateChannel from '../context/useCreateChannel'; export interface SelectChannelTypeProps { onCancel?(): void; @@ -27,11 +26,12 @@ const SelectChannelType: React.FC = (props: SelectChanne const sdk = sendbirdSelectors.getSdk(store); - const createChannelProps = useCreateChannelContext(); const { - setStep, - setType, - } = createChannelProps; + actions: { + setPageStep, + setType, + }, + } = useCreateChannel(); const { stringSet } = useContext(LocalizationContext); @@ -50,13 +50,13 @@ const SelectChannelType: React.FC = (props: SelectChanne className="sendbird-add-channel__rectangle" onClick={() => { setType(CHANNEL_TYPE.GROUP); - setStep(1); + setPageStep(1); }} role="button" tabIndex={0} onKeyDown={() => { setType(CHANNEL_TYPE.GROUP); - setStep(1); + setPageStep(1); }} > = (props: SelectChanne className="sendbird-add-channel__rectangle" onClick={() => { setType(CHANNEL_TYPE.SUPERGROUP); - setStep(1); + setPageStep(1); }} role="button" tabIndex={0} onKeyDown={() => { setType(CHANNEL_TYPE.SUPERGROUP); - setStep(1); + setPageStep(1); }} > = (props: SelectChanne className="sendbird-add-channel__rectangle" onClick={() => { setType(CHANNEL_TYPE.BROADCAST); - setStep(1); + setPageStep(1); }} role="button" tabIndex={0} onKeyDown={() => { setType(CHANNEL_TYPE.BROADCAST); - setStep(1); + setPageStep(1); }} > (null); +import { createStore } from '../../../utils/storeManager'; +import { useStore } from '../../../hooks/useStore'; +import useCreateChannel from './useCreateChannel'; + +const CreateChannelContext = React.createContext> | null>(null); + +const initialState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: undefined, + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; export interface UserListQuery { hasNext?: boolean; @@ -54,11 +68,8 @@ export interface CreateChannelProviderProps { overrideInviteUser?(params: OverrideInviteUserType): void; } -type CreateChannel = (channelParams: GroupChannelCreateParams) => Promise; - -export interface CreateChannelContextInterface { +export interface CreateChannelState { sdk: SendbirdChatType; - createChannel: CreateChannel; userListQuery?(): UserListQuery; /** @@ -75,10 +86,8 @@ export interface CreateChannelContextInterface { * */ onBeforeCreateChannel?(users: Array): GroupChannelCreateParams; - step: number, - setStep: React.Dispatch>, + pageStep: number, type: CHANNEL_TYPE, - setType: React.Dispatch>, /** * @deprecated * Use the onChannelCreated instead @@ -91,9 +100,8 @@ export interface CreateChannelContextInterface { overrideInviteUser?(params: OverrideInviteUserType): void; } -const CreateChannelProvider: React.FC = (props: CreateChannelProviderProps) => { +const CreateChannelManager: React.FC = (props: CreateChannelProviderProps) => { const { - children, onCreateChannelClick, onBeforeCreateChannel, onChannelCreated, @@ -102,39 +110,64 @@ const CreateChannelProvider: React.FC = (props: Crea overrideInviteUser, } = props; + const { updateState } = useCreateChannelStore(); const store = useSendbirdStateContext(); const _userListQuery = userListQuery ?? store?.config?.userListQuery; - const [step, setStep] = useState(0); - const [type, setType] = useState(CHANNEL_TYPE.GROUP); - - return ( - { + updateState({ onCreateChannelClick, onBeforeCreateChannel, onChannelCreated, userListQuery: _userListQuery, - step, - setStep, - type, - setType, onCreateChannel, overrideInviteUser, - }}> + }); + }, [ + onCreateChannelClick, + onBeforeCreateChannel, + onChannelCreated, + userListQuery, + onCreateChannel, + overrideInviteUser, + _userListQuery, + ]); + + return null; +}; +const CreateChannelProvider: React.FC = (props: CreateChannelProviderProps) => { + const { children } = props; + + return ( + + + {children} + + ); +}; + +const createCreateChannelStore = () => createStore(initialState); +const InternalCreateChannelProvider: React.FC> = ({ children }) => { + const storeRef = useRef(createCreateChannelStore()); + + return ( + {children} ); }; +const useCreateChannelStore = () => { + return useStore(CreateChannelContext, state => state, initialState); +}; + const useCreateChannelContext = () => { - const context = React.useContext(CreateChannelContext); - if (!context) throw new Error('CreateChannelContext not found. Use within the CreateChannel module.'); - return context; + const { state, actions } = useCreateChannel(); + return { ...state, ...actions }; }; export { CreateChannelProvider, + CreateChannelContext, useCreateChannelContext, }; diff --git a/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx new file mode 100644 index 0000000000..0be826a28e --- /dev/null +++ b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { act, waitFor } from '@testing-library/react'; +import { CreateChannelProvider } from '../CreateChannelProvider'; +import { CHANNEL_TYPE } from '../../types'; +import useCreateChannel from '../useCreateChannel'; +import { renderHook } from '@testing-library/react-hooks'; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +describe('CreateChannelProvider', () => { + const initialState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: expect.any(Function), + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, + }; + + it('provide the correct initial state', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + expect(result.current.state).toEqual(initialState); + }); + + it('provides correct actions through useCreateChannel hook', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + expect(result.current.actions).toHaveProperty('setPageStep'); + expect(result.current.actions).toHaveProperty('setType'); + }); + + it('update state correctly when setPageStep is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + await act(async () => { + result.current.actions.setPageStep(1); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.pageStep).toEqual(1); + }); + }); + }); + + it('update state correctly when setType is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + await act(async () => { + result.current.actions.setType(CHANNEL_TYPE.BROADCAST); + await waitFor(() => { + const updatedState = result.current.state; + expect(updatedState.type).toEqual(CHANNEL_TYPE.BROADCAST); + }); + }); + }); + +}); diff --git a/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx b/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx new file mode 100644 index 0000000000..4a479958b2 --- /dev/null +++ b/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { CHANNEL_TYPE } from '../../types'; +import { CreateChannelProvider } from '../CreateChannelProvider'; +import { renderHook } from '@testing-library/react'; +import useCreateChannel from '../useCreateChannel'; + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + }, + initialized: true, + }, + }, + config: { logger: console }, + })), +})); + +const initialState = { + sdk: undefined, + userListQuery: undefined, + onCreateChannelClick: undefined, + onChannelCreated: expect.any(Function), + onBeforeCreateChannel: undefined, + pageStep: 0, + type: CHANNEL_TYPE.GROUP, + onCreateChannel: undefined, + overrideInviteUser: undefined, +}; + +const wrapper = ({ children }) => ( + jest.fn()}> + {children} + +); + +describe('useCreateChannel', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an error if used outside of GroupChannelListProvider', () => { + expect(() => { + renderHook(() => useCreateChannel()); + }).toThrow(new Error('useCreateChannel must be used within a CreateChannelProvider')); + }); + + it('provide the correct initial state', () => { + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + expect(result.current.state).toEqual(expect.objectContaining(initialState)); + }); + +}); diff --git a/src/modules/CreateChannel/context/useCreateChannel.ts b/src/modules/CreateChannel/context/useCreateChannel.ts new file mode 100644 index 0000000000..b65d42a80e --- /dev/null +++ b/src/modules/CreateChannel/context/useCreateChannel.ts @@ -0,0 +1,31 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useContext, useMemo } from 'react'; +import { CreateChannelContext, CreateChannelState } from './CreateChannelProvider'; +import { CHANNEL_TYPE } from '../types'; +import { getCreateGroupChannel } from '../../../lib/selectors'; +import { useSendbirdStateContext } from '../../../index'; + +const useCreateChannel = () => { + const store = useContext(CreateChannelContext); + const sendbirdStore = useSendbirdStateContext(); + if (!store) throw new Error('useCreateChannel must be used within a CreateChannelProvider'); + + const state: CreateChannelState = useSyncExternalStore(store.subscribe, store.getState); + const actions = useMemo(() => ({ + setPageStep: (pageStep: number) => store.setState(state => ({ + ...state, + pageStep, + })), + + setType: (type: CHANNEL_TYPE) => store.setState(state => ({ + ...state, + type, + })), + + createChannel: getCreateGroupChannel(sendbirdStore), + }), [store]); + + return { state, actions }; +}; + +export default useCreateChannel; diff --git a/src/ui/Modal/index.tsx b/src/ui/Modal/index.tsx index 45482746e1..03aeb8ba68 100644 --- a/src/ui/Modal/index.tsx +++ b/src/ui/Modal/index.tsx @@ -12,8 +12,8 @@ import IconButton from '../IconButton'; import Button, { ButtonTypes } from '../Button'; import Icon, { IconTypes, IconColors } from '../Icon'; import Label, { LabelTypography, LabelColors } from '../Label'; -import { useSendbirdStateContext } from '../../lib/Sendbird'; import uuidv4 from '../../utils/uuid'; +import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; export interface ModalHeaderProps { titleText: string; diff --git a/src/ui/UserListItem/index.tsx b/src/ui/UserListItem/index.tsx index f584f35f8f..7eb2157f6b 100644 --- a/src/ui/UserListItem/index.tsx +++ b/src/ui/UserListItem/index.tsx @@ -3,7 +3,7 @@ import type { User } from '@sendbird/chat'; import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import './index.scss'; -import { useSendbirdStateContext } from '../../lib/Sendbird'; +import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { useUserProfileContext } from '../../lib/UserProfileContext'; import { useLocalization } from '../../lib/LocalizationContext'; From 9931db0412991a3eb8d18a0b21e9ac9a106daee9 Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Thu, 28 Nov 2024 21:48:04 +0900 Subject: [PATCH 09/29] [CLNP-5046] Migrate ThreadProvider to the new state management pattern (#1250) ### Overview This PR migrate ThreadProvider and related files to the new state management pattern. ### Changelog * `ThreadProvider` is migrated, and `useThread()` hook is introduced. * Removed `ThreadDispatcher` usages in ThreadProvider; it is replaced with the new state-action pattern of `useThread()`. * `PubSub` of `config` still remains. It is out of scope of this PR. ### Remaining tasks * Add unit tests and integration tests. ### FurtherConcern * Handling hook * The previous `ThreadProvider` contained several custom hooks. Those hooks retrieved state and actions through `useThreadContext()` * Due to that, replacing `useThreadContext()` to new `useThread()` faced a problem. Those hooks conatin `useThread()`, `useThread()` contains the hooks. So it makes cycle. * For now, I moved all functionality of the hooks to the `useThread()`, but it looks wrong. Any good way to handle this? --- .../ParentMessageInfoItem.tsx | 18 +- .../components/ParentMessageInfo/index.tsx | 34 +- .../Thread/components/RemoveMessageModal.tsx | 8 +- .../components/SuggestedMentionList.tsx | 8 +- .../components/ThreadList/ThreadListItem.tsx | 33 +- .../ThreadList/ThreadListItemContent.tsx | 14 +- .../Thread/components/ThreadList/index.tsx | 12 +- .../components/ThreadMessageInput/index.tsx | 28 +- .../Thread/components/ThreadUI/index.tsx | 30 +- src/modules/Thread/context/ThreadProvider.tsx | 334 +++++------ .../context/__test__/ThreadProvider.spec.tsx | 215 +++++++ src/modules/Thread/context/dux/actionTypes.ts | 48 -- .../Thread/context/dux/initialState.ts | 44 -- src/modules/Thread/context/dux/reducer.ts | 403 ------------- .../context/hooks/useDeleteMessageCallback.ts | 19 +- .../Thread/context/hooks/useGetAllEmoji.ts | 17 +- .../Thread/context/hooks/useGetChannel.ts | 28 +- .../context/hooks/useGetParentMessage.ts | 30 +- .../context/hooks/useHandleChannelEvents.ts | 94 ++- .../hooks/useHandleThreadPubsubEvents.ts | 57 +- .../context/hooks/useResendMessageCallback.ts | 76 +-- .../context/hooks/useSendFileMessage.ts | 43 +- .../hooks/useSendUserMessageCallback.ts | 20 +- .../hooks/useSendVoiceMessageCallback.ts | 44 +- .../context/hooks/useSetCurrentUserId.ts | 23 + .../Thread/context/hooks/useThreadFetchers.ts | 75 +-- .../context/hooks/useUpdateMessageCallback.ts | 17 +- src/modules/Thread/context/useThread.ts | 546 ++++++++++++++++++ src/modules/Thread/types.tsx | 10 + 29 files changed, 1252 insertions(+), 1076 deletions(-) create mode 100644 src/modules/Thread/context/__test__/ThreadProvider.spec.tsx delete mode 100644 src/modules/Thread/context/dux/actionTypes.ts delete mode 100644 src/modules/Thread/context/dux/initialState.ts delete mode 100644 src/modules/Thread/context/dux/reducer.ts create mode 100644 src/modules/Thread/context/hooks/useSetCurrentUserId.ts create mode 100644 src/modules/Thread/context/useThread.ts diff --git a/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx b/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx index 3b55d524bf..c990decae9 100644 --- a/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx +++ b/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx @@ -28,7 +28,6 @@ import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import TextButton from '../../../../ui/TextButton'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import EmojiReactions from '../../../../ui/EmojiReactions'; -import { useThreadContext } from '../../context/ThreadProvider'; import VoiceMessageItemBody from '../../../../ui/VoiceMessageItemBody'; import TextFragment from '../../../Message/components/TextFragment'; import { tokenizeMessage } from '../../../Message/utils/tokens/tokenize'; @@ -39,6 +38,7 @@ import { useFileInfoListWithUploaded } from '../../../Channel/context/hooks/useF import { Colors } from '../../../../utils/color'; import type { OnBeforeDownloadFileMessageType } from '../../../GroupChannel/context/types'; import { openURL } from '../../../../utils/utils'; +import useThread from '../../context/useThread'; export interface ParentMessageInfoItemProps { className?: string; @@ -59,12 +59,16 @@ export default function ParentMessageInfoItem({ const currentUserId = stores?.userStore?.user?.userId; const { stringSet } = useLocalization(); const { - currentChannel, - emojiContainer, - nicknamesMap, - toggleReaction, - filterEmojiCategoryIds, - } = useThreadContext(); + state: { + currentChannel, + emojiContainer, + nicknamesMap, + filterEmojiCategoryIds, + }, + actions: { + toggleReaction, + }, + } = useThread(); const { isMobile } = useMediaQueryContext(); const isReactionEnabled = config.groupChannel.enableReactions; diff --git a/src/modules/Thread/components/ParentMessageInfo/index.tsx b/src/modules/Thread/components/ParentMessageInfo/index.tsx index 3c40427855..f3238927c0 100644 --- a/src/modules/Thread/components/ParentMessageInfo/index.tsx +++ b/src/modules/Thread/components/ParentMessageInfo/index.tsx @@ -10,7 +10,6 @@ import { getSenderName, SendableMessageType } from '../../../../utils'; import { getIsReactionEnabled } from '../../../../utils/getIsReactionEnabled'; import { useLocalization } from '../../../../lib/LocalizationContext'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; -import { useThreadContext } from '../../context/ThreadProvider'; import { useUserProfileContext } from '../../../../lib/UserProfileContext'; import SuggestedMentionList from '../SuggestedMentionList'; @@ -32,6 +31,7 @@ import { getCaseResolvedReplyType } from '../../../../lib/utils/resolvedReplyTyp import { classnames } from '../../../../utils/utils'; import { MessageMenu, MessageMenuProps } from '../../../../ui/MessageMenu'; import useElementObserver from '../../../../hooks/useElementObserver'; +import useThread from '../../context/useThread'; export interface ParentMessageInfoProps { className?: string; @@ -49,20 +49,24 @@ export default function ParentMessageInfo({ const userId = stores.userStore.user?.userId ?? ''; const { dateLocale, stringSet } = useLocalization(); const { - currentChannel, - parentMessage, - allThreadMessages, - emojiContainer, - toggleReaction, - updateMessage, - deleteMessage, - onMoveToParentMessage, - onHeaderActionClick, - isMuted, - isChannelFrozen, - onBeforeDownloadFileMessage, - filterEmojiCategoryIds, - } = useThreadContext(); + state: { + currentChannel, + parentMessage, + allThreadMessages, + emojiContainer, + onMoveToParentMessage, + onHeaderActionClick, + isMuted, + isChannelFrozen, + onBeforeDownloadFileMessage, + filterEmojiCategoryIds, + }, + actions: { + toggleReaction, + updateMessage, + deleteMessage, + }, + } = useThread(); const { isMobile } = useMediaQueryContext(); const isMenuMounted = useElementObserver( diff --git a/src/modules/Thread/components/RemoveMessageModal.tsx b/src/modules/Thread/components/RemoveMessageModal.tsx index f5db80bf98..fbcfaabd64 100644 --- a/src/modules/Thread/components/RemoveMessageModal.tsx +++ b/src/modules/Thread/components/RemoveMessageModal.tsx @@ -3,9 +3,9 @@ import React, { useContext } from 'react'; import Modal from '../../../ui/Modal'; import { ButtonTypes } from '../../../ui/Button'; import { LocalizationContext } from '../../../lib/LocalizationContext'; -import { useThreadContext } from '../context/ThreadProvider'; import { SendableMessageType } from '../../../utils'; import { getModalDeleteMessageTitle } from '../../../ui/Label/stringFormatterUtils'; +import useThread from '../context/useThread'; export interface RemoveMessageProps { onCancel: () => void; // rename to onClose @@ -21,8 +21,10 @@ const RemoveMessage: React.FC = (props: RemoveMessageProps) } = props; const { stringSet } = useContext(LocalizationContext); const { - deleteMessage, - } = useThreadContext(); + actions: { + deleteMessage, + }, + } = useThread(); return ( ; export const SuggestedMentionList = (props: SuggestedMentionListProps) => { - const { currentChannel } = useThreadContext(); + const { + state: { + currentChannel, + }, + } = useThread(); return ( diff --git a/src/modules/Thread/components/ThreadMessageInput/index.tsx b/src/modules/Thread/components/ThreadMessageInput/index.tsx index e51827a8ad..e586350b25 100644 --- a/src/modules/Thread/components/ThreadMessageInput/index.tsx +++ b/src/modules/Thread/components/ThreadMessageInput/index.tsx @@ -5,7 +5,6 @@ import './index.scss'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; -import { useThreadContext } from '../../context/ThreadProvider'; import { useLocalization } from '../../../../lib/LocalizationContext'; import MessageInput from '../../../../ui/MessageInput'; @@ -19,6 +18,7 @@ import { useHandleUploadFiles } from '../../../Channel/context/hooks/useHandleUp import { isDisabledBecauseFrozen, isDisabledBecauseMuted } from '../../../Channel/context/utils'; import { User } from '@sendbird/chat'; import { classnames } from '../../../../utils/utils'; +import useThread from '../../context/useThread'; export interface ThreadMessageInputProps { className?: string; @@ -45,23 +45,27 @@ const ThreadMessageInput = ( const { isMobile } = useMediaQueryContext(); const { stringSet } = useLocalization(); const { isOnline, userMention, logger, groupChannel } = config; - const threadContext = useThreadContext(); + const threadContext = useThread(); const { - currentChannel, - parentMessage, - sendMessage, - sendFileMessage, - sendVoiceMessage, - sendMultipleFilesMessage, - isMuted, - isChannelFrozen, - allThreadMessages, + state: { + currentChannel, + parentMessage, + isMuted, + isChannelFrozen, + allThreadMessages, + }, + actions: { + sendMessage, + sendFileMessage, + sendVoiceMessage, + sendMultipleFilesMessage, + }, } = threadContext; const messageInputRef = useRef(); const isMentionEnabled = groupChannel.enableMention; const isVoiceMessageEnabled = groupChannel.enableVoiceMessage; - const isMultipleFilesMessageEnabled = threadContext.isMultipleFilesMessageEnabled ?? config.isMultipleFilesMessageEnabled; + const isMultipleFilesMessageEnabled = threadContext.state.isMultipleFilesMessageEnabled ?? config.isMultipleFilesMessageEnabled; const threadInputDisabled = props.disabled || !isOnline diff --git a/src/modules/Thread/components/ThreadUI/index.tsx b/src/modules/Thread/components/ThreadUI/index.tsx index 915da29082..05f1cbf348 100644 --- a/src/modules/Thread/components/ThreadUI/index.tsx +++ b/src/modules/Thread/components/ThreadUI/index.tsx @@ -5,7 +5,6 @@ import './index.scss'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { getChannelTitle } from '../../../GroupChannel/components/GroupChannelHeader/utils'; -import { useThreadContext } from '../../context/ThreadProvider'; import { ParentMessageStateTypes, ThreadListStateTypes } from '../../types'; import ParentMessageInfo from '../ParentMessageInfo'; import ThreadHeader from '../ThreadHeader'; @@ -19,6 +18,7 @@ import { isAboutSame } from '../../context/utils'; import { MessageProvider } from '../../../Message/context/MessageProvider'; import { SendableMessageType, getHTMLTextDirection } from '../../../../utils'; import { classnames } from '../../../../utils/utils'; +import useThread from '../../context/useThread'; export interface ThreadUIProps { renderHeader?: () => React.ReactElement; @@ -59,18 +59,22 @@ const ThreadUI: React.FC = ({ stringSet, } = useLocalization(); const { - currentChannel, - allThreadMessages, - parentMessage, - parentMessageState, - threadListState, - hasMorePrev, - hasMoreNext, - fetchPrevThreads, - fetchNextThreads, - onHeaderActionClick, - onMoveToParentMessage, - } = useThreadContext(); + state: { + currentChannel, + allThreadMessages, + parentMessage, + parentMessageState, + threadListState, + hasMorePrev, + hasMoreNext, + onHeaderActionClick, + onMoveToParentMessage, + }, + actions: { + fetchPrevThreads, + fetchNextThreads, + }, + } = useThread(); const replyCount = allThreadMessages.length; const isByMe = currentUserId === parentMessage?.sender?.userId; diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx index 28c727c4c0..bb4da9a93b 100644 --- a/src/modules/Thread/context/ThreadProvider.tsx +++ b/src/modules/Thread/context/ThreadProvider.tsx @@ -1,38 +1,28 @@ -import React, { useReducer, useMemo, useEffect } from 'react'; -import { type EmojiCategory } from '@sendbird/chat'; -import { GroupChannel } from '@sendbird/chat/groupChannel'; +import React, { useMemo, useEffect, useRef } from 'react'; +import { type EmojiCategory, EmojiContainer } from '@sendbird/chat'; +import { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import type { - BaseMessage, FileMessage, - FileMessageCreateParams, MultipleFilesMessage, + FileMessageCreateParams, MultipleFilesMessageCreateParams, UserMessageCreateParams, } from '@sendbird/chat/message'; import { getNicknamesMapFromMembers, getParentMessageFrom } from './utils'; import { UserProfileProvider, UserProfileProviderProps } from '../../../lib/UserProfileContext'; -import { CustomUseReducerDispatcher } from '../../../lib/SendbirdState'; import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; -import threadReducer from './dux/reducer'; -import { ThreadContextActionTypes } from './dux/actionTypes'; -import threadInitialState, { ThreadContextInitialState } from './dux/initialState'; - import type { OnBeforeDownloadFileMessageType } from '../../GroupChannel/context/types'; import useGetChannel from './hooks/useGetChannel'; import useGetAllEmoji from './hooks/useGetAllEmoji'; import useGetParentMessage from './hooks/useGetParentMessage'; import useHandleThreadPubsubEvents from './hooks/useHandleThreadPubsubEvents'; import useHandleChannelEvents from './hooks/useHandleChannelEvents'; -import useSendFileMessageCallback from './hooks/useSendFileMessage'; -import useUpdateMessageCallback from './hooks/useUpdateMessageCallback'; -import useDeleteMessageCallback from './hooks/useDeleteMessageCallback'; -import useToggleReactionCallback from './hooks/useToggleReactionsCallback'; -import useSendUserMessageCallback, { SendMessageParams } from './hooks/useSendUserMessageCallback'; -import useResendMessageCallback from './hooks/useResendMessageCallback'; -import useSendVoiceMessageCallback from './hooks/useSendVoiceMessageCallback'; -import { PublishingModuleType, useSendMultipleFilesMessage } from './hooks/useSendMultipleFilesMessage'; -import { SendableMessageType } from '../../../utils'; -import { useThreadFetchers } from './hooks/useThreadFetchers'; +import { CoreMessageType, SendableMessageType } from '../../../utils'; +import { createStore } from '../../../utils/storeManager'; +import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../types'; +import { useStore } from '../../../hooks/useStore'; +import useSetCurrentUserId from './hooks/useSetCurrentUserId'; +import useThread from './useThread'; export interface ThreadProviderProps extends Pick { @@ -49,25 +39,80 @@ export interface ThreadProviderProps extends isMultipleFilesMessageEnabled?: boolean; filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][]; } -export interface ThreadProviderInterface extends ThreadProviderProps, ThreadContextInitialState { - // hooks for fetching threads - fetchPrevThreads: (callback?: (messages?: Array) => void) => void; - fetchNextThreads: (callback?: (messages?: Array) => void) => void; - toggleReaction: ReturnType; - sendMessage: (props: SendMessageParams) => void; - sendFileMessage: (file: File, quoteMessage?: SendableMessageType) => Promise; - sendVoiceMessage: ReturnType; - sendMultipleFilesMessage: (files: Array, quoteMessage?: SendableMessageType) => Promise, - resendMessage: (failedMessage: SendableMessageType) => void; - updateMessage: ReturnType; - deleteMessage: (message: SendableMessageType) => Promise; + +export interface ThreadState { + channelUrl: string; + message: SendableMessageType | null; + onHeaderActionClick?: () => void; + onMoveToParentMessage?: (props: { message: SendableMessageType, channel: GroupChannel }) => void; + onBeforeSendUserMessage?: (message: string, quotedMessage?: SendableMessageType) => UserMessageCreateParams; + onBeforeSendFileMessage?: (file: File, quotedMessage?: SendableMessageType) => FileMessageCreateParams; + onBeforeSendVoiceMessage?: (file: File, quotedMessage?: SendableMessageType) => FileMessageCreateParams; + onBeforeSendMultipleFilesMessage?: (files: Array, quotedMessage?: SendableMessageType) => MultipleFilesMessageCreateParams; + onBeforeDownloadFileMessage?: OnBeforeDownloadFileMessageType; + isMultipleFilesMessageEnabled?: boolean; + filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][]; + currentChannel: GroupChannel; + allThreadMessages: Array; + localThreadMessages: Array; + parentMessage: SendableMessageType; + channelState: ChannelStateTypes; + parentMessageState: ParentMessageStateTypes; + threadListState: ThreadListStateTypes; + hasMorePrev: boolean; + hasMoreNext: boolean; + emojiContainer: EmojiContainer; + isMuted: boolean; + isChannelFrozen: boolean; + currentUserId: string; + typingMembers: Member[]; nicknamesMap: Map; } -const ThreadContext = React.createContext(null); -export const ThreadProvider = (props: ThreadProviderProps) => { +const initialState = { + channelUrl: '', + message: null, + onHeaderActionClick: undefined, + onMoveToParentMessage: undefined, + onBeforeSendUserMessage: undefined, + onBeforeSendFileMessage: undefined, + onBeforeSendVoiceMessage: undefined, + onBeforeSendMultipleFilesMessage: undefined, + onBeforeDownloadFileMessage: undefined, + isMultipleFilesMessageEnabled: undefined, + filterEmojiCategoryIds: undefined, + currentChannel: null, + allThreadMessages: [], + localThreadMessages: [], + parentMessage: null, + channelState: ChannelStateTypes.NIL, + parentMessageState: ParentMessageStateTypes.NIL, + threadListState: ThreadListStateTypes.NIL, + hasMorePrev: false, + hasMoreNext: false, + emojiContainer: {} as EmojiContainer, + isMuted: false, + isChannelFrozen: false, + currentUserId: '', + typingMembers: [], + nicknamesMap: null, +}; + +export const ThreadContext = React.createContext> | null>(null); + +export const InternalThreadProvider: React.FC> = ({ children }) => { + const storeRef = useRef(createStore(initialState)); + + return ( + + {children} + + ); +}; + +export const ThreadManager: React.FC> = (props) => { const { - children, + message, channelUrl, onHeaderActionClick, onMoveToParentMessage, @@ -79,8 +124,19 @@ export const ThreadProvider = (props: ThreadProviderProps) => { isMultipleFilesMessageEnabled, filterEmojiCategoryIds, } = props; - const propsMessage = props?.message; - const propsParentMessage = getParentMessageFrom(propsMessage); + + const { + state: { + currentChannel, + parentMessage, + }, + actions: { + initializeThreadFetcher, + }, + } = useThread(); + const { updateState } = useThreadStore(); + + const propsParentMessage = getParentMessageFrom(message); // Context from SendbirdProvider const globalStore = useSendbirdStateContext(); const { stores, config } = globalStore; @@ -92,185 +148,95 @@ export const ThreadProvider = (props: ThreadProviderProps) => { // // config const { logger, pubSub } = config; - const isMentionEnabled = config.groupChannel.enableMention; - const isReactionEnabled = config.groupChannel.enableReactions; - - // dux of Thread - const [threadStore, threadDispatcher] = useReducer( - threadReducer, - threadInitialState, - ) as [ThreadContextInitialState, CustomUseReducerDispatcher]; - const { - currentChannel, - allThreadMessages, - localThreadMessages, - parentMessage, - channelState, - threadListState, - parentMessageState, - hasMorePrev, - hasMoreNext, - emojiContainer, - isMuted, - isChannelFrozen, - currentUserId, - typingMembers, - }: ThreadContextInitialState = threadStore; - // Initialization - useEffect(() => { - threadDispatcher({ - type: ThreadContextActionTypes.INIT_USER_ID, - payload: user?.userId, - }); - }, [user]); + useSetCurrentUserId({ user }); useGetChannel({ channelUrl, sdkInit, - message: propsMessage, - }, { sdk, logger, threadDispatcher }); + message, + }, { sdk, logger }); useGetParentMessage({ channelUrl, sdkInit, parentMessage: propsParentMessage, - }, { sdk, logger, threadDispatcher }); - useGetAllEmoji({ sdk }, { logger, threadDispatcher }); + }, { sdk, logger }); + useGetAllEmoji({ sdk }, { logger }); // Handle channel events useHandleChannelEvents({ sdk, currentChannel, - }, { logger, threadDispatcher }); + }, { logger }); useHandleThreadPubsubEvents({ sdkInit, currentChannel, parentMessage, - }, { logger, pubSub, threadDispatcher }); - - const { initialize, loadPrevious, loadNext } = useThreadFetchers({ - parentMessage, - // anchorMessage should be null when parentMessage doesn't exist - anchorMessage: propsMessage?.messageId !== propsParentMessage?.messageId ? propsMessage || undefined : undefined, - logger, - isReactionEnabled, - threadDispatcher, - threadListState, - oldestMessageTimeStamp: allThreadMessages[0]?.createdAt || 0, - latestMessageTimeStamp: allThreadMessages[allThreadMessages.length - 1]?.createdAt || 0, - }); + }, { logger, pubSub }); useEffect(() => { if (stores.sdkStore.initialized && config.isOnline) { - initialize(); + initializeThreadFetcher(); } - }, [stores.sdkStore.initialized, config.isOnline, initialize]); + }, [stores.sdkStore.initialized, config.isOnline, initializeThreadFetcher]); - const toggleReaction = useToggleReactionCallback({ currentChannel }, { logger }); + // memo + const nicknamesMap: Map = useMemo(() => ( + (config.groupChannel.replyType !== 'none' && currentChannel) + ? getNicknamesMapFromMembers(currentChannel?.members) + : new Map() + ), [currentChannel?.members]); - // Send Message Hooks - const sendMessage = useSendUserMessageCallback({ - isMentionEnabled, - currentChannel, + useEffect(() => { + updateState({ + channelUrl, + message, + onHeaderActionClick, + onMoveToParentMessage, + onBeforeSendUserMessage, + onBeforeSendFileMessage, + onBeforeSendVoiceMessage, + onBeforeSendMultipleFilesMessage, + onBeforeDownloadFileMessage, + isMultipleFilesMessageEnabled, + filterEmojiCategoryIds, + nicknamesMap, + }); + }, [ + channelUrl, + message, + onHeaderActionClick, + onMoveToParentMessage, onBeforeSendUserMessage, - }, { - logger, - pubSub, - threadDispatcher, - }); - const sendFileMessage = useSendFileMessageCallback({ - currentChannel, onBeforeSendFileMessage, - }, { - logger, - pubSub, - threadDispatcher, - }); - const sendVoiceMessage = useSendVoiceMessageCallback({ - currentChannel, onBeforeSendVoiceMessage, - }, { - logger, - pubSub, - threadDispatcher, - }); - const [sendMultipleFilesMessage] = useSendMultipleFilesMessage({ - currentChannel, onBeforeSendMultipleFilesMessage, - publishingModules: [PublishingModuleType.THREAD], - }, { - logger, - pubSub, - }); + onBeforeDownloadFileMessage, + isMultipleFilesMessageEnabled, + filterEmojiCategoryIds, + nicknamesMap, + ]); - const resendMessage = useResendMessageCallback({ - currentChannel, - }, { logger, pubSub, threadDispatcher }); - const updateMessage = useUpdateMessageCallback({ - currentChannel, - isMentionEnabled, - }, { logger, pubSub, threadDispatcher }); - const deleteMessage = useDeleteMessageCallback( - { currentChannel, threadDispatcher }, - { logger }, - ); + return null; +}; - // memo - const nicknamesMap: Map = useMemo(() => ( - (config.groupChannel.replyType !== 'none' && currentChannel) - ? getNicknamesMapFromMembers(currentChannel?.members) - : new Map() - ), [currentChannel?.members]); +export const ThreadProvider = (props: ThreadProviderProps) => { + const { children } = props; return ( - - {/* UserProfileProvider */} - - {children} - - + + + {/* UserProfileProvider */} + + {children} + + ); }; export const useThreadContext = () => { - const context = React.useContext(ThreadContext); - if (!context) throw new Error('ThreadContext not found. Use within the Thread module'); - return context; + const { state, actions } = useThread(); + return { ...state, ...actions }; +}; + +const useThreadStore = () => { + return useStore(ThreadContext, state => state, initialState); }; diff --git a/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx new file mode 100644 index 0000000000..01b5e866c6 --- /dev/null +++ b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { act, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ThreadProvider } from '../ThreadProvider'; +import useThread from '../useThread'; +import { SendableMessageType } from '../../../../utils'; + +const mockChannel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), +}; + +const mockNewMessage = { + messageId: 42, + message: 'new message', +}; + +const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); + +jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + __esModule: true, + default: jest.fn(() => ({ + stores: { + sdkStore: { + sdk: { + groupChannel: { + getChannel: mockGetChannel, + }, + }, + initialized: true, + }, + userStore: { user: { userId: 'test-user-id' } }, + }, + config: { + logger: console, + pubSub: { + publish: jest.fn(), + }, + groupChannel: { + enableMention: true, + enableReactions: true, + }, + }, + })), +})); + +describe('ThreadProvider', () => { + const initialMockMessage = { + messageId: 1, + } as SendableMessageType; + + it('provides the correct initial state', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + await act(async () => { + const { result } = renderHook(() => useThread(), { wrapper }); + await waitFor(() => { + expect(result.current.state.message).toBe(initialMockMessage); + }); + }); + + }); + + it('provides correct actions through useThread hook', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + await act(async () => { + const { result } = renderHook(() => useThread(), { wrapper }); + await waitFor(() => { + expect(result.current.actions).toHaveProperty('toggleReaction'); + expect(result.current.actions).toHaveProperty('sendMessage'); + expect(result.current.actions).toHaveProperty('sendFileMessage'); + expect(result.current.actions).toHaveProperty('sendVoiceMessage'); + expect(result.current.actions).toHaveProperty('sendMultipleFilesMessage'); + expect(result.current.actions).toHaveProperty('resendMessage'); + expect(result.current.actions).toHaveProperty('initializeThreadFetcher'); + expect(result.current.actions).toHaveProperty('fetchPrevThreads'); + expect(result.current.actions).toHaveProperty('fetchNextThreads'); + expect(result.current.actions).toHaveProperty('updateMessage'); + expect(result.current.actions).toHaveProperty('deleteMessage'); + expect(result.current.actions).toHaveProperty('setCurrentUserId'); + expect(result.current.actions).toHaveProperty('getChannelStart'); + expect(result.current.actions).toHaveProperty('getChannelSuccess'); + expect(result.current.actions).toHaveProperty('getChannelFailure'); + expect(result.current.actions).toHaveProperty('getParentMessageStart'); + expect(result.current.actions).toHaveProperty('getParentMessageSuccess'); + expect(result.current.actions).toHaveProperty('getParentMessageFailure'); + expect(result.current.actions).toHaveProperty('setEmojiContainer'); + expect(result.current.actions).toHaveProperty('onMessageReceived'); + expect(result.current.actions).toHaveProperty('onMessageUpdated'); + expect(result.current.actions).toHaveProperty('onMessageDeleted'); + expect(result.current.actions).toHaveProperty('onMessageDeletedByReqId'); + expect(result.current.actions).toHaveProperty('onReactionUpdated'); + expect(result.current.actions).toHaveProperty('onUserMuted'); + expect(result.current.actions).toHaveProperty('onUserUnmuted'); + expect(result.current.actions).toHaveProperty('onUserBanned'); + expect(result.current.actions).toHaveProperty('onUserUnbanned'); + expect(result.current.actions).toHaveProperty('onUserLeft'); + expect(result.current.actions).toHaveProperty('onChannelFrozen'); + expect(result.current.actions).toHaveProperty('onChannelUnfrozen'); + expect(result.current.actions).toHaveProperty('onOperatorUpdated'); + expect(result.current.actions).toHaveProperty('onTypingStatusUpdated'); + expect(result.current.actions).toHaveProperty('sendMessageStart'); + expect(result.current.actions).toHaveProperty('sendMessageSuccess'); + expect(result.current.actions).toHaveProperty('sendMessageFailure'); + expect(result.current.actions).toHaveProperty('resendMessageStart'); + expect(result.current.actions).toHaveProperty('onFileInfoUpdated'); + expect(result.current.actions).toHaveProperty('initializeThreadListStart'); + expect(result.current.actions).toHaveProperty('initializeThreadListSuccess'); + expect(result.current.actions).toHaveProperty('initializeThreadListFailure'); + expect(result.current.actions).toHaveProperty('getPrevMessagesStart'); + expect(result.current.actions).toHaveProperty('getPrevMessagesSuccess'); + expect(result.current.actions).toHaveProperty('getPrevMessagesFailure'); + expect(result.current.actions).toHaveProperty('getNextMessagesStart'); + expect(result.current.actions).toHaveProperty('getNextMessagesSuccess'); + expect(result.current.actions).toHaveProperty('getNextMessagesFailure'); + }); + }); + + }); + + it('updates state when props change', async () => { + const wrapper = ({ children }) => ( + {}}> + {children} + + ); + + await act(async () => { + const { result } = renderHook(() => useThread(), { wrapper }); + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + + result.current.actions.setCurrentUserId('new-user-id'); + + await waitFor(() => { + expect(result.current.state.currentUserId).toEqual('new-user-id'); + }); + }); + }); + + // it('calls sendMessage correctly', async () => { + // const wrapper = ({ children }) => ( + // + // {children} + // + // ); + // + // const { result } = renderHook(() => useThreadContext(), { wrapper }); + // const sendMessageMock = jest.fn(); + // + // result.current.sendMessage({ message: 'Test Message' }); + // + // expect(sendMessageMock).toHaveBeenCalledWith({ message: 'Test Message' }); + // }); + // + // it('handles channel events correctly', () => { + // const wrapper = ({ children }) => ( + // + // {children} + // + // ); + // + // render(); + // // Add assertions for handling channel events + // }); + // + // it('updates state when nicknamesMap is updated', async () => { + // const wrapper = ({ children }) => ( + // + // {children} + // + // ); + // + // const { result } = renderHook(() => useThreadContext(), { wrapper }); + // + // await act(async () => { + // result.current.updateState({ + // nicknamesMap: new Map([['user1', 'User One'], ['user2', 'User Two']]), + // }); + // await waitFor(() => { + // expect(result.current.nicknamesMap.get('user1')).toBe('User One'); + // }); + // }); + // }); + // + // it('calls onMoveToParentMessage when provided', async () => { + // const onMoveToParentMessageMock = jest.fn(); + // const wrapper = ({ children }) => ( + // + // {children} + // + // ); + // + // const { result } = renderHook(() => useThreadContext(), { wrapper }); + // + // await act(async () => { + // result.current.onMoveToParentMessage({ message: { messageId: 1 }, channel: {} }); + // await waitFor(() => { + // expect(onMoveToParentMessageMock).toHaveBeenCalled(); + // }); + // }); + // }); +}); diff --git a/src/modules/Thread/context/dux/actionTypes.ts b/src/modules/Thread/context/dux/actionTypes.ts deleted file mode 100644 index 93e845f6c4..0000000000 --- a/src/modules/Thread/context/dux/actionTypes.ts +++ /dev/null @@ -1,48 +0,0 @@ -export enum ThreadContextActionTypes { - // initialize - INIT_USER_ID = 'INIT_USER_ID', - // channel - GET_CHANNEL_START = 'GET_CHANNEL_START', - GET_CHANNEL_SUCCESS = 'GET_CHANNEL_SUCCESS', - GET_CHANNEL_FAILURE = 'GET_CHANNEL_FAILURE', - // emojis - SET_EMOJI_CONTAINER = 'SET_EMOJI_CONTAINER', - // parent message - GET_PARENT_MESSAGE_START = 'GET_PARENT_MESSAGE_START', - GET_PARENT_MESSAGE_SUCCESS = 'GET_PARENT_MESSAGE_SUCCESS', - GET_PARENT_MESSAGE_FAILURE = 'GET_PARENT_MESSAGE_FAILURE', - // fetch threads - INITIALIZE_THREAD_LIST_START = 'INITIALIZE_THREAD_LIST_START', - INITIALIZE_THREAD_LIST_SUCCESS = 'INITIALIZE_THREAD_LIST_SUCCESS', - INITIALIZE_THREAD_LIST_FAILURE = 'INITIALIZE_THREAD_LIST_FAILURE', - GET_PREV_MESSAGES_START = 'GET_PREV_MESSAGES_START', - GET_PREV_MESSAGES_SUCESS = 'GET_PREV_MESSAGES_SUCESS', - GET_PREV_MESSAGES_FAILURE = 'GET_PREV_MESSAGES_FAILURE', - GET_NEXT_MESSAGES_START = 'GET_NEXT_MESSAGES_START', - GET_NEXT_MESSAGES_SUCESS = 'GET_NEXT_MESSAGES_SUCESS', - GET_NEXT_MESSAGES_FAILURE = 'GET_NEXT_MESSAGES_FAILURE', - // handle messages - SEND_MESSAGE_START = 'SEND_MESSAGE_START', - SEND_MESSAGE_SUCESS = 'SEND_MESSAGE_SUCESS', - SEND_MESSAGE_FAILURE = 'SEND_MESSAGE_FAILURE', - RESEND_MESSAGE_START = 'RESEND_MESSAGE_START', - ON_MESSAGE_DELETED_BY_REQ_ID = 'ON_MESSAGE_DELETED_BY_REQ_ID', - // event handlers - message status change - ON_MESSAGE_RECEIVED = 'ON_MESSAGE_RECEIVED', - ON_MESSAGE_UPDATED = 'ON_MESSAGE_UPDATED', - ON_MESSAGE_DELETED = 'ON_MESSAGE_DELETED', - ON_REACTION_UPDATED = 'ON_REACTION_UPDATED', - ON_FILE_INFO_UPLOADED = 'ON_FILE_INFO_UPLOADED', - // event handlers - user status change - ON_USER_MUTED = 'ON_USER_MUTED', - ON_USER_UNMUTED = 'ON_USER_UNMUTED', - ON_USER_BANNED = 'ON_USER_BANNED', - ON_USER_UNBANNED = 'ON_USER_UNBANNED', - ON_USER_LEFT = 'ON_USER_LEFT', - // event handler - channel status change - ON_CHANNEL_FROZEN = 'ON_CHANNEL_FROZEN', - ON_CHANNEL_UNFROZEN = 'ON_CHANNEL_UNFROZEN', - ON_OPERATOR_UPDATED = 'ON_OPERATOR_UPDATED', - // event handler - typing status change - ON_TYPING_STATUS_UPDATED = 'ON_TYPING_STATUS_UPDATED', -} diff --git a/src/modules/Thread/context/dux/initialState.ts b/src/modules/Thread/context/dux/initialState.ts deleted file mode 100644 index 8529c53d0f..0000000000 --- a/src/modules/Thread/context/dux/initialState.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { EmojiContainer } from '@sendbird/chat'; -import { GroupChannel, Member } from '@sendbird/chat/groupChannel'; -import { - ChannelStateTypes, - ParentMessageStateTypes, - ThreadListStateTypes, -} from '../../types'; -import { CoreMessageType, SendableMessageType } from '../../../../utils'; - -export interface ThreadContextInitialState { - currentChannel: GroupChannel; - allThreadMessages: Array; - localThreadMessages: Array; - parentMessage: SendableMessageType; - channelState: ChannelStateTypes; - parentMessageState: ParentMessageStateTypes; - threadListState: ThreadListStateTypes; - hasMorePrev: boolean; - hasMoreNext: boolean; - emojiContainer: EmojiContainer; - isMuted: boolean; - isChannelFrozen: boolean; - currentUserId: string; - typingMembers: Member[]; -} - -const initialState: ThreadContextInitialState = { - currentChannel: null, - allThreadMessages: [], - localThreadMessages: [], - parentMessage: null, - channelState: ChannelStateTypes.NIL, - parentMessageState: ParentMessageStateTypes.NIL, - threadListState: ThreadListStateTypes.NIL, - hasMorePrev: false, - hasMoreNext: false, - emojiContainer: {} as EmojiContainer, - isMuted: false, - isChannelFrozen: false, - currentUserId: '', - typingMembers: [], -}; - -export default initialState; diff --git a/src/modules/Thread/context/dux/reducer.ts b/src/modules/Thread/context/dux/reducer.ts deleted file mode 100644 index 68cbade914..0000000000 --- a/src/modules/Thread/context/dux/reducer.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { MultipleFilesMessage, ReactionEvent, UserMessage } from '@sendbird/chat/message'; -import { NEXT_THREADS_FETCH_SIZE, PREV_THREADS_FETCH_SIZE } from '../../consts'; -import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../types'; -import { compareIds } from '../utils'; -import { ThreadContextActionTypes as actionTypes } from './actionTypes'; -import { ThreadContextInitialState } from './initialState'; -import { SendableMessageType } from '../../../../utils'; - -interface ActionInterface { - type: actionTypes; - payload?: any; -} - -export default function reducer( - state: ThreadContextInitialState, - action: ActionInterface, -): ThreadContextInitialState { - switch (action.type) { - // initialize - case actionTypes.INIT_USER_ID: { - return { - ...state, - currentUserId: action.payload, - }; - } - case actionTypes.GET_CHANNEL_START: { - return { - ...state, - channelState: ChannelStateTypes.LOADING, - currentChannel: null, - }; - } - case actionTypes.GET_CHANNEL_SUCCESS: { - const groupChannel = action.payload.groupChannel as GroupChannel; - return { - ...state, - channelState: ChannelStateTypes.INITIALIZED, - currentChannel: groupChannel, - // only support in normal group channel - isMuted: groupChannel?.members?.find((member) => member?.userId === state.currentUserId)?.isMuted || false, - isChannelFrozen: groupChannel?.isFrozen || false, - }; - } - case actionTypes.GET_CHANNEL_FAILURE: { - return { - ...state, - channelState: ChannelStateTypes.INVALID, - currentChannel: null, - }; - } - case actionTypes.SET_EMOJI_CONTAINER: { - const { emojiContainer } = action.payload; - return { - ...state, - emojiContainer: emojiContainer, - }; - } - case actionTypes.GET_PARENT_MESSAGE_START: { - return { - ...state, - parentMessageState: ParentMessageStateTypes.LOADING, - parentMessage: null, - }; - } - case actionTypes.GET_PARENT_MESSAGE_SUCCESS: { - return { - ...state, - parentMessageState: ParentMessageStateTypes.INITIALIZED, - parentMessage: action.payload.parentMessage, - }; - } - case actionTypes.GET_PARENT_MESSAGE_FAILURE: { - return { - ...state, - parentMessageState: ParentMessageStateTypes.INVALID, - parentMessage: null, - }; - } - // fetch threads - case actionTypes.INITIALIZE_THREAD_LIST_START: { - return { - ...state, - threadListState: ThreadListStateTypes.LOADING, - allThreadMessages: [], - }; - } - case actionTypes.INITIALIZE_THREAD_LIST_SUCCESS: { - const { parentMessage, anchorMessage, threadedMessages } = action.payload; - const anchorMessageCreatedAt = (!anchorMessage?.messageId) ? parentMessage?.createdAt : anchorMessage?.createdAt; - const anchorIndex = threadedMessages.findIndex((message) => message?.createdAt > anchorMessageCreatedAt); - const prevThreadMessages = anchorIndex > -1 ? threadedMessages.slice(0, anchorIndex) : threadedMessages; - const anchorThreadMessage = anchorMessage?.messageId ? [anchorMessage] : []; - const nextThreadMessages = anchorIndex > -1 ? threadedMessages.slice(anchorIndex) : []; - return { - ...state, - threadListState: ThreadListStateTypes.INITIALIZED, - hasMorePrev: anchorIndex === -1 || anchorIndex === PREV_THREADS_FETCH_SIZE, - hasMoreNext: threadedMessages.length - anchorIndex === NEXT_THREADS_FETCH_SIZE, - allThreadMessages: [prevThreadMessages, anchorThreadMessage, nextThreadMessages].flat(), - }; - } - case actionTypes.INITIALIZE_THREAD_LIST_FAILURE: { - return { - ...state, - threadListState: ThreadListStateTypes.INVALID, - allThreadMessages: [], - }; - } - case actionTypes.GET_NEXT_MESSAGES_START: { - return { - ...state, - }; - } - case actionTypes.GET_NEXT_MESSAGES_SUCESS: { - const { threadedMessages } = action.payload; - return { - ...state, - hasMoreNext: threadedMessages.length === NEXT_THREADS_FETCH_SIZE, - allThreadMessages: [...state.allThreadMessages, ...threadedMessages], - }; - } - case actionTypes.GET_NEXT_MESSAGES_FAILURE: { - return { - ...state, - hasMoreNext: false, - }; - } - case actionTypes.GET_PREV_MESSAGES_START: { - return { - ...state, - }; - } - case actionTypes.GET_PREV_MESSAGES_SUCESS: { - const { threadedMessages } = action.payload; - return { - ...state, - hasMorePrev: threadedMessages.length === PREV_THREADS_FETCH_SIZE, - allThreadMessages: [...threadedMessages, ...state.allThreadMessages], - }; - } - case actionTypes.GET_PREV_MESSAGES_FAILURE: { - return { - ...state, - hasMorePrev: false, - }; - } - // event handlers - message status change - case actionTypes.ON_MESSAGE_RECEIVED: { - const { channel, message }: { channel: GroupChannel, message: SendableMessageType } = action.payload; - - if ( - state.currentChannel?.url !== channel?.url - || state.hasMoreNext - || message?.parentMessage?.messageId !== state?.parentMessage?.messageId - ) { - return state; - } - const isAlreadyReceived = state.allThreadMessages.findIndex((m) => ( - m.messageId === message.messageId - )) > -1; - return { - ...state, - parentMessage: state.parentMessage?.messageId === message?.messageId ? message : state.parentMessage, - allThreadMessages: isAlreadyReceived - ? state.allThreadMessages.map((m) => ( - m.messageId === message.messageId ? message : m - )) - : [ - ...state.allThreadMessages.filter((m) => (m as SendableMessageType)?.reqId !== message?.reqId), - message, - ], - }; - } - case actionTypes.ON_MESSAGE_UPDATED: { - const { channel, message } = action.payload; - if (state.currentChannel?.url !== channel?.url) { - return state; - } - return { - ...state, - parentMessage: state.parentMessage?.messageId === message?.messageId - ? message - : state.parentMessage, - allThreadMessages: state.allThreadMessages?.map((msg) => ( - (msg?.messageId === message?.messageId) ? message : msg - )), - }; - } - case actionTypes.ON_MESSAGE_DELETED: { - const { channel, messageId } = action.payload; - if (state.currentChannel?.url !== channel?.url) { - return state; - } - if (state?.parentMessage?.messageId === messageId) { - return { - ...state, - parentMessage: null, - parentMessageState: ParentMessageStateTypes.NIL, - allThreadMessages: [], - }; - } - return { - ...state, - allThreadMessages: state.allThreadMessages?.filter((msg) => ( - msg?.messageId !== messageId - )), - localThreadMessages: state.localThreadMessages?.filter((msg) => ( - msg?.messageId !== messageId - )), - }; - } - case actionTypes.ON_MESSAGE_DELETED_BY_REQ_ID: { - return { - ...state, - localThreadMessages: state.localThreadMessages.filter((m) => ( - !compareIds((m as SendableMessageType).reqId, action.payload) - )), - }; - } - case actionTypes.ON_REACTION_UPDATED: { - const reactionEvent = action.payload?.reactionEvent as ReactionEvent; - if (state?.parentMessage?.messageId === reactionEvent?.messageId) { - state.parentMessage?.applyReactionEvent?.(reactionEvent); - } - return { - ...state, - allThreadMessages: state.allThreadMessages.map((m) => { - if (reactionEvent?.messageId === m?.messageId) { - m?.applyReactionEvent?.(reactionEvent); - return m; - } - return m; - }), - }; - } - // event handlers - user status change - case actionTypes.ON_USER_MUTED: { - const { channel, user } = action.payload; - if (state.currentChannel?.url !== channel?.url || state.currentUserId !== user?.userId) { - return state; - } - return { - ...state, - isMuted: true, - }; - } - case actionTypes.ON_USER_UNMUTED: { - const { channel, user } = action.payload; - if (state.currentChannel?.url !== channel?.url || state.currentUserId !== user?.userId) { - return state; - } - return { - ...state, - isMuted: false, - }; - } - case actionTypes.ON_USER_BANNED: { - return { - ...state, - channelState: ChannelStateTypes.NIL, - threadListState: ThreadListStateTypes.NIL, - parentMessageState: ParentMessageStateTypes.NIL, - currentChannel: null, - parentMessage: null, - allThreadMessages: [], - hasMorePrev: false, - hasMoreNext: false, - }; - } - case actionTypes.ON_USER_UNBANNED: { - return { - ...state, - }; - } - case actionTypes.ON_USER_LEFT: { - return { - ...state, - channelState: ChannelStateTypes.NIL, - threadListState: ThreadListStateTypes.NIL, - parentMessageState: ParentMessageStateTypes.NIL, - currentChannel: null, - parentMessage: null, - allThreadMessages: [], - hasMorePrev: false, - hasMoreNext: false, - }; - } - // event handler - channel status change - case actionTypes.ON_CHANNEL_FROZEN: { - return { - ...state, - isChannelFrozen: true, - }; - } - case actionTypes.ON_CHANNEL_UNFROZEN: { - return { - ...state, - isChannelFrozen: false, - }; - } - case actionTypes.ON_OPERATOR_UPDATED: { - const { channel } = action.payload; - if (channel?.url === state.currentChannel?.url) { - return { - ...state, - currentChannel: channel, - }; - } - return state; - } - // message - case actionTypes.SEND_MESSAGE_START: { - const { message } = action.payload; - return { - ...state, - localThreadMessages: [ - ...state.localThreadMessages, - message, - ], - }; - } - case actionTypes.SEND_MESSAGE_SUCESS: { - const { message } = action.payload; - return { - ...state, - allThreadMessages: [ - ...state.allThreadMessages.filter((m) => ( - !compareIds((m as UserMessage)?.reqId, message?.reqId) - )), - message, - ], - localThreadMessages: state.localThreadMessages.filter((m) => ( - !compareIds((m as UserMessage)?.reqId, message?.reqId) - )), - }; - } - case actionTypes.SEND_MESSAGE_FAILURE: { - const { message } = action.payload; - return { - ...state, - localThreadMessages: state.localThreadMessages.map((m) => ( - compareIds((m as UserMessage)?.reqId, message?.reqId) - ? message - : m - )), - }; - } - case actionTypes.RESEND_MESSAGE_START: { - const { message } = action.payload; - return { - ...state, - localThreadMessages: state.localThreadMessages.map((m) => ( - compareIds((m as UserMessage)?.reqId, message?.reqId) - ? message - : m - )), - }; - } - case actionTypes.ON_FILE_INFO_UPLOADED: { - const { channelUrl, requestId, index, uploadableFileInfo, error } = action.payload; - if (!compareIds(channelUrl, state.currentChannel?.url)) { - return state; - } - /** - * We don't have to do anything here because - * onFailed() will be called so handle error there instead. - */ - if (error) return state; - const { localThreadMessages } = state; - const messageToUpdate = localThreadMessages.find((message) => compareIds(hasReqId(message) && message.reqId, requestId), - ); - const fileInfoList = (messageToUpdate as MultipleFilesMessage) - .messageParams?.fileInfoList; - if (Array.isArray(fileInfoList)) { - fileInfoList[index] = uploadableFileInfo; - } - return { - ...state, - localThreadMessages, - }; - } - case actionTypes.ON_TYPING_STATUS_UPDATED: { - const { channel, typingMembers } = action.payload; - if (!compareIds(channel.url, state.currentChannel?.url)) { - return state; - } - return { - ...state, - typingMembers, - }; - } - default: { - return state; - } - } -} - -function hasReqId( - message: T, -): message is T & { reqId: string } { - return 'reqId' in message; -} diff --git a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts index 30c1139315..021ca7d271 100644 --- a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts @@ -1,12 +1,12 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; import { useCallback } from 'react'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { Logger } from '../../../../lib/SendbirdState'; import { SendableMessageType } from '../../../../utils'; interface DynamicProps { currentChannel: GroupChannel | null; - threadDispatcher: CustomUseReducerDispatcher; + onMessageDeletedByReqId: (reqId: string | number) => void, + onMessageDeleted: (channel: GroupChannel, messageId: number) => void, } interface StaticProps { logger: Logger; @@ -14,7 +14,8 @@ interface StaticProps { export default function useDeleteMessageCallback({ currentChannel, - threadDispatcher, + onMessageDeletedByReqId, + onMessageDeleted, }: DynamicProps, { logger, }: StaticProps): (message: SendableMessageType) => Promise { @@ -26,10 +27,7 @@ export default function useDeleteMessageCallback({ // Message is only on local if (sendingStatus === 'failed' || sendingStatus === 'pending') { logger.info('Thread | useDeleteMessageCallback: Deleted message from local:', message); - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_DELETED_BY_REQ_ID, - payload: message.reqId, - }); + onMessageDeletedByReqId(message.reqId); resolve(); } @@ -37,10 +35,7 @@ export default function useDeleteMessageCallback({ currentChannel?.deleteMessage?.(message) .then(() => { logger.info('Thread | useDeleteMessageCallback: Deleting message success!', message); - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_DELETED, - payload: { message, channel: currentChannel }, - }); + onMessageDeleted(currentChannel, message.messageId); resolve(); }) .catch((err) => { diff --git a/src/modules/Thread/context/hooks/useGetAllEmoji.ts b/src/modules/Thread/context/hooks/useGetAllEmoji.ts index 65a01ed378..e0a44a8464 100644 --- a/src/modules/Thread/context/hooks/useGetAllEmoji.ts +++ b/src/modules/Thread/context/hooks/useGetAllEmoji.ts @@ -1,31 +1,32 @@ import { useEffect } from 'react'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { Logger } from '../../../../lib/SendbirdState'; import { SdkStore } from '../../../../lib/types'; +import useThread from '../useThread'; interface DanamicPrpos { sdk: SdkStore['sdk']; } interface StaticProps { logger: Logger; - threadDispatcher: CustomUseReducerDispatcher; } export default function useGetAllEmoji({ sdk, }: DanamicPrpos, { logger, - threadDispatcher, }: StaticProps): void { + const { + actions: { + setEmojiContainer, + }, + } = useThread(); + useEffect(() => { if (sdk?.getAllEmoji) { // validation check sdk?.getAllEmoji() .then((emojiContainer) => { logger.info('Thread | useGetAllEmoji: Getting emojis succeeded.', emojiContainer); - threadDispatcher({ - type: ThreadContextActionTypes.SET_EMOJI_CONTAINER, - payload: { emojiContainer }, - }); + setEmojiContainer(emojiContainer); }) .catch((error) => { logger.info('Thread | useGetAllEmoji: Getting emojis failed.', error); diff --git a/src/modules/Thread/context/hooks/useGetChannel.ts b/src/modules/Thread/context/hooks/useGetChannel.ts index 19b0d25f03..7319ba6a81 100644 --- a/src/modules/Thread/context/hooks/useGetChannel.ts +++ b/src/modules/Thread/context/hooks/useGetChannel.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react'; import { Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; import { SendableMessageType } from '../../../../utils'; import { SdkStore } from '../../../../lib/types'; +import useThread from '../useThread'; interface DynamicProps { channelUrl: string; @@ -14,7 +14,6 @@ interface DynamicProps { interface StaticProps { sdk: SdkStore['sdk']; logger: Logger; - threadDispatcher: (props: { type: string, payload: any }) => void; } export default function useGetChannel({ @@ -24,29 +23,28 @@ export default function useGetChannel({ }: DynamicProps, { sdk, logger, - threadDispatcher, }: StaticProps): void { + const { + actions: { + getChannelStart, + getChannelSuccess, + getChannelFailure, + }, + } = useThread(); + useEffect(() => { // validation check if (sdkInit && channelUrl && sdk?.groupChannel) { - threadDispatcher({ - type: ThreadContextActionTypes.GET_CHANNEL_START, - payload: null, - }); + logger.info('Thread | useInitialize: Get channel started'); + getChannelStart(); sdk.groupChannel.getChannel?.(channelUrl) .then((groupChannel) => { logger.info('Thread | useInitialize: Get channel succeeded', groupChannel); - threadDispatcher({ - type: ThreadContextActionTypes.GET_CHANNEL_SUCCESS, - payload: { groupChannel }, - }); + getChannelSuccess(groupChannel); }) .catch((error) => { logger.info('Thread | useInitialize: Get channel failed', error); - threadDispatcher({ - type: ThreadContextActionTypes.GET_CHANNEL_FAILURE, - payload: error, - }); + getChannelFailure(); }); } }, [message, sdkInit]); diff --git a/src/modules/Thread/context/hooks/useGetParentMessage.ts b/src/modules/Thread/context/hooks/useGetParentMessage.ts index bc1b05185b..adfcd491d6 100644 --- a/src/modules/Thread/context/hooks/useGetParentMessage.ts +++ b/src/modules/Thread/context/hooks/useGetParentMessage.ts @@ -1,10 +1,10 @@ import { useEffect } from 'react'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import { Logger } from '../../../../lib/SendbirdState'; import { BaseMessage, MessageRetrievalParams } from '@sendbird/chat/message'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; import { ChannelType } from '@sendbird/chat'; import { SdkStore } from '../../../../lib/types'; +import useThread from '../useThread'; interface DynamicProps { channelUrl: string; @@ -15,7 +15,6 @@ interface DynamicProps { interface StaticProps { sdk: SdkStore['sdk']; logger: Logger; - threadDispatcher: CustomUseReducerDispatcher; } export default function useGetParentMessage({ @@ -25,15 +24,19 @@ export default function useGetParentMessage({ }: DynamicProps, { sdk, logger, - threadDispatcher, }: StaticProps): void { + const { + actions: { + getParentMessageStart, + getParentMessageSuccess, + getParentMessageFailure, + }, + } = useThread(); + useEffect(() => { // validation check if (sdkInit && sdk?.message?.getMessage && parentMessage) { - threadDispatcher({ - type: ThreadContextActionTypes.GET_PARENT_MESSAGE_START, - payload: null, - }); + getParentMessageStart(); const params: MessageRetrievalParams = { channelUrl, channelType: ChannelType.GROUP, @@ -49,17 +52,12 @@ export default function useGetParentMessage({ logger.info('Thread | useGetParentMessage: Get parent message succeeded.', parentMessage); // @ts-ignore parentMsg.ogMetaData = parentMessage?.ogMetaData || null;// ogMetaData is not included for now - threadDispatcher({ - type: ThreadContextActionTypes.GET_PARENT_MESSAGE_SUCCESS, - payload: { parentMessage: parentMsg }, - }); + // @ts-ignore + getParentMessageSuccess(parentMsg); }) .catch((error) => { logger.info('Thread | useGetParentMessage: Get parent message failed.', error); - threadDispatcher({ - type: ThreadContextActionTypes.GET_PARENT_MESSAGE_FAILURE, - payload: error, - }); + getParentMessageFailure(); }); } }, [sdkInit, parentMessage?.messageId]); diff --git a/src/modules/Thread/context/hooks/useHandleChannelEvents.ts b/src/modules/Thread/context/hooks/useHandleChannelEvents.ts index 363a7e3e2f..2cd11c4cbc 100644 --- a/src/modules/Thread/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/Thread/context/hooks/useHandleChannelEvents.ts @@ -1,11 +1,11 @@ import { GroupChannel, GroupChannelHandler } from '@sendbird/chat/groupChannel'; import { useEffect } from 'react'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import { Logger } from '../../../../lib/SendbirdState'; import uuidv4 from '../../../../utils/uuid'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; import { SdkStore } from '../../../../lib/types'; import compareIds from '../../../../utils/compareIds'; -import * as messageActions from '../../../Channel/context/dux/actionTypes'; +import useThread from '../useThread'; +import { SendableMessageType } from '../../../../utils'; interface DynamicProps { sdk: SdkStore['sdk']; @@ -13,7 +13,6 @@ interface DynamicProps { } interface StaticProps { logger: Logger; - threadDispatcher: CustomUseReducerDispatcher; } export default function useHandleChannelEvents({ @@ -21,8 +20,25 @@ export default function useHandleChannelEvents({ currentChannel, }: DynamicProps, { logger, - threadDispatcher, }: StaticProps): void { + const { + actions: { + onMessageReceived, + onMessageUpdated, + onMessageDeleted, + onReactionUpdated, + onUserMuted, + onUserUnmuted, + onUserBanned, + onUserUnbanned, + onUserLeft, + onChannelFrozen, + onChannelUnfrozen, + onOperatorUpdated, + onTypingStatusUpdated, + }, + } = useThread(); + useEffect(() => { const handlerId = uuidv4(); // validation check @@ -33,101 +49,59 @@ export default function useHandleChannelEvents({ // message status change onMessageReceived(channel, message) { logger.info('Thread | useHandleChannelEvents: onMessageReceived', { channel, message }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_RECEIVED, - payload: { channel, message }, - }); + onMessageReceived(channel as GroupChannel, message as SendableMessageType); }, onMessageUpdated(channel, message) { logger.info('Thread | useHandleChannelEvents: onMessageUpdated', { channel, message }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_UPDATED, - payload: { channel, message }, - }); + onMessageUpdated(channel as GroupChannel, message as SendableMessageType); }, onMessageDeleted(channel, messageId) { logger.info('Thread | useHandleChannelEvents: onMessageDeleted', { channel, messageId }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_DELETED, - payload: { channel, messageId }, - }); + onMessageDeleted(channel as GroupChannel, messageId); }, onReactionUpdated(channel, reactionEvent) { logger.info('Thread | useHandleChannelEvents: onReactionUpdated', { channel, reactionEvent }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_REACTION_UPDATED, - payload: { channel, reactionEvent }, - }); + onReactionUpdated(reactionEvent); }, // user status change onUserMuted(channel, user) { logger.info('Thread | useHandleChannelEvents: onUserMuted', { channel, user }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_USER_MUTED, - payload: { channel, user }, - }); + onUserMuted(channel as GroupChannel, user); }, onUserUnmuted(channel, user) { logger.info('Thread | useHandleChannelEvents: onUserUnmuted', { channel, user }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_USER_UNMUTED, - payload: { channel, user }, - }); + onUserUnmuted(channel as GroupChannel, user); }, onUserBanned(channel, user) { logger.info('Thread | useHandleChannelEvents: onUserBanned', { channel, user }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_USER_BANNED, - payload: { channel, user }, - }); + onUserBanned(); }, onUserUnbanned(channel, user) { logger.info('Thread | useHandleChannelEvents: onUserUnbanned', { channel, user }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_USER_UNBANNED, - payload: { channel, user }, - }); + onUserUnbanned(); }, onUserLeft(channel, user) { logger.info('Thread | useHandleChannelEvents: onUserLeft', { channel, user }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_USER_LEFT, - payload: { channel, user }, - }); + onUserLeft(); }, // channel status change onChannelFrozen(channel) { logger.info('Thread | useHandleChannelEvents: onChannelFrozen', { channel }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_CHANNEL_FROZEN, - payload: { channel }, - }); + onChannelFrozen(); }, onChannelUnfrozen(channel) { logger.info('Thread | useHandleChannelEvents: onChannelUnfrozen', { channel }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_CHANNEL_UNFROZEN, - payload: { channel }, - }); + onChannelUnfrozen(); }, onOperatorUpdated(channel, users) { logger.info('Thread | useHandleChannelEvents: onOperatorUpdated', { channel, users }); - threadDispatcher({ - type: ThreadContextActionTypes.ON_OPERATOR_UPDATED, - payload: { channel, users }, - }); + onOperatorUpdated(channel as GroupChannel); }, onTypingStatusUpdated: (channel) => { if (compareIds(channel?.url, currentChannel.url)) { logger.info('Channel | onTypingStatusUpdated', { channel }); const typingMembers = channel.getTypingUsers(); - threadDispatcher({ - type: messageActions.ON_TYPING_STATUS_UPDATED, - payload: { - channel, - typingMembers, - }, - }); + onTypingStatusUpdated(channel as GroupChannel, typingMembers); } }, }; diff --git a/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts b/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts index 8498c03871..022bac16bb 100644 --- a/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts +++ b/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts @@ -1,12 +1,11 @@ import { useEffect } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import { Logger } from '../../../../lib/SendbirdState'; import topics, { PUBSUB_TOPICS, SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { scrollIntoLast } from '../utils'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; import { SendableMessageType } from '../../../../utils'; -import * as channelActions from '../../../Channel/context/dux/actionTypes'; import { shouldPubSubPublishToThread } from '../../../internalInterfaces'; +import useThread from '../useThread'; interface DynamicProps { sdkInit: boolean; @@ -16,7 +15,6 @@ interface DynamicProps { interface StaticProps { logger: Logger; pubSub: SBUGlobalPubSub; - threadDispatcher: CustomUseReducerDispatcher; } export default function useHandleThreadPubsubEvents({ @@ -25,8 +23,18 @@ export default function useHandleThreadPubsubEvents({ parentMessage, }: DynamicProps, { pubSub, - threadDispatcher, }: StaticProps): void { + const { + actions: { + sendMessageStart, + sendMessageSuccess, + sendMessageFailure, + onFileInfoUpdated, + onMessageUpdated, + onMessageDeleted, + }, + } = useThread(); + useEffect(() => { const subscriber = new Map(); if (pubSub?.subscribe) { @@ -42,22 +50,14 @@ export default function useHandleThreadPubsubEvents({ url: URL.createObjectURL(fileInfo.file as File), })) ?? []; } - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_START, - payload: { - message: pendingMessage, - }, - }); + sendMessageStart(message); } scrollIntoLast?.(); })); subscriber.set(PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED, pubSub.subscribe(PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED, (props) => { const { response, publishingModules } = props; if (currentChannel?.url === response.channelUrl && shouldPubSubPublishToThread(publishingModules)) { - threadDispatcher({ - type: channelActions.ON_FILE_INFO_UPLOADED, - payload: response, - }); + onFileInfoUpdated(response); } })); subscriber.set(topics.SEND_USER_MESSAGE, pubSub.subscribe(topics.SEND_USER_MESSAGE, (props) => { @@ -68,29 +68,20 @@ export default function useHandleThreadPubsubEvents({ if (currentChannel?.url === channel?.url && message?.parentMessageId === parentMessage?.messageId ) { - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, - payload: { message }, - }); + sendMessageSuccess(message); } scrollIntoLast?.(); })); subscriber.set(topics.SEND_MESSAGE_FAILED, pubSub.subscribe(topics.SEND_MESSAGE_FAILED, (props) => { const { channel, message, publishingModules } = props; if (currentChannel?.url === channel?.url && message?.parentMessageId === parentMessage?.messageId && shouldPubSubPublishToThread(publishingModules)) { - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message }, - }); + sendMessageFailure(message); } })); subscriber.set(topics.SEND_FILE_MESSAGE, pubSub.subscribe(topics.SEND_FILE_MESSAGE, (props) => { const { channel, message, publishingModules } = props; if (currentChannel?.url === channel?.url && shouldPubSubPublishToThread(publishingModules)) { - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, - payload: { message }, - }); + sendMessageSuccess(message); } scrollIntoLast?.(); })); @@ -100,20 +91,12 @@ export default function useHandleThreadPubsubEvents({ message, } = props as { channel: GroupChannel, message: SendableMessageType }; if (currentChannel?.url === channel?.url) { - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_UPDATED, - payload: { channel, message }, - }); + onMessageUpdated(channel, message); } })); subscriber.set(topics.DELETE_MESSAGE, pubSub.subscribe(topics.DELETE_MESSAGE, (props) => { const { channel, messageId } = props as { channel: GroupChannel, messageId: number }; - if (currentChannel?.url === channel?.url) { - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_DELETED, - payload: { messageId }, - }); - } + onMessageDeleted(channel, messageId); })); } return () => { diff --git a/src/modules/Thread/context/hooks/useResendMessageCallback.ts b/src/modules/Thread/context/hooks/useResendMessageCallback.ts index aab62ec10b..2e9d5e20cd 100644 --- a/src/modules/Thread/context/hooks/useResendMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useResendMessageCallback.ts @@ -8,27 +8,30 @@ import { UserMessage, } from '@sendbird/chat/message'; import { useCallback } from 'react'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { Logger } from '../../../../lib/SendbirdState'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { SendableMessageType } from '../../../../utils'; import { PublishingModuleType } from '../../../internalInterfaces'; interface DynamicProps { currentChannel: GroupChannel | null; + resendMessageStart: (message: SendableMessageType) => void; + sendMessageSuccess: (message: SendableMessageType) => void; + sendMessageFailure: (message: SendableMessageType) => void; } interface StaticProps { logger: Logger; pubSub: SBUGlobalPubSub; - threadDispatcher: CustomUseReducerDispatcher; } export default function useResendMessageCallback({ currentChannel, + resendMessageStart, + sendMessageSuccess, + sendMessageFailure, }: DynamicProps, { logger, pubSub, - threadDispatcher, }: StaticProps): (failedMessage: SendableMessageType) => void { return useCallback((failedMessage: SendableMessageType) => { if ((failedMessage as SendableMessageType)?.isResendable) { @@ -38,17 +41,11 @@ export default function useResendMessageCallback({ currentChannel?.resendMessage(failedMessage as UserMessage) .onPending((message) => { logger.info('Thread | useResendMessageCallback: Resending user message started.', message); - threadDispatcher({ - type: ThreadContextActionTypes.RESEND_MESSAGE_START, - payload: { message }, - }); + resendMessageStart(message); }) .onSucceeded((message) => { logger.info('Thread | useResendMessageCallback: Resending user message succeeded.', message); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, - payload: { message }, - }); + sendMessageSuccess(message); pubSub.publish(topics.SEND_USER_MESSAGE, { channel: currentChannel, message: message, @@ -58,35 +55,23 @@ export default function useResendMessageCallback({ .onFailed((error) => { logger.warning('Thread | useResendMessageCallback: Resending user message failed.', error); failedMessage.sendingStatus = SendingStatus.FAILED; - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message: failedMessage }, - }); + sendMessageFailure(failedMessage); }); } catch (err) { logger.warning('Thread | useResendMessageCallback: Resending user message failed.', err); failedMessage.sendingStatus = SendingStatus.FAILED; - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message: failedMessage }, - }); + sendMessageFailure(failedMessage); } } else if (failedMessage?.isFileMessage?.()) { try { currentChannel?.resendMessage?.(failedMessage as FileMessage) .onPending((message) => { logger.info('Thread | useResendMessageCallback: Resending file message started.', message); - threadDispatcher({ - type: ThreadContextActionTypes.RESEND_MESSAGE_START, - payload: { message }, - }); + resendMessageStart(message); }) .onSucceeded((message) => { logger.info('Thread | useResendMessageCallback: Resending file message succeeded.', message); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, - payload: { message }, - }); + sendMessageSuccess(message); pubSub.publish(topics.SEND_FILE_MESSAGE, { channel: currentChannel, message: failedMessage, @@ -96,28 +81,19 @@ export default function useResendMessageCallback({ .onFailed((error) => { logger.warning('Thread | useResendMessageCallback: Resending file message failed.', error); failedMessage.sendingStatus = SendingStatus.FAILED; - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message: failedMessage }, - }); + sendMessageFailure(failedMessage); }); } catch (err) { logger.warning('Thread | useResendMessageCallback: Resending file message failed.', err); failedMessage.sendingStatus = SendingStatus.FAILED; - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message: failedMessage }, - }); + sendMessageFailure(failedMessage); } } else if (failedMessage?.isMultipleFilesMessage?.()) { try { currentChannel?.resendMessage?.(failedMessage as MultipleFilesMessage) .onPending((message) => { logger.info('Thread | useResendMessageCallback: Resending multiple files message started.', message); - threadDispatcher({ - type: ThreadContextActionTypes.RESEND_MESSAGE_START, - payload: { message }, - }); + resendMessageStart(message); }) .onFileUploaded((requestId, index, uploadableFileInfo: UploadableFileInfo, error) => { logger.info('Thread | useResendMessageCallback: onFileUploaded during resending multiple files message.', { @@ -139,10 +115,7 @@ export default function useResendMessageCallback({ }) .onSucceeded((message: MultipleFilesMessage) => { logger.info('Thread | useResendMessageCallback: Resending MFM succeeded.', message); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_SUCESS, - payload: { message }, - }); + sendMessageSuccess(message); pubSub.publish(topics.SEND_FILE_MESSAGE, { channel: currentChannel, message, @@ -151,25 +124,16 @@ export default function useResendMessageCallback({ }) .onFailed((error, message) => { logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', error); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message }, - }); + sendMessageFailure(message); }); } catch (err) { logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', err); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message: failedMessage }, - }); + sendMessageFailure(failedMessage); } } else { logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); failedMessage.sendingStatus = SendingStatus.FAILED; - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message: failedMessage }, - }); + sendMessageFailure(failedMessage); } } }, [currentChannel]); diff --git a/src/modules/Thread/context/hooks/useSendFileMessage.ts b/src/modules/Thread/context/hooks/useSendFileMessage.ts index 1c9d07924a..cf7babd54a 100644 --- a/src/modules/Thread/context/hooks/useSendFileMessage.ts +++ b/src/modules/Thread/context/hooks/useSendFileMessage.ts @@ -1,9 +1,8 @@ import { useCallback } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { FileMessage, FileMessageCreateParams } from '@sendbird/chat/message'; +import { FileMessage, FileMessageCreateParams, SendingStatus } from '@sendbird/chat/message'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { Logger } from '../../../../lib/SendbirdState'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { scrollIntoLast } from '../utils'; import { SendableMessageType } from '../../../../utils'; @@ -13,11 +12,12 @@ import { SCROLL_BOTTOM_DELAY_FOR_SEND } from '../../../../utils/consts'; interface DynamicProps { currentChannel: GroupChannel | null; onBeforeSendFileMessage?: (file: File, quotedMessage?: SendableMessageType) => FileMessageCreateParams; + sendMessageStart: (message: SendableMessageType) => void; + sendMessageFailure: (message: SendableMessageType) => void; } interface StaticProps { logger: Logger; pubSub: SBUGlobalPubSub; - threadDispatcher: CustomUseReducerDispatcher; } interface LocalFileMessage extends FileMessage { @@ -30,10 +30,11 @@ export type SendFileMessageFunctionType = (file: File, quoteMessage?: SendableMe export default function useSendFileMessageCallback({ currentChannel, onBeforeSendFileMessage, + sendMessageStart, + sendMessageFailure, }: DynamicProps, { logger, pubSub, - threadDispatcher, }: StaticProps): SendFileMessageFunctionType { return useCallback((file, quoteMessage): Promise => { return new Promise((resolve, reject) => { @@ -51,23 +52,16 @@ export default function useSendFileMessageCallback({ currentChannel?.sendFileMessage(params) .onPending((pendingMessage) => { - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_START, - payload: { - /* pubSub is used instead of messagesDispatcher - to avoid redundantly calling `messageActionTypes.SEND_MESSAGE_START` */ - // TODO: remove data pollution - message: { - ...pendingMessage, - url: URL.createObjectURL(file), - // pending thumbnail message seems to be failed - requestState: 'pending', - isUserMessage: pendingMessage.isUserMessage, - isFileMessage: pendingMessage.isFileMessage, - isAdminMessage: pendingMessage.isAdminMessage, - isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, - }, - }, + // @ts-ignore + sendMessageStart({ + ...pendingMessage, + url: URL.createObjectURL(file), + // pending thumbnail message seems to be failed + sendingStatus: SendingStatus.PENDING, + isUserMessage: pendingMessage.isUserMessage, + isFileMessage: pendingMessage.isFileMessage, + isAdminMessage: pendingMessage.isAdminMessage, + isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, }); setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND); }) @@ -75,10 +69,7 @@ export default function useSendFileMessageCallback({ (message as LocalFileMessage).localUrl = URL.createObjectURL(file); (message as LocalFileMessage).file = file; logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error }); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message, error }, - }); + sendMessageFailure(message as SendableMessageType); reject(error); }) .onSucceeded((message) => { diff --git a/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts b/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts index ae14fa1745..5ef9ff2168 100644 --- a/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts @@ -3,8 +3,7 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; import { UserMessage, UserMessageCreateParams } from '@sendbird/chat/message'; import { User } from '@sendbird/chat'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { Logger } from '../../../../lib/SendbirdState'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { SendableMessageType } from '../../../../utils'; import { PublishingModuleType } from '../../../internalInterfaces'; @@ -14,11 +13,12 @@ interface DynamicProps { isMentionEnabled: boolean; currentChannel: GroupChannel | null; onBeforeSendUserMessage?: OnBeforeSendUserMessageType; + sendMessageStart: (message: SendableMessageType) => void; + sendMessageFailure: (message: SendableMessageType) => void; } interface StaticProps { logger: Logger; pubSub: SBUGlobalPubSub; - threadDispatcher: CustomUseReducerDispatcher; } export type SendMessageParams = { @@ -32,10 +32,11 @@ export default function useSendUserMessageCallback({ isMentionEnabled, currentChannel, onBeforeSendUserMessage, + sendMessageStart, + sendMessageFailure, }: DynamicProps, { logger, pubSub, - threadDispatcher, }: StaticProps): (props: SendMessageParams) => void { const sendMessage = useCallback((props: SendMessageParams) => { const { @@ -44,6 +45,7 @@ export default function useSendUserMessageCallback({ mentionTemplate, mentionedUsers, } = props; + const createDefaultParams = () => { const params = {} as UserMessageCreateParams; params.message = message; @@ -67,17 +69,11 @@ export default function useSendUserMessageCallback({ if (currentChannel?.sendUserMessage) { currentChannel?.sendUserMessage(params) .onPending((pendingMessage) => { - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_START, - payload: { message: pendingMessage }, - }); + sendMessageStart(pendingMessage as SendableMessageType); }) .onFailed((error, message) => { logger.info('Thread | useSendUserMessageCallback: Sending user message failed.', { message, error }); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { error, message }, - }); + sendMessageFailure(message as SendableMessageType); }) .onSucceeded((message) => { logger.info('Thread | useSendUserMessageCallback: Sending user message succeeded.', message); diff --git a/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts b/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts index 987d73e5e7..c4632826ff 100644 --- a/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts @@ -1,8 +1,7 @@ import { useCallback } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { FileMessage, FileMessageCreateParams, MessageMetaArray } from '@sendbird/chat/message'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { FileMessage, FileMessageCreateParams, MessageMetaArray, SendingStatus } from '@sendbird/chat/message'; +import { Logger } from '../../../../lib/SendbirdState'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { scrollIntoLast } from '../utils'; import { @@ -19,11 +18,12 @@ import { PublishingModuleType } from '../../../internalInterfaces'; interface DynamicParams { currentChannel: GroupChannel | null; onBeforeSendVoiceMessage?: (file: File, quoteMessage?: SendableMessageType) => FileMessageCreateParams; + sendMessageStart: (message: SendableMessageType) => void; + sendMessageFailure: (message: SendableMessageType) => void; } interface StaticParams { logger: Logger; pubSub: SBUGlobalPubSub; - threadDispatcher: CustomUseReducerDispatcher; } type FuncType = (file: File, duration: number, quoteMessage: SendableMessageType) => void; interface LocalFileMessage extends FileMessage { @@ -34,11 +34,12 @@ interface LocalFileMessage extends FileMessage { export const useSendVoiceMessageCallback = ({ currentChannel, onBeforeSendVoiceMessage, + sendMessageStart, + sendMessageFailure, }: DynamicParams, { logger, pubSub, - threadDispatcher, }: StaticParams): FuncType => { const sendMessage = useCallback((file: File, duration: number, quoteMessage: SendableMessageType) => { const messageParams: FileMessageCreateParams = ( @@ -68,23 +69,19 @@ export const useSendVoiceMessageCallback = ({ logger.info('Thread | useSendVoiceMessageCallback: Start sending voice message', messageParams); currentChannel?.sendFileMessage(messageParams) .onPending((pendingMessage) => { - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_START, - payload: { - /* pubSub is used instead of messagesDispatcher + // @ts-ignore + sendMessageStart({ + /* pubSub is used instead of messagesDispatcher to avoid redundantly calling `messageActionTypes.SEND_MESSAGE_START` */ - // TODO: remove data pollution - message: { - ...pendingMessage, - url: URL.createObjectURL(file), - // pending thumbnail message seems to be failed - requestState: 'pending', - isUserMessage: pendingMessage.isUserMessage, - isFileMessage: pendingMessage.isFileMessage, - isAdminMessage: pendingMessage.isAdminMessage, - isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, - }, - }, + // TODO: remove data pollution + ...pendingMessage, + url: URL.createObjectURL(file), + // pending thumbnail message seems to be failed + sendingStatus: SendingStatus.PENDING, + isUserMessage: pendingMessage.isUserMessage, + isFileMessage: pendingMessage.isFileMessage, + isAdminMessage: pendingMessage.isAdminMessage, + isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, }); setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND); }) @@ -92,10 +89,7 @@ export const useSendVoiceMessageCallback = ({ (message as LocalFileMessage).localUrl = URL.createObjectURL(file); (message as LocalFileMessage).file = file; logger.info('Thread | useSendVoiceMessageCallback: Sending voice message failed.', { message, error }); - threadDispatcher({ - type: ThreadContextActionTypes.SEND_MESSAGE_FAILURE, - payload: { message, error }, - }); + sendMessageFailure(message as SendableMessageType); }) .onSucceeded((message) => { logger.info('Thread | useSendVoiceMessageCallback: Sending voice message succeeded.', message); diff --git a/src/modules/Thread/context/hooks/useSetCurrentUserId.ts b/src/modules/Thread/context/hooks/useSetCurrentUserId.ts new file mode 100644 index 0000000000..fe3e02db85 --- /dev/null +++ b/src/modules/Thread/context/hooks/useSetCurrentUserId.ts @@ -0,0 +1,23 @@ +import useThread from '../useThread'; +import { useEffect } from 'react'; +import type { User } from '@sendbird/chat'; + +interface DynamicParams { + user: User | null; +} + +function useSetCurrentUserId( + { user }: DynamicParams, +) { + const { + actions: { + setCurrentUserId, + }, + } = useThread(); + + useEffect(() => { + setCurrentUserId(user?.userId); + }, [user?.userId]); +} + +export default useSetCurrentUserId; diff --git a/src/modules/Thread/context/hooks/useThreadFetchers.ts b/src/modules/Thread/context/hooks/useThreadFetchers.ts index 7947d276a1..fa9e4d642f 100644 --- a/src/modules/Thread/context/hooks/useThreadFetchers.ts +++ b/src/modules/Thread/context/hooks/useThreadFetchers.ts @@ -1,8 +1,6 @@ -import { ThreadContextActionTypes } from '../dux/actionTypes'; import { NEXT_THREADS_FETCH_SIZE, PREV_THREADS_FETCH_SIZE } from '../../consts'; import { BaseMessage, ThreadedMessageListParams } from '@sendbird/chat/message'; -import { SendableMessageType } from '../../../../utils'; -import { CustomUseReducerDispatcher } from '../../../../lib/SendbirdState'; +import { CoreMessageType, SendableMessageType } from '../../../../utils'; import { LoggerInterface } from '../../../../lib/Logger'; import { useCallback } from 'react'; import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; @@ -12,11 +10,19 @@ type Params = { anchorMessage?: SendableMessageType; parentMessage: SendableMessageType | null; isReactionEnabled?: boolean; - threadDispatcher: CustomUseReducerDispatcher; logger: LoggerInterface; threadListState: ThreadListStateTypes; oldestMessageTimeStamp: number; latestMessageTimeStamp: number; + initializeThreadListStart: () => void, + initializeThreadListSuccess: (parentMessage: BaseMessage, anchorMessage: SendableMessageType, threadedMessages: BaseMessage[]) => void, + initializeThreadListFailure: () => void, + getPrevMessagesStart: () => void, + getPrevMessagesSuccess: (threadedMessages: CoreMessageType[]) => void, + getPrevMessagesFailure: () => void, + getNextMessagesStart: () => void, + getNextMessagesSuccess: (threadedMessages: CoreMessageType[]) => void, + getNextMessagesFailure: () => void, }; function getThreadMessageListParams(params?: Partial): ThreadedMessageListParams { @@ -32,11 +38,19 @@ export const useThreadFetchers = ({ isReactionEnabled, anchorMessage, parentMessage: staleParentMessage, - threadDispatcher, logger, oldestMessageTimeStamp, latestMessageTimeStamp, threadListState, + initializeThreadListStart, + initializeThreadListSuccess, + initializeThreadListFailure, + getPrevMessagesStart, + getPrevMessagesSuccess, + getPrevMessagesFailure, + getNextMessagesStart, + getNextMessagesSuccess, + getNextMessagesFailure, }: Params) => { const { stores } = useSendbirdStateContext(); const timestamp = anchorMessage?.createdAt || 0; @@ -45,10 +59,7 @@ export const useThreadFetchers = ({ async (callback?: (messages: BaseMessage[]) => void) => { if (!stores.sdkStore.initialized || !staleParentMessage) return; - threadDispatcher({ - type: ThreadContextActionTypes.INITIALIZE_THREAD_LIST_START, - payload: null, - }); + initializeThreadListStart(); try { const params = getThreadMessageListParams({ includeReactions: isReactionEnabled }); @@ -56,17 +67,11 @@ export const useThreadFetchers = ({ const { threadedMessages, parentMessage } = await staleParentMessage.getThreadedMessagesByTimestamp(timestamp, params); logger.info('Thread | useGetThreadList: Initialize thread list succeeded.', { staleParentMessage, threadedMessages }); - threadDispatcher({ - type: ThreadContextActionTypes.INITIALIZE_THREAD_LIST_SUCCESS, - payload: { parentMessage, anchorMessage, threadedMessages }, - }); + initializeThreadListSuccess(parentMessage, anchorMessage, threadedMessages); setTimeout(() => callback?.(threadedMessages)); } catch (error) { logger.info('Thread | useGetThreadList: Initialize thread list failed.', error); - threadDispatcher({ - type: ThreadContextActionTypes.INITIALIZE_THREAD_LIST_FAILURE, - payload: error, - }); + initializeThreadListFailure(); } }, [stores.sdkStore.initialized, staleParentMessage, anchorMessage, isReactionEnabled], @@ -76,10 +81,7 @@ export const useThreadFetchers = ({ async (callback?: (messages: BaseMessage[]) => void) => { if (threadListState !== ThreadListStateTypes.INITIALIZED || oldestMessageTimeStamp === 0 || !staleParentMessage) return; - threadDispatcher({ - type: ThreadContextActionTypes.GET_PREV_MESSAGES_START, - payload: null, - }); + getPrevMessagesStart(); try { const params = getThreadMessageListParams({ nextResultSize: 0, includeReactions: isReactionEnabled }); @@ -87,17 +89,11 @@ export const useThreadFetchers = ({ const { threadedMessages, parentMessage } = await staleParentMessage.getThreadedMessagesByTimestamp(oldestMessageTimeStamp, params); logger.info('Thread | useGetPrevThreadsCallback: Fetch prev threads succeeded.', { parentMessage, threadedMessages }); - threadDispatcher({ - type: ThreadContextActionTypes.GET_PREV_MESSAGES_SUCESS, - payload: { parentMessage, threadedMessages }, - }); + getPrevMessagesSuccess(threadedMessages as CoreMessageType[]); setTimeout(() => callback?.(threadedMessages)); } catch (error) { logger.info('Thread | useGetPrevThreadsCallback: Fetch prev threads failed.', error); - threadDispatcher({ - type: ThreadContextActionTypes.GET_PREV_MESSAGES_FAILURE, - payload: error, - }); + getPrevMessagesFailure(); } }, [threadListState, oldestMessageTimeStamp, isReactionEnabled, staleParentMessage], @@ -107,35 +103,26 @@ export const useThreadFetchers = ({ async (callback?: (messages: BaseMessage[]) => void) => { if (threadListState !== ThreadListStateTypes.INITIALIZED || latestMessageTimeStamp === 0 || !staleParentMessage) return; - threadDispatcher({ - type: ThreadContextActionTypes.GET_NEXT_MESSAGES_START, - payload: null, - }); + getNextMessagesStart(); try { const params = getThreadMessageListParams({ prevResultSize: 0, includeReactions: isReactionEnabled }); const { threadedMessages, parentMessage } = await staleParentMessage.getThreadedMessagesByTimestamp(latestMessageTimeStamp, params); logger.info('Thread | useGetNextThreadsCallback: Fetch next threads succeeded.', { parentMessage, threadedMessages }); - threadDispatcher({ - type: ThreadContextActionTypes.GET_NEXT_MESSAGES_SUCESS, - payload: { parentMessage, threadedMessages }, - }); + getNextMessagesSuccess(threadedMessages as CoreMessageType[]); setTimeout(() => callback?.(threadedMessages)); } catch (error) { logger.info('Thread | useGetNextThreadsCallback: Fetch next threads failed.', error); - threadDispatcher({ - type: ThreadContextActionTypes.GET_NEXT_MESSAGES_FAILURE, - payload: error, - }); + getNextMessagesFailure(); } }, [threadListState, latestMessageTimeStamp, isReactionEnabled, staleParentMessage], ); return { - initialize, - loadPrevious, - loadNext, + initializeThreadFetcher: initialize, + fetchPrevThreads: loadPrevious, + fetchNextThreads: loadNext, }; }; diff --git a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts index db58e4a2b3..fb5a981e4b 100644 --- a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts @@ -3,20 +3,20 @@ import { User } from '@sendbird/chat'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { UserMessage, UserMessageUpdateParams } from '@sendbird/chat/message'; -import { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; -import { ThreadContextActionTypes } from '../dux/actionTypes'; +import { Logger } from '../../../../lib/SendbirdState'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { PublishingModuleType } from '../../../internalInterfaces'; +import { SendableMessageType } from '../../../../utils'; interface DynamicProps { currentChannel: GroupChannel | null; isMentionEnabled?: boolean; + onMessageUpdated: (currentChannel: GroupChannel, message: SendableMessageType) => void; } interface StaticProps { logger: Logger; pubSub: SBUGlobalPubSub; - threadDispatcher: CustomUseReducerDispatcher; } type CallbackParams = { @@ -29,10 +29,10 @@ type CallbackParams = { export default function useUpdateMessageCallback({ currentChannel, isMentionEnabled, + onMessageUpdated, }: DynamicProps, { logger, pubSub, - threadDispatcher, }: StaticProps) { // TODO: add type return useCallback((props: CallbackParams) => { @@ -42,6 +42,7 @@ export default function useUpdateMessageCallback({ mentionedUsers, mentionTemplate, } = props; + const createParamsDefault = () => { const params = {} as UserMessageUpdateParams; params.message = message; @@ -62,13 +63,7 @@ export default function useUpdateMessageCallback({ currentChannel?.updateUserMessage?.(messageId, params) .then((message: UserMessage) => { logger.info('Thread | useUpdateMessageCallback: Message update succeeded.', message); - threadDispatcher({ - type: ThreadContextActionTypes.ON_MESSAGE_UPDATED, - payload: { - channel: currentChannel, - message: message, - }, - }); + onMessageUpdated(currentChannel, message); pubSub.publish( topics.UPDATE_USER_MESSAGE, { diff --git a/src/modules/Thread/context/useThread.ts b/src/modules/Thread/context/useThread.ts new file mode 100644 index 0000000000..58e3f352cb --- /dev/null +++ b/src/modules/Thread/context/useThread.ts @@ -0,0 +1,546 @@ +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { useContext, useMemo } from 'react'; +import { ThreadContext, ThreadState } from './ThreadProvider'; +import { ChannelStateTypes, FileUploadInfoParams, ParentMessageStateTypes, ThreadListStateTypes } from '../types'; +import { GroupChannel, Member } from '@sendbird/chat/groupChannel'; +import { CoreMessageType, SendableMessageType } from '../../../utils'; +import { EmojiContainer, User } from '@sendbird/chat'; +import { compareIds } from './utils'; +import { + BaseMessage, + MultipleFilesMessage, + ReactionEvent, + UserMessage, +} from '@sendbird/chat/message'; +import { NEXT_THREADS_FETCH_SIZE, PREV_THREADS_FETCH_SIZE } from '../consts'; +import useToggleReactionCallback from './hooks/useToggleReactionsCallback'; +import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; +import useSendUserMessageCallback from './hooks/useSendUserMessageCallback'; +import { PublishingModuleType } from '../../../lib/pubSub/topics'; + +import useSendFileMessageCallback from './hooks/useSendFileMessage'; +import useSendVoiceMessageCallback from './hooks/useSendVoiceMessageCallback'; +import { useSendMultipleFilesMessage } from '../../Channel/context/hooks/useSendMultipleFilesMessage'; +import useResendMessageCallback from './hooks/useResendMessageCallback'; +import useUpdateMessageCallback from './hooks/useUpdateMessageCallback'; +import useDeleteMessageCallback from './hooks/useDeleteMessageCallback'; +import { useThreadFetchers } from './hooks/useThreadFetchers'; + +function hasReqId( + message: T, +): message is T & { reqId: string } { + return 'reqId' in message; +} + +const useThread = () => { + const store = useContext(ThreadContext); + if (!store) throw new Error('useCreateChannel must be used within a CreateChannelProvider'); + + // SendbirdStateContext config + const { stores, config } = useSendbirdStateContext(); + const { logger, pubSub } = config; + const isMentionEnabled = config.groupChannel.enableMention; + const isReactionEnabled = config.groupChannel.enableReactions; + + const state: ThreadState = useSyncExternalStore(store.subscribe, store.getState); + const { + message, + parentMessage, + currentChannel, + threadListState, + allThreadMessages, + onBeforeSendUserMessage, + onBeforeSendFileMessage, + onBeforeSendVoiceMessage, + onBeforeSendMultipleFilesMessage, + } = state; + + const sendMessageStatusActions = { + sendMessageStart: (message: SendableMessageType) => store.setState(state => { + return { + ...state, + localThreadMessages: [ + ...state.localThreadMessages, + message, + ], + }; + }), + + sendMessageSuccess: (message: SendableMessageType) => store.setState(state => { + return { + ...state, + allThreadMessages: [ + ...state.allThreadMessages.filter((m) => ( + !compareIds((m as UserMessage)?.reqId, message?.reqId) + )), + message, + ], + localThreadMessages: state.localThreadMessages.filter((m) => ( + !compareIds((m as UserMessage)?.reqId, message?.reqId) + )), + }; + }), + + sendMessageFailure: (message: SendableMessageType) => store.setState(state => { + return { + ...state, + localThreadMessages: state.localThreadMessages.map((m) => ( + compareIds((m as UserMessage)?.reqId, message?.reqId) + ? message + : m + )), + }; + }), + + resendMessageStart: (message: SendableMessageType) => store.setState(state => { + return { + ...state, + localThreadMessages: state.localThreadMessages.map((m) => ( + compareIds((m as UserMessage)?.reqId, message?.reqId) + ? message + : m + )), + }; + }), + }; + + const toggleReaction = useToggleReactionCallback({ currentChannel }, { logger }); + + const sendMessageActions = { + sendMessage: useSendUserMessageCallback({ + isMentionEnabled, + currentChannel, + onBeforeSendUserMessage, + sendMessageStart: sendMessageStatusActions.sendMessageStart, + sendMessageFailure: sendMessageStatusActions.sendMessageFailure, + }, { + logger, + pubSub, + }), + + sendFileMessage: useSendFileMessageCallback({ + currentChannel, + onBeforeSendFileMessage, + sendMessageStart: sendMessageStatusActions.sendMessageStart, + sendMessageFailure: sendMessageStatusActions.sendMessageFailure, + }, { + logger, + pubSub, + }), + + sendVoiceMessage: useSendVoiceMessageCallback({ + currentChannel, + onBeforeSendVoiceMessage, + sendMessageStart: sendMessageStatusActions.sendMessageStart, + sendMessageFailure: sendMessageStatusActions.sendMessageFailure, + }, { + logger, + pubSub, + }), + + sendMultipleFilesMessage: useSendMultipleFilesMessage({ + currentChannel, + onBeforeSendMultipleFilesMessage, + publishingModules: [PublishingModuleType.THREAD], + }, { + logger, + pubSub, + })[0], + + resendMessage: useResendMessageCallback({ + resendMessageStart: sendMessageStatusActions.resendMessageStart, + sendMessageSuccess: sendMessageStatusActions.sendMessageSuccess, + sendMessageFailure: sendMessageStatusActions.sendMessageFailure, + currentChannel, + }, { logger, pubSub }), + }; + + const messageModifiedActions = { + onMessageUpdated: (channel: GroupChannel, message: SendableMessageType) => store.setState(state => { + if (state.currentChannel?.url !== channel?.url) { + return state; + } + + return { + ...state, + parentMessage: state.parentMessage?.messageId === message?.messageId + ? message + : state.parentMessage, + allThreadMessages: state.allThreadMessages?.map((msg) => ( + (msg?.messageId === message?.messageId) ? message : msg + )), + }; + }), + + onMessageDeleted: (channel: GroupChannel, messageId: number) => store.setState(state => { + if (state.currentChannel?.url !== channel?.url) { + return state; + } + if (state?.parentMessage?.messageId === messageId) { + return { + ...state, + parentMessage: null, + parentMessageState: ParentMessageStateTypes.NIL, + allThreadMessages: [], + }; + } + return { + ...state, + allThreadMessages: state.allThreadMessages?.filter((msg) => ( + msg?.messageId !== messageId + )), + localThreadMessages: state.localThreadMessages?.filter((msg) => ( + msg?.messageId !== messageId + )), + }; + }), + + onMessageDeletedByReqId: (reqId: string | number) => store.setState(state => { + return { + ...state, + localThreadMessages: state.localThreadMessages.filter((m) => ( + !compareIds((m as SendableMessageType).reqId, reqId) + )), + }; + }), + }; + + const modifyMessageActions = { + updateMessage: useUpdateMessageCallback({ + currentChannel, + isMentionEnabled, + onMessageUpdated: messageModifiedActions.onMessageUpdated, + }, { logger, pubSub }), + + deleteMessage: useDeleteMessageCallback({ + currentChannel, + onMessageDeleted: messageModifiedActions.onMessageDeleted, + onMessageDeletedByReqId: messageModifiedActions.onMessageDeletedByReqId, + }, { logger }), + }; + + const threadFetcherStatusActions = { + initializeThreadListStart: () => store.setState(state => { + return { + ...state, + threadListState: ThreadListStateTypes.LOADING, + allThreadMessages: [], + }; + }), + + initializeThreadListSuccess: (parentMessage: BaseMessage, anchorMessage: SendableMessageType, threadedMessages: BaseMessage[]) => store.setState(state => { + const anchorMessageCreatedAt = (!anchorMessage?.messageId) ? parentMessage?.createdAt : anchorMessage?.createdAt; + const anchorIndex = threadedMessages.findIndex((message) => message?.createdAt > anchorMessageCreatedAt); + const prevThreadMessages = anchorIndex > -1 ? threadedMessages.slice(0, anchorIndex) : threadedMessages; + const anchorThreadMessage = anchorMessage?.messageId ? [anchorMessage] : []; + const nextThreadMessages = anchorIndex > -1 ? threadedMessages.slice(anchorIndex) : []; + return { + ...state, + threadListState: ThreadListStateTypes.INITIALIZED, + hasMorePrev: anchorIndex === -1 || anchorIndex === PREV_THREADS_FETCH_SIZE, + hasMoreNext: threadedMessages.length - anchorIndex === NEXT_THREADS_FETCH_SIZE, + allThreadMessages: [prevThreadMessages, anchorThreadMessage, nextThreadMessages].flat() as CoreMessageType[], + }; + }), + + initializeThreadListFailure: () => store.setState(state => { + return { + ...state, + threadListState: ThreadListStateTypes.LOADING, + allThreadMessages: [], + }; + }), + + getPrevMessagesStart: () => store.setState(state => { + return { + ...state, + }; + }), + + getPrevMessagesSuccess: (threadedMessages: CoreMessageType[]) => store.setState(state => { + return { + ...state, + hasMorePrev: threadedMessages.length === PREV_THREADS_FETCH_SIZE, + allThreadMessages: [...threadedMessages, ...state.allThreadMessages], + }; + }), + + getPrevMessagesFailure: () => store.setState(state => { + return { + ...state, + hasMorePrev: false, + }; + }), + + getNextMessagesStart: () => store.setState(state => { + return { + ...state, + }; + }), + + getNextMessagesSuccess: (threadedMessages: CoreMessageType[]) => store.setState(state => { + return { + ...state, + hasMoreNext: threadedMessages.length === NEXT_THREADS_FETCH_SIZE, + allThreadMessages: [...state.allThreadMessages, ...threadedMessages], + }; + }), + + getNextMessagesFailure: () => store.setState(state => { + return { + ...state, + hasMoreNext: false, + }; + }), + }; + + const { initializeThreadFetcher, fetchPrevThreads, fetchNextThreads } = useThreadFetchers({ + parentMessage, + // anchorMessage should be null when parentMessage doesn't exist + anchorMessage: message?.messageId !== parentMessage?.messageId ? message || undefined : undefined, + logger, + isReactionEnabled, + threadListState, + oldestMessageTimeStamp: allThreadMessages[0]?.createdAt || 0, + latestMessageTimeStamp: allThreadMessages[allThreadMessages.length - 1]?.createdAt || 0, + initializeThreadListStart: threadFetcherStatusActions.initializeThreadListStart, + initializeThreadListSuccess: threadFetcherStatusActions.initializeThreadListSuccess, + initializeThreadListFailure: threadFetcherStatusActions.initializeThreadListFailure, + getPrevMessagesStart: threadFetcherStatusActions.getPrevMessagesStart, + getPrevMessagesSuccess: threadFetcherStatusActions.getPrevMessagesSuccess, + getPrevMessagesFailure: threadFetcherStatusActions.getPrevMessagesFailure, + getNextMessagesStart: threadFetcherStatusActions.getNextMessagesStart, + getNextMessagesSuccess: threadFetcherStatusActions.getNextMessagesSuccess, + getNextMessagesFailure: threadFetcherStatusActions.getNextMessagesFailure, + }); + + const actions = useMemo(() => ({ + setCurrentUserId: (currentUserId: string) => store.setState(state => ({ + ...state, + currentUserId: currentUserId, + })), + + getChannelStart: () => store.setState(state => ({ + ...state, + channelState: ChannelStateTypes.LOADING, + currentChannel: null, + })), + + getChannelSuccess: (groupChannel: GroupChannel) => store.setState(state => ({ + ...state, + channelState: ChannelStateTypes.INITIALIZED, + currentChannel: groupChannel, + // only support in normal group channel + isMuted: groupChannel?.members?.find((member) => member?.userId === state.currentUserId)?.isMuted || false, + isChannelFrozen: groupChannel?.isFrozen || false, + })), + + getChannelFailure: () => store.setState(state => ({ + ...state, + channelState: ChannelStateTypes.INVALID, + currentChannel: null, + })), + + getParentMessageStart: () => store.setState(state => ({ + ...state, + parentMessageState: ParentMessageStateTypes.LOADING, + parentMessage: null, + })), + + getParentMessageSuccess: (parentMessage: SendableMessageType) => store.setState(state => ({ + ...state, + parentMessageState: ParentMessageStateTypes.INITIALIZED, + parentMessage: parentMessage, + })), + + getParentMessageFailure: () => store.setState(state => ({ + ...state, + parentMessageState: ParentMessageStateTypes.INVALID, + parentMessage: null, + })), + + setEmojiContainer: (emojiContainer: EmojiContainer) => store.setState(state => ({ + ...state, + emojiContainer: emojiContainer, + })), + + onMessageReceived: (channel: GroupChannel, message: SendableMessageType) => store.setState(state => { + if ( + state.currentChannel?.url !== channel?.url + || state.hasMoreNext + || message?.parentMessage?.messageId !== state?.parentMessage?.messageId + ) { + return state; + } + + const isAlreadyReceived = state.allThreadMessages.findIndex((m) => ( + m.messageId === message.messageId + )) > -1; + + return { + ...state, + parentMessage: state.parentMessage?.messageId === message?.messageId ? message : state.parentMessage, + allThreadMessages: isAlreadyReceived + ? state.allThreadMessages.map((m) => ( + m.messageId === message.messageId ? message : m + )) + : [ + ...state.allThreadMessages.filter((m) => (m as SendableMessageType)?.reqId !== message?.reqId), + message, + ], + }; + }), + + onReactionUpdated: (reactionEvent: ReactionEvent) => store.setState(state => { + if (state?.parentMessage?.messageId === reactionEvent?.messageId) { + state.parentMessage?.applyReactionEvent?.(reactionEvent); + } + return { + ...state, + allThreadMessages: state.allThreadMessages.map((m) => { + if (reactionEvent?.messageId === m?.messageId) { + m?.applyReactionEvent?.(reactionEvent); + return m; + } + return m; + }), + }; + }), + + onUserMuted: (channel: GroupChannel, user: User) => store.setState(state => { + if (state.currentChannel?.url !== channel?.url || state.currentUserId !== user?.userId) { + return state; + } + return { + ...state, + isMuted: true, + }; + }), + + onUserUnmuted: (channel: GroupChannel, user: User) => store.setState(state => { + if (state.currentChannel?.url !== channel?.url || state.currentUserId !== user?.userId) { + return state; + } + return { + ...state, + isMuted: false, + }; + }), + + onUserBanned: () => store.setState(state => { + return { + ...state, + channelState: ChannelStateTypes.NIL, + threadListState: ThreadListStateTypes.NIL, + parentMessageState: ParentMessageStateTypes.NIL, + currentChannel: null, + parentMessage: null, + allThreadMessages: [], + hasMorePrev: false, + hasMoreNext: false, + }; + }), + + onUserUnbanned: () => store.setState(state => { + return { + ...state, + }; + }), + + onUserLeft: () => store.setState(state => { + return { + ...state, + channelState: ChannelStateTypes.NIL, + threadListState: ThreadListStateTypes.NIL, + parentMessageState: ParentMessageStateTypes.NIL, + currentChannel: null, + parentMessage: null, + allThreadMessages: [], + hasMorePrev: false, + hasMoreNext: false, + }; + }), + + onChannelFrozen: () => store.setState(state => { + return { + ...state, + isChannelFrozen: true, + }; + }), + + onChannelUnfrozen: () => store.setState(state => { + return { + ...state, + isChannelFrozen: false, + }; + }), + + onOperatorUpdated: (channel: GroupChannel) => store.setState(state => { + if (channel?.url === state.currentChannel?.url) { + return { + ...state, + currentChannel: channel, + }; + } + return state; + }), + + onTypingStatusUpdated: (channel: GroupChannel, typingMembers: Member[]) => store.setState(state => { + if (!compareIds(channel.url, state.currentChannel?.url)) { + return state; + } + return { + ...state, + typingMembers, + }; + }), + + onFileInfoUpdated: ({ + channelUrl, + requestId, + index, + uploadableFileInfo, + error, + }: FileUploadInfoParams) => store.setState(state => { + if (!compareIds(channelUrl, state.currentChannel?.url)) { + return state; + } + /** + * We don't have to do anything here because + * onFailed() will be called so handle error there instead. + */ + if (error) return state; + const { localThreadMessages } = state; + const messageToUpdate = localThreadMessages.find((message) => compareIds(hasReqId(message) && message.reqId, requestId), + ); + const fileInfoList = (messageToUpdate as MultipleFilesMessage) + .messageParams?.fileInfoList; + if (Array.isArray(fileInfoList)) { + fileInfoList[index] = uploadableFileInfo; + } + return { + ...state, + localThreadMessages, + }; + }), + + toggleReaction, + ...sendMessageStatusActions, + ...sendMessageActions, + ...messageModifiedActions, + ...modifyMessageActions, + ...threadFetcherStatusActions, + initializeThreadFetcher, + fetchPrevThreads, + fetchNextThreads, + }), [ + store, + currentChannel, + stores.sdkStore.initialized, + parentMessage, + ]); + + return { state, actions }; +}; + +export default useThread; diff --git a/src/modules/Thread/types.tsx b/src/modules/Thread/types.tsx index dacdbdab61..e4c8d2712a 100644 --- a/src/modules/Thread/types.tsx +++ b/src/modules/Thread/types.tsx @@ -1,4 +1,6 @@ // Initializing status +import { UploadableFileInfo } from '@sendbird/chat/message'; + export enum ChannelStateTypes { NIL = 'NIL', LOADING = 'LOADING', @@ -17,3 +19,11 @@ export enum ThreadListStateTypes { INVALID = 'INVALID', INITIALIZED = 'INITIALIZED', } + +export interface FileUploadInfoParams { + channelUrl: string, + requestId: string, + index: number, + uploadableFileInfo: UploadableFileInfo, + error: Error, +} From 1abf238646f16cccf3d004bdd8ab598b0528ce92 Mon Sep 17 00:00:00 2001 From: Baek EunSeo Date: Fri, 29 Nov 2024 11:00:52 +0900 Subject: [PATCH 10/29] refactor: SendbirdProvider Migration (#1260) --- .storybook/preview.tsx | 6 +- apps/testing/src/utils/paramsBuilder.ts | 2 +- rollup.module-exports.mjs | 7 +- src/hooks/VoicePlayer/index.tsx | 5 +- src/hooks/VoiceRecorder/index.tsx | 5 +- src/hooks/VoiceRecorder/useVoiceRecorder.tsx | 5 +- src/hooks/useSendbirdStateContext.tsx | 22 - src/index.ts | 4 +- src/lib/MediaQueryContext.tsx | 2 +- src/lib/Sendbird.tsx | 436 ----------------- src/lib/Sendbird/MIGRATION_GUIDE.md | 27 ++ src/lib/Sendbird/README.md | 25 + .../Sendbird/__experimental__typography.scss | 110 +++++ src/lib/Sendbird/context/SendbirdContext.tsx | 23 + src/lib/Sendbird/context/SendbirdProvider.tsx | 403 ++++++++++++++++ .../Sendbird/context/hooks/useSendbird.tsx | 232 +++++++++ .../context/hooks/useSendbirdStateContext.tsx | 12 + src/lib/Sendbird/context/initialState.ts | 133 ++++++ src/lib/Sendbird/index.scss | 6 + src/lib/Sendbird/index.tsx | 82 ++++ src/lib/Sendbird/types.ts | 439 ++++++++++++++++++ src/lib/Sendbird/utils.ts | 93 ++++ src/lib/SendbirdSdkContext.tsx | 36 -- src/lib/SendbirdState.tsx | 9 - src/lib/UserProfileContext.tsx | 5 +- src/lib/dux/appInfo/actionTypes.ts | 23 - src/lib/dux/appInfo/initialState.ts | 30 -- src/lib/dux/appInfo/reducers.ts | 68 --- src/lib/dux/appInfo/utils.ts | 23 - src/lib/dux/sdk/actionTypes.ts | 18 - src/lib/dux/sdk/initialState.ts | 17 - src/lib/dux/sdk/reducers.ts | 36 -- src/lib/dux/user/actionTypes.ts | 16 - src/lib/dux/user/initialState.ts | 15 - src/lib/dux/user/reducers.ts | 26 -- src/lib/emojiManager.tsx | 3 +- .../hooks/__tests__/schedulerFactory.spec.ts | 2 +- .../useMarkAsDeliveredScheduler.spec.ts | 2 +- .../__tests__/useMarkAsReadScheduler.spec.ts | 2 +- src/lib/hooks/schedulerFactory.ts | 2 +- .../hooks/useConnect/__test__/data.mocks.ts | 90 ---- .../useConnect/__test__/disconnectSdk.spec.ts | 29 -- .../__test__/setupConnection.spec.ts | 291 ------------ src/lib/hooks/useConnect/connect.ts | 56 --- src/lib/hooks/useConnect/disconnectSdk.ts | 25 - src/lib/hooks/useConnect/index.ts | 100 ---- src/lib/hooks/useConnect/setupConnection.ts | 239 ---------- src/lib/hooks/useConnect/types.ts | 58 --- src/lib/hooks/useMarkAsDeliveredScheduler.ts | 4 +- src/lib/hooks/useMarkAsReadScheduler.ts | 2 +- src/lib/hooks/useMessageTemplateUtils.ts | 153 +++--- src/lib/selectors.ts | 50 +- src/lib/types.ts | 316 ------------- .../utils/__tests__/uikitConfigMapper.spec.ts | 2 +- src/lib/utils/uikitConfigMapper.ts | 2 +- src/modules/App/AppLayout.tsx | 6 +- src/modules/App/MobileLayout.tsx | 4 +- src/modules/App/types.ts | 2 +- .../Channel/components/Message/index.tsx | 4 +- .../Channel/components/MessageList/index.tsx | 4 +- .../Channel/context/ChannelProvider.tsx | 5 +- .../useSendMultipleFilesMessage.spec.ts | 2 +- .../Channel/context/hooks/useGetChannel.ts | 2 +- .../context/hooks/useHandleChannelEvents.ts | 6 +- .../context/hooks/useHandleReconnect.ts | 3 +- .../hooks/useHandleReconnectForChannelList.ts | 3 +- .../context/hooks/useHandleUploadFiles.tsx | 6 +- .../context/hooks/useScrollCallback.ts | 2 +- .../context/hooks/useScrollDownCallback.ts | 2 +- .../hooks/useSendFileMessageCallback.ts | 4 +- .../hooks/useSendMultipleFilesMessage.ts | 2 +- .../hooks/useSendVoiceMessageCallback.ts | 2 +- .../components/ChannelListUI/index.tsx | 5 +- .../components/ChannelPreview/index.tsx | 5 +- .../context/ChannelListProvider.tsx | 6 +- .../context/hooks/useActiveChannelUrl.ts | 3 +- .../context/hooks/useFetchChannelList.ts | 2 +- src/modules/ChannelList/utils.ts | 2 +- .../__test__/ChannelSettingsProvider.spec.tsx | 26 +- .../ChannelSettingsUI.integration.test.tsx | 22 +- .../components/ChannelProfile/index.tsx | 4 +- .../ChannelSettingsHeader.tsx | 5 +- .../components/ChannelSettingsUI/index.tsx | 7 +- .../components/EditDetailsModal/index.tsx | 4 +- .../components/LeaveChannel/index.tsx | 4 +- .../ModerationPanel/InviteUsersModal.tsx | 4 +- .../context/hooks/useChannelHandler.ts | 3 +- .../context/hooks/useSetChannel.ts | 3 +- .../CreateChannelUI.integration.test.tsx | 46 +- .../InviteUsers/__tests__/index.spec.tsx | 26 +- .../components/InviteUsers/index.tsx | 6 +- .../components/InviteUsers/utils.ts | 2 +- .../components/SelectChannelType.tsx | 7 +- .../context/CreateChannelProvider.tsx | 8 +- .../__tests__/CreateChannelProvider.spec.tsx | 18 +- .../__tests__/useCreateChannel.spec.tsx | 18 +- src/modules/CreateChannel/utils.ts | 2 +- .../context/CreateOpenChannelProvider.tsx | 8 +- .../EditUserProfileUIView.tsx | 5 +- .../components/EditUserProfileUI/index.tsx | 19 +- .../GroupChannelUIView.integration.test.tsx | 44 +- .../components/FileViewer/FileViewerView.tsx | 5 +- .../components/FileViewer/index.tsx | 5 +- .../GroupChannelHeaderView.tsx | 5 +- .../GroupChannelUI/GroupChannelUIView.tsx | 7 +- .../components/Message/MessageView.tsx | 6 +- .../GroupChannel/components/Message/index.tsx | 5 +- .../MessageInputWrapperView.tsx | 5 +- .../VoiceMessageInputWrapper.tsx | 5 +- .../useHandleUploadFiles.tsx | 7 +- .../components/MessageList/index.tsx | 16 +- .../SuggestedMentionListView.tsx | 5 +- .../components/TypingIndicator.tsx | 8 +- .../__test__/GroupChannelProvider.spec.tsx | 72 +-- .../context/__test__/useGroupChannel.spec.tsx | 54 +-- .../context/hooks/useGroupChannel.ts | 4 +- .../AddGroupChannel/AddGroupChannelView.tsx | 4 +- .../GroupChannelListHeader/index.tsx | 5 +- .../GroupChannelListItemView.tsx | 4 +- .../components/GroupChannelListItem/index.tsx | 4 +- .../GroupChannelListUIView.tsx | 4 +- .../GroupChannelListUI.integration.test.tsx | 46 +- .../components/GroupChannelListUI/index.tsx | 5 +- .../components/LeaveGroupChannel/index.tsx | 5 +- .../context/GroupChannelListProvider.tsx | 3 +- .../GroupChannelListProvider.spec.tsx | 26 +- .../__tests__/useGroupChannelList.spec.tsx | 26 +- .../Message/hooks/useDirtyGetMentions.ts | 2 +- .../context/MessageSearchProvider.tsx | 5 +- .../__test__/MessageSearchProvider.spec.tsx | 20 +- .../__test__/useMessageSearch.spec.tsx | 28 +- .../context/hooks/useGetSearchedMessages.ts | 3 +- .../context/hooks/useSetChannel.ts | 3 +- .../components/OpenChannelMessage/index.tsx | 8 +- .../OpenChannelMessageList/index.tsx | 6 +- .../context/OpenChannelProvider.tsx | 12 +- .../context/hooks/useDeleteMessageCallback.ts | 2 +- .../context/hooks/useFileUploadCallback.tsx | 9 +- .../context/hooks/useHandleChannelEvents.ts | 3 +- .../context/hooks/useInitialMessagesFetch.ts | 2 +- .../context/hooks/useResendMessageCallback.ts | 2 +- .../context/hooks/useScrollCallback.ts | 3 +- .../context/hooks/useSendMessageCallback.ts | 3 +- .../context/hooks/useSetChannel.ts | 3 +- .../context/hooks/useUpdateMessageCallback.ts | 2 +- src/modules/OpenChannel/context/utils.ts | 2 +- .../components/CommunityChannelList.tsx | 2 +- .../components/StreamingChannelList.tsx | 2 +- .../context/OpenChannelListInterfaces.ts | 2 +- .../context/OpenChannelListProvider.tsx | 4 +- .../context/hooks/createChannelListQuery.ts | 3 +- .../context/hooks/useFetchNextCallback.ts | 2 +- .../hooks/useRefreshOpenChannelList.ts | 3 +- .../context/hooks/useSetupOpenChannelList.ts | 3 +- .../components/EditDetailsModal.tsx | 6 +- .../components/OpenChannelProfile/index.tsx | 8 +- .../OpenChannelSettingsUI/index.tsx | 8 +- .../components/OperatorUI/BannedUserList.tsx | 4 +- .../OperatorUI/BannedUsersModal.tsx | 6 +- .../OperatorUI/DeleteOpenChannel.tsx | 6 +- .../OperatorUI/MutedParticipantList.tsx | 6 +- .../OperatorUI/MutedParticipantsModal.tsx | 4 +- .../components/OperatorUI/OperatorList.tsx | 4 +- .../components/OperatorUI/OperatorsModal.tsx | 4 +- .../ParticipantUI/ParticipantItem.tsx | 6 +- .../ParticipantUI/ParticipantsModal.tsx | 4 +- .../components/ParticipantUI/index.tsx | 6 +- .../context/OpenChannelSettingsProvider.tsx | 12 +- .../ParentMessageInfoItem.tsx | 4 +- .../components/ParentMessageInfo/index.tsx | 6 +- .../components/ThreadList/ThreadListItem.tsx | 6 +- .../ThreadList/ThreadListItemContent.tsx | 4 +- .../Thread/components/ThreadList/index.tsx | 4 +- .../components/ThreadMessageInput/index.tsx | 6 +- .../Thread/components/ThreadUI/index.tsx | 7 +- src/modules/Thread/context/ThreadProvider.tsx | 5 +- .../context/__test__/ThreadProvider.spec.tsx | 50 +- .../context/hooks/useDeleteMessageCallback.ts | 5 +- .../Thread/context/hooks/useGetAllEmoji.ts | 3 +- .../Thread/context/hooks/useGetChannel.ts | 3 +- .../context/hooks/useGetParentMessage.ts | 3 +- .../context/hooks/useHandleChannelEvents.ts | 3 +- .../hooks/useHandleThreadPubsubEvents.ts | 3 +- .../context/hooks/useResendMessageCallback.ts | 5 +- .../context/hooks/useSendFileMessage.ts | 2 +- .../hooks/useSendUserMessageCallback.ts | 2 +- .../hooks/useSendVoiceMessageCallback.ts | 3 +- .../Thread/context/hooks/useThreadFetchers.ts | 4 +- .../hooks/useToggleReactionsCallback.ts | 5 +- .../context/hooks/useUpdateMessageCallback.ts | 2 +- src/modules/Thread/context/useThread.ts | 4 +- .../BottomSheet/__tests__/BottomSheet.spec.js | 28 +- src/ui/BottomSheet/index.tsx | 4 +- src/ui/EmojiReactions/ReactionItem.tsx | 5 +- src/ui/EmojiReactions/index.tsx | 4 +- src/ui/FileMessageItemBody/index.tsx | 5 +- .../FileViewer/__tests__/FileViewer.spec.js | 39 +- src/ui/LegacyEditUserProfile/index.tsx | 4 +- src/ui/MentionLabel/index.tsx | 8 +- src/ui/MessageContent/MessageBody/index.tsx | 4 +- .../MessageContentForTemplateMessage.tsx | 4 +- .../__tests__/MessageContent.spec.js | 68 ++- src/ui/MessageContent/index.tsx | 4 +- .../__tests__/MessageInput.spec.js | 95 ++-- src/ui/MessageInput/index.tsx | 4 +- src/ui/MessageInput/messageInputUtils.ts | 6 +- src/ui/MessageItemMenu/index.tsx | 2 +- src/ui/MessageMenu/MessageMenu.tsx | 5 +- src/ui/MobileMenu/MobileBottomSheet.tsx | 5 +- src/ui/MobileMenu/MobileContextMenu.tsx | 5 +- src/ui/Modal/index.tsx | 4 +- src/ui/MultipleFilesMessageItemBody/index.tsx | 4 +- src/ui/OpenChannelMobileMenu/index.tsx | 4 +- src/ui/TemplateMessageItemBody/index.tsx | 14 +- src/ui/Toggle/utils.ts | 5 +- .../__tests__/UserListItem.spec.js | 27 +- src/ui/UserListItem/index.tsx | 4 +- src/ui/UserListItemMenu/UserListItemMenu.tsx | 4 +- src/ui/UserListItemMenu/context.tsx | 4 +- src/ui/UserProfile/index.tsx | 10 +- src/ui/Word/__tests__/Word.spec.js | 32 +- .../__tests__/__snapshots__/Word.spec.js.snap | 2 +- .../__tests__/getIsReactionEnabled.spec.ts | 4 +- .../isReplyTypeMessageEnabled.spec.ts | 2 +- src/utils/compressImages.ts | 3 +- src/utils/getIsReactionEnabled.ts | 4 +- 226 files changed, 2542 insertions(+), 2789 deletions(-) delete mode 100644 src/hooks/useSendbirdStateContext.tsx delete mode 100644 src/lib/Sendbird.tsx create mode 100644 src/lib/Sendbird/MIGRATION_GUIDE.md create mode 100644 src/lib/Sendbird/README.md create mode 100644 src/lib/Sendbird/__experimental__typography.scss create mode 100644 src/lib/Sendbird/context/SendbirdContext.tsx create mode 100644 src/lib/Sendbird/context/SendbirdProvider.tsx create mode 100644 src/lib/Sendbird/context/hooks/useSendbird.tsx create mode 100644 src/lib/Sendbird/context/hooks/useSendbirdStateContext.tsx create mode 100644 src/lib/Sendbird/context/initialState.ts create mode 100644 src/lib/Sendbird/index.scss create mode 100644 src/lib/Sendbird/index.tsx create mode 100644 src/lib/Sendbird/types.ts create mode 100644 src/lib/Sendbird/utils.ts delete mode 100644 src/lib/SendbirdSdkContext.tsx delete mode 100644 src/lib/SendbirdState.tsx delete mode 100644 src/lib/dux/appInfo/actionTypes.ts delete mode 100644 src/lib/dux/appInfo/initialState.ts delete mode 100644 src/lib/dux/appInfo/reducers.ts delete mode 100644 src/lib/dux/appInfo/utils.ts delete mode 100644 src/lib/dux/sdk/actionTypes.ts delete mode 100644 src/lib/dux/sdk/initialState.ts delete mode 100644 src/lib/dux/sdk/reducers.ts delete mode 100644 src/lib/dux/user/actionTypes.ts delete mode 100644 src/lib/dux/user/initialState.ts delete mode 100644 src/lib/dux/user/reducers.ts delete mode 100644 src/lib/hooks/useConnect/__test__/data.mocks.ts delete mode 100644 src/lib/hooks/useConnect/__test__/disconnectSdk.spec.ts delete mode 100644 src/lib/hooks/useConnect/__test__/setupConnection.spec.ts delete mode 100644 src/lib/hooks/useConnect/connect.ts delete mode 100644 src/lib/hooks/useConnect/disconnectSdk.ts delete mode 100644 src/lib/hooks/useConnect/index.ts delete mode 100644 src/lib/hooks/useConnect/setupConnection.ts delete mode 100644 src/lib/hooks/useConnect/types.ts delete mode 100644 src/lib/types.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 1cd9a07cd8..d08cce1499 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { Preview } from '@storybook/react'; -import { SendbirdSdkContext } from '../src/lib/SendbirdSdkContext'; +import { SendbirdContext } from '../src/lib/Sendbird/context/SendbirdContext'; import '../src/lib/index.scss'; import './index.css'; @@ -28,9 +28,9 @@ const preview: Preview = { decorators: [ (Story) => (
- + {Story()} - +
), ], diff --git a/apps/testing/src/utils/paramsBuilder.ts b/apps/testing/src/utils/paramsBuilder.ts index ee910b0401..a94cb86e6e 100644 --- a/apps/testing/src/utils/paramsBuilder.ts +++ b/apps/testing/src/utils/paramsBuilder.ts @@ -1,4 +1,4 @@ -import { UIKitOptions } from '../../../../src/lib/types.ts'; +import { UIKitOptions } from '../../../../src/lib/Sendbird/types'; import { useSearchParams } from 'react-router-dom'; export interface InitialParams { diff --git a/rollup.module-exports.mjs b/rollup.module-exports.mjs index bea9e1cc55..9c17567955 100644 --- a/rollup.module-exports.mjs +++ b/rollup.module-exports.mjs @@ -10,10 +10,11 @@ export default { App: 'src/modules/App/index.tsx', // SendbirdProvider - SendbirdProvider: 'src/lib/Sendbird.tsx', + SendbirdProvider: 'src/lib/Sendbird/index.tsx', sendbirdSelectors: 'src/lib/selectors.ts', - useSendbirdStateContext: 'src/hooks/useSendbirdStateContext.tsx', - withSendbird: 'src/lib/SendbirdSdkContext.tsx', + // TODO: Support below legacy exports + // useSendbirdStateContext: 'src/hooks/useSendbirdStateContext.tsx', + // withSendbird: 'src/lib/SendbirdSdkContext.tsx', // Voice message 'VoiceRecorder/context': 'src/hooks/VoiceRecorder/index.tsx', diff --git a/src/hooks/VoicePlayer/index.tsx b/src/hooks/VoicePlayer/index.tsx index 5985282429..de644ec80a 100644 --- a/src/hooks/VoicePlayer/index.tsx +++ b/src/hooks/VoicePlayer/index.tsx @@ -19,8 +19,8 @@ import { VOICE_PLAYER_AUDIO_ID, VOICE_PLAYER_ROOT_ID, } from '../../utils/consts'; -import useSendbirdStateContext from '../useSendbirdStateContext'; import { getParsedVoiceAudioFileInfo } from './utils'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; // VoicePlayerProvider interface export interface VoicePlayerProps { @@ -64,7 +64,8 @@ export const VoicePlayerProvider = ({ currentPlayer, audioStorage, } = voicePlayerStore; - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { logger } = config; const stop = (text = '') => { diff --git a/src/hooks/VoiceRecorder/index.tsx b/src/hooks/VoiceRecorder/index.tsx index 84681b9caf..217877f5de 100644 --- a/src/hooks/VoiceRecorder/index.tsx +++ b/src/hooks/VoiceRecorder/index.tsx @@ -8,9 +8,9 @@ import { VOICE_MESSAGE_MIME_TYPE, VOICE_RECORDER_AUDIO_BIT_RATE, } from '../../utils/consts'; -import useSendbirdStateContext from '../useSendbirdStateContext'; import { type WebAudioUtils } from './WebAudioUtils'; import { noop } from '../../utils/utils'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; // Input props of VoiceRecorder export interface VoiceRecorderProps { @@ -37,7 +37,8 @@ const Context = createContext({ export const VoiceRecorderProvider = (props: VoiceRecorderProps): React.ReactElement => { const { children } = props; - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { logger, groupChannel } = config; const [mediaRecorder, setMediaRecorder] = useState(null); const [isRecordable, setIsRecordable] = useState(false); diff --git a/src/hooks/VoiceRecorder/useVoiceRecorder.tsx b/src/hooks/VoiceRecorder/useVoiceRecorder.tsx index 50533cabfa..e317b1c2a4 100644 --- a/src/hooks/VoiceRecorder/useVoiceRecorder.tsx +++ b/src/hooks/VoiceRecorder/useVoiceRecorder.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { VoiceRecorderEventHandler, useVoiceRecorderContext } from '.'; -import useSendbirdStateContext from '../useSendbirdStateContext'; import { noop } from '../../utils/utils'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; // export interface UseVoiceRecorderProps extends VoiceRecorderEventHandler { // /** @@ -31,7 +31,8 @@ export const useVoiceRecorder = ({ onRecordingStarted = noop, onRecordingEnded = noop, }: VoiceRecorderEventHandler): UseVoiceRecorderContext => { - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { voiceRecord } = config; const maxRecordingTime = voiceRecord.maxRecordingTime; const voiceRecorder = useVoiceRecorderContext(); diff --git a/src/hooks/useSendbirdStateContext.tsx b/src/hooks/useSendbirdStateContext.tsx deleted file mode 100644 index 591fd8f667..0000000000 --- a/src/hooks/useSendbirdStateContext.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Example: - * const MyComponent = () => { - * const context = useSendbirdStateContext(); - * const sdk = sendbirdSelectors.getSdk(context); - * return (
...
); - * } - */ -import { useContext } from 'react'; - -import { SendbirdSdkContext } from '../lib/SendbirdSdkContext'; -import { SendBirdState } from '../lib/types'; - -const NO_CONTEXT_ERROR = 'No sendbird state value available. Make sure you are rendering `` at the top of your app.'; - -export function useSendbirdStateContext(): SendBirdState { - const context = useContext(SendbirdSdkContext); - if (!context) throw new Error(NO_CONTEXT_ERROR); - return context; -} - -export default useSendbirdStateContext; diff --git a/src/index.ts b/src/index.ts index 14dd7e661e..16bf960d2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,11 +15,11 @@ export { default as MessageSearch } from './modules/MessageSearch'; // HOC for using ui-kit state // withBird(MyCustomComponent) will give the sendbird state as props to MyCustomComponent -export { default as withSendBird } from './lib/SendbirdSdkContext'; +export { withSendBird } from './lib/Sendbird/index'; +export { useSendbirdStateContext } from './lib/Sendbird/context/hooks/useSendbirdStateContext'; export { default as sendbirdSelectors } from './lib/selectors'; // for legacy parity, slowly remove export { default as sendBirdSelectors } from './lib/selectors'; -export { default as useSendbirdStateContext } from './hooks/useSendbirdStateContext'; // Public enum included in AppProps export { TypingIndicatorType } from './types'; diff --git a/src/lib/MediaQueryContext.tsx b/src/lib/MediaQueryContext.tsx index 5c59fe9e06..20eb8163e1 100644 --- a/src/lib/MediaQueryContext.tsx +++ b/src/lib/MediaQueryContext.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import type { Logger } from './SendbirdState'; +import type { Logger } from './Sendbird/types'; const DEFAULT_MOBILE = false; // const DEFAULT_MOBILE = '768px'; diff --git a/src/lib/Sendbird.tsx b/src/lib/Sendbird.tsx deleted file mode 100644 index a2eadf6b2f..0000000000 --- a/src/lib/Sendbird.tsx +++ /dev/null @@ -1,436 +0,0 @@ -import './index.scss'; -import './__experimental__typography.scss'; - -import React, { useEffect, useMemo, useReducer, useState } from 'react'; -import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { UIKitConfigProvider, useUIKitConfig } from '@sendbird/uikit-tools'; - -import { SendbirdSdkContext } from './SendbirdSdkContext'; - -import useTheme from './hooks/useTheme'; - -import sdkReducers from './dux/sdk/reducers'; -import userReducers from './dux/user/reducers'; -import appInfoReducers from './dux/appInfo/reducers'; - -import sdkInitialState from './dux/sdk/initialState'; -import userInitialState from './dux/user/initialState'; -import appInfoInitialState from './dux/appInfo/initialState'; - -import useOnlineStatus from './hooks/useOnlineStatus'; -import useConnect from './hooks/useConnect'; -import { LoggerFactory, LogLevel } from './Logger'; -import pubSubFactory from './pubSub/index'; - -import { VoiceMessageProvider } from './VoiceMessageProvider'; -import { LocalizationProvider } from './LocalizationContext'; -import { MediaQueryProvider, useMediaQueryContext } from './MediaQueryContext'; -import getStringSet, { StringSet } from '../ui/Label/stringSet'; -import { - DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, - DEFAULT_UPLOAD_SIZE_LIMIT, - VOICE_RECORDER_DEFAULT_MAX, - VOICE_RECORDER_DEFAULT_MIN, -} from '../utils/consts'; -import { uikitConfigMapper } from './utils/uikitConfigMapper'; - -import { useMarkAsReadScheduler } from './hooks/useMarkAsReadScheduler'; -import { ConfigureSessionTypes } from './hooks/useConnect/types'; -import { useMarkAsDeliveredScheduler } from './hooks/useMarkAsDeliveredScheduler'; -import { getCaseResolvedReplyType } from './utils/resolvedReplyType'; -import { useUnmount } from '../hooks/useUnmount'; -import { disconnectSdk } from './hooks/useConnect/disconnectSdk'; -import { - UIKitOptions, - CommonUIKitConfigProps, - SendbirdChatInitParams, - CustomExtensionParams, - SBUEventHandlers, - SendbirdProviderUtils, -} from './types'; -import { GlobalModalProvider, ModalRoot } from '../hooks/useModal'; -import { HTMLTextDirection, RenderUserProfileProps, UserListQuery } from '../types'; -import PUBSUB_TOPICS, { SBUGlobalPubSub, SBUGlobalPubSubTopicPayloadUnion } from './pubSub/topics'; -import { EmojiManager } from './emojiManager'; -import { uikitConfigStorage } from './utils/uikitConfigStorage'; -import useMessageTemplateUtils from './hooks/useMessageTemplateUtils'; -import { EmojiReactionListRoot, MenuRoot } from '../ui/ContextMenu'; -import useHTMLTextDirection from '../hooks/useHTMLTextDirection'; - -export { useSendbirdStateContext } from '../hooks/useSendbirdStateContext'; - -interface VoiceRecordOptions { - maxRecordingTime?: number; - minRecordingTime?: number; -} - -export type ImageCompressionOutputFormatType = 'preserve' | 'png' | 'jpeg'; -export interface ImageCompressionOptions { - compressionRate?: number; - resizingWidth?: number | string; - resizingHeight?: number | string; - outputFormat?: ImageCompressionOutputFormatType; -} - -export interface SendbirdConfig { - logLevel?: string | Array; - pubSub?: SBUGlobalPubSub; - userMention?: { - maxMentionCount?: number; - maxSuggestionCount?: number; - }; - isREMUnitEnabled?: boolean; -} - -export interface SendbirdProviderProps extends CommonUIKitConfigProps, React.PropsWithChildren { - appId: string; - userId: string; - accessToken?: string; - customApiHost?: string; - customWebSocketHost?: string; - configureSession?: ConfigureSessionTypes; - theme?: 'light' | 'dark'; - config?: SendbirdConfig; - nickname?: string; - colorSet?: Record; - stringSet?: Partial; - dateLocale?: Locale; - profileUrl?: string; - voiceRecord?: VoiceRecordOptions; - userListQuery?: () => UserListQuery; - imageCompression?: ImageCompressionOptions; - allowProfileEdit?: boolean; - disableMarkAsDelivered?: boolean; - breakpoint?: string | boolean; - htmlTextDirection?: HTMLTextDirection; - forceLeftToRightMessageLayout?: boolean; - uikitOptions?: UIKitOptions; - isUserIdUsedForNickname?: boolean; - sdkInitParams?: SendbirdChatInitParams; - customExtensionParams?: CustomExtensionParams; - isMultipleFilesMessageEnabled?: boolean; - // UserProfile - renderUserProfile?: (props: RenderUserProfileProps) => React.ReactElement; - onStartDirectMessage?: (channel: GroupChannel) => void; - /** - * @deprecated Please use `onStartDirectMessage` instead. It's renamed. - */ - onUserProfileMessage?: (channel: GroupChannel) => void; - - // Customer provided callbacks - eventHandlers?: SBUEventHandlers; -} - -export function SendbirdProvider(props: SendbirdProviderProps) { - const localConfigs: UIKitOptions = uikitConfigMapper({ - legacyConfig: { - replyType: props.replyType, - isMentionEnabled: props.isMentionEnabled, - isReactionEnabled: props.isReactionEnabled, - disableUserProfile: props.disableUserProfile, - isVoiceMessageEnabled: props.isVoiceMessageEnabled, - isTypingIndicatorEnabledOnChannelList: props.isTypingIndicatorEnabledOnChannelList, - isMessageReceiptStatusEnabledOnChannelList: props.isMessageReceiptStatusEnabledOnChannelList, - showSearchIcon: props.showSearchIcon, - }, - uikitOptions: props.uikitOptions, - }); - - return ( - - - - ); -} -const SendbirdSDK = ({ - appId, - userId, - children, - accessToken, - customApiHost, - customWebSocketHost, - configureSession, - theme = 'light', - config = {}, - nickname = '', - colorSet, - stringSet, - dateLocale, - profileUrl = '', - voiceRecord, - userListQuery, - imageCompression = {}, - allowProfileEdit = false, - disableMarkAsDelivered = false, - renderUserProfile, - onUserProfileMessage: _onUserProfileMessage, - onStartDirectMessage: _onStartDirectMessage, - breakpoint = false, - isUserIdUsedForNickname = true, - sdkInitParams, - customExtensionParams, - isMultipleFilesMessageEnabled = false, - eventHandlers, - htmlTextDirection = 'ltr', - forceLeftToRightMessageLayout = false, -}: SendbirdProviderProps): React.ReactElement => { - const onStartDirectMessage = _onStartDirectMessage ?? _onUserProfileMessage; - const { logLevel = '', userMention = {}, isREMUnitEnabled = false, pubSub: customPubSub } = config; - const { isMobile } = useMediaQueryContext(); - const [logger, setLogger] = useState(LoggerFactory(logLevel as LogLevel)); - const [pubSub] = useState(() => customPubSub ?? pubSubFactory()); - const [sdkStore, sdkDispatcher] = useReducer(sdkReducers, sdkInitialState); - const [userStore, userDispatcher] = useReducer(userReducers, userInitialState); - const [appInfoStore, appInfoDispatcher] = useReducer(appInfoReducers, appInfoInitialState); - - const { configs, configsWithAppAttr, initDashboardConfigs } = useUIKitConfig(); - - const sdkInitialized = sdkStore.initialized; - const sdk = sdkStore?.sdk; - const { uploadSizeLimit, multipleFilesMessageFileCountLimit } = sdk?.appInfo ?? {}; - - useTheme(colorSet); - - const { getCachedTemplate, updateMessageTemplatesInfo, initializeMessageTemplatesInfo } = useMessageTemplateUtils({ - sdk, - logger, - appInfoStore, - appInfoDispatcher, - }); - - const utils: SendbirdProviderUtils = { - updateMessageTemplatesInfo, - getCachedTemplate, - }; - - const reconnect = useConnect( - { - appId, - userId, - accessToken, - isUserIdUsedForNickname, - isMobile, - }, - { - logger, - nickname, - profileUrl, - configureSession, - customApiHost, - customWebSocketHost, - sdkInitParams, - customExtensionParams, - sdk, - sdkDispatcher, - userDispatcher, - appInfoDispatcher, - initDashboardConfigs, - eventHandlers, - initializeMessageTemplatesInfo, - }, - ); - - useUnmount(() => { - if (typeof sdk.disconnect === 'function') { - disconnectSdk({ - logger, - sdkDispatcher, - userDispatcher, - sdk, - }); - } - }, [sdk.disconnectWebSocket]); - - // to create a pubsub to communicate between parent and child - useEffect(() => { - setLogger(LoggerFactory(logLevel as LogLevel)); - }, [logLevel]); - - // should move to reducer - const [currentTheme, setCurrentTheme] = useState(theme); - useEffect(() => { - setCurrentTheme(theme); - }, [theme]); - - useEffect(() => { - const body = document.querySelector('body'); - body?.classList.remove('sendbird-experimental__rem__units'); - if (isREMUnitEnabled) { - body?.classList.add('sendbird-experimental__rem__units'); - } - }, [isREMUnitEnabled]); - // add-remove theme from body - useEffect(() => { - logger.info('Setup theme', `Theme: ${currentTheme}`); - try { - const body = document.querySelector('body'); - body?.classList.remove('sendbird-theme--light'); - body?.classList.remove('sendbird-theme--dark'); - body?.classList.add(`sendbird-theme--${currentTheme || 'light'}`); - logger.info('Finish setup theme'); - // eslint-disable-next-line no-empty - } catch (e) { - logger.warning('Setup theme failed', `${e}`); - } - return () => { - try { - const body = document.querySelector('body'); - body?.classList.remove('sendbird-theme--light'); - body?.classList.remove('sendbird-theme--dark'); - // eslint-disable-next-line no-empty - } catch { } - }; - }, [currentTheme]); - - useHTMLTextDirection(htmlTextDirection); - - const isOnline = useOnlineStatus(sdkStore.sdk, logger); - - const markAsReadScheduler = useMarkAsReadScheduler({ isConnected: isOnline }, { logger }); - const markAsDeliveredScheduler = useMarkAsDeliveredScheduler({ isConnected: isOnline }, { logger }); - - const localeStringSet = React.useMemo(() => { - return { ...getStringSet('en'), ...stringSet }; - }, [stringSet]); - - /** - * Feature Configuration - TODO - * This will be moved into the UIKitConfigProvider, aftering Dashboard applies - */ - const uikitMultipleFilesMessageLimit = useMemo(() => { - return Math.min(DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, multipleFilesMessageFileCountLimit ?? Number.MAX_SAFE_INTEGER); - }, [multipleFilesMessageFileCountLimit]); - - // Emoji Manager - const emojiManager = useMemo(() => { - return new EmojiManager({ - sdk, - logger, - }); - }, [sdkStore.initialized]); - - return ( - - - - - {children} - - - - {/* Roots */} - - - - - ); -}; - -export default SendbirdProvider; diff --git a/src/lib/Sendbird/MIGRATION_GUIDE.md b/src/lib/Sendbird/MIGRATION_GUIDE.md new file mode 100644 index 0000000000..8e85eb71e1 --- /dev/null +++ b/src/lib/Sendbird/MIGRATION_GUIDE.md @@ -0,0 +1,27 @@ +# SendbirdProvider interface change + +## What has been changed? + +### Dispatchers are removed + +There were `sdkDispatcher`, `userDispatcher`, `appInfoDispatcher` and `reconnect` in the `SendbirdSdkContext`. +so you could get them like below + +```tsx +const { dispatchers } = useSendbirdSdkContext(); +const { sdkDispatcher, userDispatcher, appInfoDispatcher, reconnect } = dispatchers; +``` + +Now these context are removed, so you can't get the `dispatchers` from now on. + +### Actions are added! + +However, you don't have to worry! We replace them with `actions`. +```tsx +const { actions } = useSendbirdSdkContext(); +``` + +This is a replacement of `dispatchers`. For example, `connect` replace the `reconnect` of `dispatchers`. +```tsx +actions.connect(); +``` diff --git a/src/lib/Sendbird/README.md b/src/lib/Sendbird/README.md new file mode 100644 index 0000000000..3968d4abff --- /dev/null +++ b/src/lib/Sendbird/README.md @@ -0,0 +1,25 @@ +# SendbirdProvider + +## How to use SendbirdProvider? + +#### Import +Import `SendbirdProvider` and `useSendbirdStateContext`. +```tsx +import { SendbirdProvider, useSendbirdStateContext } from '@sendbird/uikit-react'; +``` + +#### Example +```tsx +const MyComponent = () => { + const context = useSendbirdStateContext(); + // Use the context + return (
{/* Fill components */}
); +}; +const MyApp = () => { + return ( + + + + ); +}; +``` diff --git a/src/lib/Sendbird/__experimental__typography.scss b/src/lib/Sendbird/__experimental__typography.scss new file mode 100644 index 0000000000..c55ad2bcaf --- /dev/null +++ b/src/lib/Sendbird/__experimental__typography.scss @@ -0,0 +1,110 @@ +// We are tyring to move font size into "rem" units for accesibility +// todo: make this default in @sendbird/uikit@v4 + +// assuming @fontsize = 16px +// about rem https://www.joshwcomeau.com/css/surprising-truth-about-pixels-and-accessibility/#rems +.sendbird-experimental__rem__units { + // typography + .sendbird-label--h-1 { + font-size: 1.25rem; + } + + .sendbird-label--h-2 { + font-size: 1.125rem; + } + + .sendbird-label--subtitle-1 { + font-size: 1rem; + } + + .sendbird-label--subtitle-2 { + font-size: .875rem; + } + + .sendbird-label--body-1 { + font-size: .875rem; + } + + .sendbird-label--body-2 { + font-size: .75rem; + } + + .sendbird-label--button-1 { + font-size: .875rem; + } + + .sendbird-label--button-2 { + font-size: .875rem; + } + + .sendbird-label--caption-1 { + font-size: .875rem; + } + + .sendbird-label--caption-2 { + font-size: .75rem; + } + + .sendbird-label--caption-3 { + font-size: .75rem; + } + + // message search + .sendbird-message-search-pannel { + .sendbird-message-search-pannel__input__container__input-area { + font-size: .875rem; + } + } + + // checkbox + .sendbird-checkbox { + font-size: 1.375rem; + } + + .sendbird-mention-user-label { + font-size: .875rem; + &.purple { + font-size: 1.125rem; + } + } + + // message input + .sendbird-message-input { + .sendbird-message-input--textarea, + .sendbird-message-input--placeholder { + font-size: .875rem; + } + } + + // input + .sendbird-input { + .sendbird-input__input, + .sendbird-input__placeholder { + font-size: .875rem; + } + } + + // tooltip + .sendbird-tooltip { + &__text { + font-size: .75rem; + } + } + + // quote-message + .sendbird-quote-message { + .sendbird-quote-message__replied-to { + .sendbird-quote-message__replied-to__text { + font-size: .75rem; + } + } + .sendbird-quote-message__replied-message { + .sendbird-quote-message__replied-message__text-message { + font-size: .75rem; + } + } + .sendbird-quote-message__replied-message__file-message { + font-size: .75rem; + } + } +} diff --git a/src/lib/Sendbird/context/SendbirdContext.tsx b/src/lib/Sendbird/context/SendbirdContext.tsx new file mode 100644 index 0000000000..21d1bfa135 --- /dev/null +++ b/src/lib/Sendbird/context/SendbirdContext.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { createStore } from '../../../utils/storeManager'; +import { initialState } from './initialState'; +import { SendbirdState } from '../types'; +import { useStore } from '../../../hooks/useStore'; + +/** + * SendbirdContext + */ +export const SendbirdContext = React.createContext> | null>(null); + +/** + * Create store for Sendbird context + */ +export const createSendbirdContextStore = () => createStore(initialState); + +/** + * A specialized hook for Ssendbird state management + * @returns {ReturnType>} + */ +export const useSendbirdStore = () => { + return useStore(SendbirdContext, state => state, initialState); +}; diff --git a/src/lib/Sendbird/context/SendbirdProvider.tsx b/src/lib/Sendbird/context/SendbirdProvider.tsx new file mode 100644 index 0000000000..b992665e6a --- /dev/null +++ b/src/lib/Sendbird/context/SendbirdProvider.tsx @@ -0,0 +1,403 @@ +/* External libraries */ +import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react'; +import { useUIKitConfig } from '@sendbird/uikit-tools'; + +/* Types */ +import type { ImageCompressionOptions, SendbirdProviderProps, SendbirdStateConfig } from '../types'; + +/* Providers */ +import VoiceMessageProvider from '../../VoiceMessageProvider'; +import { MediaQueryProvider, useMediaQueryContext } from '../../MediaQueryContext'; +import { LocalizationProvider } from '../../LocalizationContext'; +import { GlobalModalProvider, ModalRoot } from '../../../hooks/useModal'; + +/* Managers */ +import { LoggerFactory, type LogLevel } from '../../Logger'; +import pubSubFactory from '../../pubSub'; +import { EmojiManager } from '../../emojiManager'; +import PUBSUB_TOPICS, { SBUGlobalPubSubTopicPayloadUnion } from '../../pubSub/topics'; + +/* Hooks */ +import useTheme from '../../hooks/useTheme'; +import useMessageTemplateUtils from '../../hooks/useMessageTemplateUtils'; +import { useUnmount } from '../../../hooks/useUnmount'; +import useHTMLTextDirection from '../../../hooks/useHTMLTextDirection'; +import useOnlineStatus from '../../hooks/useOnlineStatus'; +import { useMarkAsReadScheduler } from '../../hooks/useMarkAsReadScheduler'; +import { useMarkAsDeliveredScheduler } from '../../hooks/useMarkAsDeliveredScheduler'; + +/* Utils */ +import getStringSet from '../../../ui/Label/stringSet'; +import { getCaseResolvedReplyType } from '../../utils/resolvedReplyType'; +import { DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, DEFAULT_UPLOAD_SIZE_LIMIT, VOICE_RECORDER_DEFAULT_MAX, VOICE_RECORDER_DEFAULT_MIN } from '../../../utils/consts'; +import { EmojiReactionListRoot, MenuRoot } from '../../../ui/ContextMenu'; + +import useSendbird from './hooks/useSendbird'; +import { SendbirdContext, useSendbirdStore } from './SendbirdContext'; +import { createStore } from '../../../utils/storeManager'; +import { initialState } from './initialState'; + +/** + * SendbirdContext - Manager + */ +const SendbirdContextManager = ({ + appId, + userId, + accessToken, + customApiHost, + customWebSocketHost, + configureSession, + theme = 'light', + config = {}, + nickname = '', + colorSet, + profileUrl = '', + voiceRecord, + userListQuery, + imageCompression = {}, + allowProfileEdit = false, + disableMarkAsDelivered = false, + renderUserProfile, + onUserProfileMessage: _onUserProfileMessage, + onStartDirectMessage: _onStartDirectMessage, + isUserIdUsedForNickname = true, + sdkInitParams, + customExtensionParams, + isMultipleFilesMessageEnabled = false, + eventHandlers, + htmlTextDirection = 'ltr', + forceLeftToRightMessageLayout = false, +}: SendbirdProviderProps): ReactElement => { + const onStartDirectMessage = _onStartDirectMessage ?? _onUserProfileMessage; + const { logLevel = '', userMention = {}, isREMUnitEnabled = false, pubSub: customPubSub } = config; + const { isMobile } = useMediaQueryContext(); + const [logger, setLogger] = useState(LoggerFactory(logLevel as LogLevel)); + const [pubSub] = useState(customPubSub ?? pubSubFactory()); + + const { state, updateState } = useSendbirdStore(); + const { actions } = useSendbird(); + const { sdkStore, appInfoStore } = state.stores; + + const { configs, configsWithAppAttr, initDashboardConfigs } = useUIKitConfig(); + + const sdkInitialized = sdkStore.initialized; + const sdk = sdkStore?.sdk; + const { uploadSizeLimit, multipleFilesMessageFileCountLimit } = sdk?.appInfo ?? {}; + + useTheme(colorSet); + + const { getCachedTemplate, updateMessageTemplatesInfo, initializeMessageTemplatesInfo } = useMessageTemplateUtils({ + sdk, + logger, + appInfoStore, + actions, + }); + + // Reconnect when necessary + useEffect(() => { + actions.connect({ + appId, + userId, + accessToken, + isUserIdUsedForNickname, + isMobile, + logger, + nickname, + profileUrl, + configureSession, + customApiHost, + customWebSocketHost, + sdkInitParams, + customExtensionParams, + initDashboardConfigs, + eventHandlers, + initializeMessageTemplatesInfo, + }); + }, [appId, userId]); + + // Disconnect on unmount + useUnmount(() => { + actions.disconnect({ logger }); + }); + + // to create a pubsub to communicate between parent and child + useEffect(() => { + setLogger(LoggerFactory(logLevel as LogLevel)); + }, [logLevel]); + + // should move to reducer + const [currentTheme, setCurrentTheme] = useState(theme); + useEffect(() => { + setCurrentTheme(theme); + }, [theme]); + + useEffect(() => { + const body = document.querySelector('body'); + body?.classList.remove('sendbird-experimental__rem__units'); + if (isREMUnitEnabled) { + body?.classList.add('sendbird-experimental__rem__units'); + } + }, [isREMUnitEnabled]); + // add-remove theme from body + useEffect(() => { + logger.info('Setup theme', `Theme: ${currentTheme}`); + try { + const body = document.querySelector('body'); + body?.classList.remove('sendbird-theme--light'); + body?.classList.remove('sendbird-theme--dark'); + body?.classList.add(`sendbird-theme--${currentTheme || 'light'}`); + logger.info('Finish setup theme'); + // eslint-disable-next-line no-empty + } catch (e) { + logger.warning('Setup theme failed', `${e}`); + } + return () => { + try { + const body = document.querySelector('body'); + body?.classList.remove('sendbird-theme--light'); + body?.classList.remove('sendbird-theme--dark'); + // eslint-disable-next-line no-empty + } catch { } + }; + }, [currentTheme]); + + useHTMLTextDirection(htmlTextDirection); + + const isOnline = useOnlineStatus(sdkStore.sdk, logger); + + const markAsReadScheduler = useMarkAsReadScheduler({ isConnected: isOnline }, { logger }); + const markAsDeliveredScheduler = useMarkAsDeliveredScheduler({ isConnected: isOnline }, { logger }); + + /** + * Feature Configuration - TODO + * This will be moved into the UIKitConfigProvider, aftering Dashboard applies + */ + const uikitMultipleFilesMessageLimit = useMemo(() => { + return Math.min(DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, multipleFilesMessageFileCountLimit ?? Number.MAX_SAFE_INTEGER); + }, [multipleFilesMessageFileCountLimit]); + + // Emoji Manager + const emojiManager = useMemo(() => { + return new EmojiManager({ + sdk, + logger, + }); + }, [sdkStore.initialized]); + + const uikitConfigs = useMemo(() => ({ + common: { + enableUsingDefaultUserProfile: configs.common.enableUsingDefaultUserProfile, + }, + groupChannel: { + enableOgtag: sdkInitialized && configsWithAppAttr(sdk).groupChannel.channel.enableOgtag, + enableTypingIndicator: configs.groupChannel.channel.enableTypingIndicator, + enableReactions: sdkInitialized && configsWithAppAttr(sdk).groupChannel.channel.enableReactions, + enableMention: configs.groupChannel.channel.enableMention, + replyType: configs.groupChannel.channel.replyType, + threadReplySelectType: configs.groupChannel.channel.threadReplySelectType, + enableVoiceMessage: configs.groupChannel.channel.enableVoiceMessage, + enableDocument: configs.groupChannel.channel.input.enableDocument, + typingIndicatorTypes: configs.groupChannel.channel.typingIndicatorTypes, + enableFeedback: configs.groupChannel.channel.enableFeedback, + enableSuggestedReplies: configs.groupChannel.channel.enableSuggestedReplies, + showSuggestedRepliesFor: configs.groupChannel.channel.showSuggestedRepliesFor, + suggestedRepliesDirection: configs.groupChannel.channel.suggestedRepliesDirection, + enableMarkdownForUserMessage: configs.groupChannel.channel.enableMarkdownForUserMessage, + enableFormTypeMessage: configs.groupChannel.channel.enableFormTypeMessage, + enableReactionsSupergroup: sdkInitialized && configsWithAppAttr(sdk).groupChannel.channel.enableReactionsSupergroup as never, + }, + groupChannelList: { + enableTypingIndicator: configs.groupChannel.channelList.enableTypingIndicator, + enableMessageReceiptStatus: configs.groupChannel.channelList.enableMessageReceiptStatus, + }, + groupChannelSettings: { + enableMessageSearch: sdkInitialized && configsWithAppAttr(sdk).groupChannel.setting.enableMessageSearch, + }, + openChannel: { + enableOgtag: sdkInitialized && configsWithAppAttr(sdk).openChannel.channel.enableOgtag, + enableDocument: configs.openChannel.channel.input.enableDocument, + }, + }), [ + sdkInitialized, + configs.common, + configs.groupChannel.channel, + configs.groupChannel.channelList, + configs.groupChannel.setting, + configs.openChannel.channel, + ]); + const storeState = useMemo(() => ({ + stores: { + sdkStore: state.stores.sdkStore, + userStore: state.stores.userStore, + appInfoStore: state.stores.appInfoStore, + }, + }), [ + state.stores.sdkStore, + state.stores.userStore, + state.stores.appInfoStore, + ]); + const uikitUploadSizeLimit = useMemo(() => (uploadSizeLimit ?? DEFAULT_UPLOAD_SIZE_LIMIT), [uploadSizeLimit, DEFAULT_UPLOAD_SIZE_LIMIT]); + const configImageCompression = useMemo(() => ({ + compressionRate: 0.7, + outputFormat: 'preserve', + ...imageCompression, + }), [imageCompression]); + const configVoiceRecord = useMemo(() => ({ + maxRecordingTime: voiceRecord?.maxRecordingTime ?? VOICE_RECORDER_DEFAULT_MAX, + minRecordingTime: voiceRecord?.minRecordingTime ?? VOICE_RECORDER_DEFAULT_MIN, + }), [ + voiceRecord?.maxRecordingTime, + voiceRecord?.minRecordingTime, + ]); + const configUserMention = useMemo(() => ({ + maxMentionCount: userMention?.maxMentionCount || 10, + maxSuggestionCount: userMention?.maxSuggestionCount || 15, + }), [ + userMention?.maxMentionCount, + userMention?.maxSuggestionCount, + ]); + const deprecatedConfigs = useMemo(() => ({ + disableUserProfile: !configs.common.enableUsingDefaultUserProfile, + isReactionEnabled: sdkInitialized && configsWithAppAttr(sdk).groupChannel.channel.enableReactions, + isMentionEnabled: configs.groupChannel.channel.enableMention, + isVoiceMessageEnabled: configs.groupChannel.channel.enableVoiceMessage, + replyType: getCaseResolvedReplyType(configs.groupChannel.channel.replyType).upperCase, + isTypingIndicatorEnabledOnChannelList: configs.groupChannel.channelList.enableTypingIndicator, + isMessageReceiptStatusEnabledOnChannelList: configs.groupChannel.channelList.enableMessageReceiptStatus, + showSearchIcon: sdkInitialized && configsWithAppAttr(sdk).groupChannel.setting.enableMessageSearch, + }), [ + sdkInitialized, + configsWithAppAttr, + configs.common.enableUsingDefaultUserProfile, + configs.groupChannel.channel.enableReactions, + configs.groupChannel.channel.enableMention, + configs.groupChannel.channel.enableVoiceMessage, + configs.groupChannel.channel.replyType, + configs.groupChannel.channelList.enableTypingIndicator, + configs.groupChannel.channelList.enableMessageReceiptStatus, + configs.groupChannel.setting.enableMessageSearch, + ]); + const configState = useMemo>(() => ({ + config: { + disableMarkAsDelivered, + renderUserProfile, + onStartDirectMessage, + onUserProfileMessage: onStartDirectMessage, // legacy of onStartDirectMessage + allowProfileEdit, + isOnline, + userId, + appId, + accessToken, + theme: currentTheme, + setCurrentTheme, + setCurrenttheme: setCurrentTheme, // deprecated: typo + isMultipleFilesMessageEnabled, + uikitMultipleFilesMessageLimit, + logger, + pubSub, + userListQuery, + htmlTextDirection, + forceLeftToRightMessageLayout, + markAsReadScheduler, + markAsDeliveredScheduler, + uikitUploadSizeLimit, + imageCompression: configImageCompression, + voiceRecord: configVoiceRecord, + userMention: configUserMention, + // Remote configs set from dashboard by UIKit feature configuration + ...uikitConfigs, + ...deprecatedConfigs, + }, + }), [ + disableMarkAsDelivered, + renderUserProfile, + onStartDirectMessage, + allowProfileEdit, + isOnline, + userId, + appId, + accessToken, + currentTheme, + setCurrentTheme, + isMultipleFilesMessageEnabled, + uikitMultipleFilesMessageLimit, + logger, + pubSub, + userListQuery, + htmlTextDirection, + forceLeftToRightMessageLayout, + markAsReadScheduler, + markAsDeliveredScheduler, + uikitUploadSizeLimit, + configImageCompression, + configVoiceRecord, + configUserMention, + uikitConfigs, + deprecatedConfigs, + ]); + const utilsState = useMemo(() => ({ + utils: { + updateMessageTemplatesInfo, + getCachedTemplate, + }, + }), [ + updateMessageTemplatesInfo, + getCachedTemplate, + ]); + + useEffect(() => { + updateState({ + ...storeState, + ...utilsState, + ...configState, + eventHandlers, + emojiManager, + }); + }, [ + storeState, + configState, + eventHandlers, + emojiManager, + utilsState, + ]); + + return null; +}; + +const InternalSendbirdProvider = ({ children, stringSet, breakpoint, dateLocale }) => { + const storeRef = useRef(createStore(initialState)); + const localeStringSet = useMemo(() => { + return { ...getStringSet('en'), ...stringSet }; + }, [stringSet]); + + return ( + + + + + + {children} + + + + + {/* Roots */} + + + + + ); +}; + +export const SendbirdContextProvider = (props: SendbirdProviderProps) => { + const { children } = props; + + return ( + + + {children} + + ); +}; + +export default SendbirdContextProvider; diff --git a/src/lib/Sendbird/context/hooks/useSendbird.tsx b/src/lib/Sendbird/context/hooks/useSendbird.tsx new file mode 100644 index 0000000000..908242fe32 --- /dev/null +++ b/src/lib/Sendbird/context/hooks/useSendbird.tsx @@ -0,0 +1,232 @@ +import { useContext, useMemo, useSyncExternalStore } from 'react'; +import { SendbirdError, User } from '@sendbird/chat'; + +import { SendbirdContext } from '../SendbirdContext'; +import { LoggerInterface } from '../../../Logger'; +import { MessageTemplatesInfo, SendbirdState, WaitingTemplateKeyData } from '../../types'; +import { initSDK, setupSDK, updateAppInfoStore, updateSdkStore, updateUserStore } from '../../utils'; + +const NO_CONTEXT_ERROR = 'No sendbird state value available. Make sure you are rendering `` at the top of your app.'; +export const useSendbird = () => { + const store = useContext(SendbirdContext); + if (!store) throw new Error(NO_CONTEXT_ERROR); + + const state = useSyncExternalStore(store.subscribe, store.getState); + const actions = useMemo(() => ({ + /* Example: How to set the state basically */ + // exampleAction: () => { + // store.setState((state): SendbirdState => ({ + // ...state, + // example: true, + // })), + // }, + + /* AppInfo */ + initMessageTemplateInfo: ({ payload }: { payload: MessageTemplatesInfo }) => { + store.setState((state): SendbirdState => ( + updateAppInfoStore(state, { + messageTemplatesInfo: payload, + waitingTemplateKeysMap: {}, + }) + )); + }, + upsertMessageTemplates: ({ payload }) => { + const appInfoStore = state.stores.appInfoStore; + const templatesInfo = appInfoStore.messageTemplatesInfo; + if (!templatesInfo) return state; // Not initialized. Ignore. + + const waitingTemplateKeysMap = { ...appInfoStore.waitingTemplateKeysMap }; + payload.forEach((templatesMapData) => { + const { key, template } = templatesMapData; + templatesInfo.templatesMap[key] = template; + delete waitingTemplateKeysMap[key]; + }); + store.setState((state): SendbirdState => ( + updateAppInfoStore(state, { + waitingTemplateKeysMap, + messageTemplatesInfo: templatesInfo, + }) + )); + }, + upsertWaitingTemplateKeys: ({ payload }) => { + const appInfoStore = state.stores.appInfoStore; + const { keys, requestedAt } = payload; + const waitingTemplateKeysMap = { ...appInfoStore.waitingTemplateKeysMap }; + keys.forEach((key) => { + waitingTemplateKeysMap[key] = { + erroredMessageIds: waitingTemplateKeysMap[key]?.erroredMessageIds ?? [], + requestedAt, + }; + }); + store.setState((state): SendbirdState => ( + updateAppInfoStore(state, { + waitingTemplateKeysMap, + }) + )); + }, + markErrorWaitingTemplateKeys: ({ payload }) => { + const appInfoStore = state.stores.appInfoStore; + const { keys, messageId } = payload; + const waitingTemplateKeysMap = { ...appInfoStore.waitingTemplateKeysMap }; + keys.forEach((key) => { + const waitingTemplateKeyData: WaitingTemplateKeyData | undefined = waitingTemplateKeysMap[key]; + if (waitingTemplateKeyData && waitingTemplateKeyData.erroredMessageIds.indexOf(messageId) === -1) { + waitingTemplateKeyData.erroredMessageIds.push(messageId); + } + }); + store.setState((state): SendbirdState => ( + updateAppInfoStore(state, { + waitingTemplateKeysMap, + }) + )); + }, + + /* SDK */ + setSdkLoading: (payload) => { + store.setState((state): SendbirdState => ( + updateSdkStore(state, { + initialized: false, + loading: payload, + }) + )); + }, + sdkError: () => { + store.setState((state): SendbirdState => ( + updateSdkStore(state, { + initialized: false, + loading: false, + error: true, + }) + )); + }, + initSdk: (payload) => { + store.setState((state): SendbirdState => ( + updateSdkStore(state, { + sdk: payload, + initialized: true, + loading: false, + error: false, + }) + )); + }, + resetSdk: () => { + store.setState((state): SendbirdState => ( + updateSdkStore(state, { + sdk: null, + initialized: false, + loading: false, + error: false, + }) + )); + }, + + /* User */ + initUser: (payload) => { + store.setState((state): SendbirdState => ( + updateUserStore(state, { + initialized: true, + loading: false, + user: payload, + }) + )); + }, + resetUser: () => { + store.setState((state): SendbirdState => ( + updateUserStore(state, { + initialized: false, + loading: false, + user: null, + }) + )); + }, + updateUserInfo: (payload: User) => { + store.setState((state): SendbirdState => ( + updateUserStore(state, { + user: payload, + }) + )); + }, + + /* Connection */ + connect: async (params) => { + const { + logger, + userId, + appId, + accessToken, + nickname, + profileUrl, + isMobile, + sdkInitParams, + customApiHost, + customWebSocketHost, + customExtensionParams, + eventHandlers, + initializeMessageTemplatesInfo, + configureSession, + initDashboardConfigs, + } = params; + + // clean up previous ws connection + await actions.disconnect({ logger }); + + const sdk = initSDK({ + appId, + customApiHost, + customWebSocketHost, + sdkInitParams, + }); + + setupSDK(sdk, { + logger, + isMobile, + customExtensionParams, + sessionHandler: configureSession ? configureSession(sdk) : undefined, + }); + + actions.setSdkLoading(true); + + try { + const user = await sdk.connect(userId, accessToken); + actions.initUser(user); + + if (nickname || profileUrl) { + await sdk.updateCurrentUserInfo({ + nickname: nickname || user.nickname || '', + profileUrl: profileUrl || user.profileUrl, + }); + } + + await initializeMessageTemplatesInfo?.(sdk); + await initDashboardConfigs?.(sdk); + + actions.initSdk(sdk); + + eventHandlers?.connection?.onConnected?.(user); + } catch (error) { + const sendbirdError = error as SendbirdError; + actions.resetSdk(); + actions.resetUser(); + logger.error?.('SendbirdProvider | useSendbird/connect failed', sendbirdError); + eventHandlers?.connection?.onFailed?.(sendbirdError); + } + }, + disconnect: async ({ logger }: { logger: LoggerInterface }) => { + actions.setSdkLoading(true); + + const sdk = state.stores.sdkStore.sdk; + + if (sdk?.disconnectWebSocket) { + await sdk.disconnectWebSocket(); + } + + actions.resetSdk(); + actions.resetUser(); + logger.info?.('SendbirdProvider | useSendbird/disconnect completed'); + }, + }), [store]); + + return { state, actions }; +}; + +export default useSendbird; diff --git a/src/lib/Sendbird/context/hooks/useSendbirdStateContext.tsx b/src/lib/Sendbird/context/hooks/useSendbirdStateContext.tsx new file mode 100644 index 0000000000..145a104722 --- /dev/null +++ b/src/lib/Sendbird/context/hooks/useSendbirdStateContext.tsx @@ -0,0 +1,12 @@ +import { SendbirdState } from '../../types'; +import useSendbird from './useSendbird'; + +// NOTE: Do not use this hook internally. +// This hook is exported to support backward compatibility. +// Keep this function for backward compatibility. +export function useSendbirdStateContext(): SendbirdState { + const { state, actions } = useSendbird(); + return { ...state, ...actions }; +} + +export default useSendbirdStateContext; diff --git a/src/lib/Sendbird/context/initialState.ts b/src/lib/Sendbird/context/initialState.ts new file mode 100644 index 0000000000..74812b544a --- /dev/null +++ b/src/lib/Sendbird/context/initialState.ts @@ -0,0 +1,133 @@ +import type { SendbirdState, SendbirdStateConfig, ReplyType, SendbirdStateStore, SdkStore } from '../types'; +import { + DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, + DEFAULT_UPLOAD_SIZE_LIMIT, +} from '../../../utils/consts'; +import { User } from '@sendbird/chat'; + +/** + * Config + */ +const deprecatedConfig = { + onUserProfileMessage: undefined, + disableUserProfile: false, + isReactionEnabled: true, + isMentionEnabled: false, + isVoiceMessageEnabled: true, + replyType: 'NONE' as ReplyType, + showSearchIcon: true, + isTypingIndicatorEnabledOnChannelList: false, + isMessageReceiptStatusEnabledOnChannelList: false, + setCurrenttheme: () => {}, +}; +const config: SendbirdStateConfig = { + ...deprecatedConfig, + // Connection + appId: '', + userId: '', + accessToken: undefined, + theme: 'light', + isOnline: false, + // High level options + allowProfileEdit: true, + forceLeftToRightMessageLayout: false, + disableMarkAsDelivered: false, + isMultipleFilesMessageEnabled: false, + htmlTextDirection: 'ltr', + uikitUploadSizeLimit: DEFAULT_UPLOAD_SIZE_LIMIT, + uikitMultipleFilesMessageLimit: DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, + imageCompression: undefined, + voiceRecord: undefined, + userMention: undefined, + // Functions + renderUserProfile: undefined, + onStartDirectMessage: undefined, + setCurrentTheme: undefined, + userListQuery: undefined, + // Utils + pubSub: undefined, + logger: undefined, + markAsReadScheduler: undefined, + markAsDeliveredScheduler: undefined, + // UIKit Configs + common: { + enableUsingDefaultUserProfile: false, + }, + groupChannel: { + enableOgtag: true, + enableTypingIndicator: true, + enableReactions: true, + enableMention: false, + replyType: 'none', + threadReplySelectType: 'thread', + enableVoiceMessage: true, + typingIndicatorTypes: undefined, + enableDocument: false, + enableFeedback: false, + enableSuggestedReplies: false, + showSuggestedRepliesFor: 'all_messages', + suggestedRepliesDirection: 'vertical', + enableMarkdownForUserMessage: false, + enableFormTypeMessage: false, + enableReactionsSupergroup: undefined as never, // @deprecated + }, + groupChannelList: { + enableTypingIndicator: false, + enableMessageReceiptStatus: false, + }, + groupChannelSettings: { + enableMessageSearch: false, + }, + openChannel: { + enableOgtag: true, + enableDocument: false, + }, +}; + +/** + * Stores + */ +const stores: SendbirdStateStore = { + sdkStore: { + sdk: {} as SdkStore['sdk'], + initialized: false, + loading: false, + error: undefined, + }, + userStore: { + user: {} as User, + initialized: false, + loading: false, + }, + appInfoStore: { + messageTemplatesInfo: undefined, + waitingTemplateKeysMap: {}, + }, +}; + +export const initialState: SendbirdState = { + config, + stores, + emojiManager: undefined, + eventHandlers: { + reaction: { + onPressUserProfile: () => {}, + }, + connection: { + onConnected: () => {}, + onFailed: () => {}, + }, + modal: { + onMounted: () => {}, + }, + message: { + onSendMessageFailed: () => {}, + onUpdateMessageFailed: () => {}, + onFileUploadFailed: () => {}, + }, + }, + utils: { + updateMessageTemplatesInfo: () => new Promise(() => {}), + getCachedTemplate: () => null, + }, +}; diff --git a/src/lib/Sendbird/index.scss b/src/lib/Sendbird/index.scss new file mode 100644 index 0000000000..99ebdc01f6 --- /dev/null +++ b/src/lib/Sendbird/index.scss @@ -0,0 +1,6 @@ +// Too keep all the important CSS to boot and makes sure things arent repeated +@import url('https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap'); +@import '../../styles/light-theme'; +@import '../../styles/dark-theme'; +@import '../../styles/misc-colors'; +@import '../../styles/postcss-rtl'; diff --git a/src/lib/Sendbird/index.tsx b/src/lib/Sendbird/index.tsx new file mode 100644 index 0000000000..ef8d1fe57a --- /dev/null +++ b/src/lib/Sendbird/index.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import './index.scss'; +import './__experimental__typography.scss'; + +import { UIKitConfigProvider } from '@sendbird/uikit-tools'; + +import type { SendbirdProviderProps, UIKitOptions } from './types'; +import { uikitConfigMapper } from '../utils/uikitConfigMapper'; +import { uikitConfigStorage } from '../utils/uikitConfigStorage'; +import { SendbirdContextProvider } from './context/SendbirdProvider'; +import useSendbird from './context/hooks/useSendbird'; + +export type { SendbirdProviderProps } from './types'; + +// For Exportation +export const SendbirdProvider = (props: SendbirdProviderProps) => { + const localConfigs: UIKitOptions = uikitConfigMapper({ + legacyConfig: { + replyType: props.replyType, + isMentionEnabled: props.isMentionEnabled, + isReactionEnabled: props.isReactionEnabled, + disableUserProfile: props.disableUserProfile, + isVoiceMessageEnabled: props.isVoiceMessageEnabled, + isTypingIndicatorEnabledOnChannelList: props.isTypingIndicatorEnabledOnChannelList, + isMessageReceiptStatusEnabledOnChannelList: props.isMessageReceiptStatusEnabledOnChannelList, + showSearchIcon: props.showSearchIcon, + }, + uikitOptions: props.uikitOptions, + }); + + return ( + + + + ); +}; + +type ContextAwareComponentType = { + (props: any): JSX.Element; + displayName: string; +}; +type PropsType = Record; +const withSendbirdContext = (OriginalComponent: any, mapStoreToProps: (props: any) => PropsType): ContextAwareComponentType => { + const ContextAwareComponent = (props) => { + const { state, actions } = useSendbird(); + const context = { ...state, ...actions }; + if (mapStoreToProps && typeof mapStoreToProps !== 'function') { + // eslint-disable-next-line no-console + console.warn('Second parameter to withSendbirdContext must be a pure function'); + } + const mergedProps = (mapStoreToProps && typeof mapStoreToProps === 'function') + ? { ...mapStoreToProps(context), ...props } + : { ...context, ...props }; + return <> + + ; + }; + + const componentName = OriginalComponent.displayName || OriginalComponent.name || 'Component'; + ContextAwareComponent.displayName = `SendbirdAware${componentName}`; + + return ContextAwareComponent; +}; +/** + * @deprecated This function is deprecated. Use `useSendbirdStateContext` instead. + * */ +export const withSendBird = withSendbirdContext; + +export default SendbirdProvider; diff --git a/src/lib/Sendbird/types.ts b/src/lib/Sendbird/types.ts new file mode 100644 index 0000000000..5e1bf98549 --- /dev/null +++ b/src/lib/Sendbird/types.ts @@ -0,0 +1,439 @@ +// src/lib/Sendbird/types.ts + +import React, { MutableRefObject } from 'react'; +import type SendbirdChat from '@sendbird/chat'; +import type { + User, + SendbirdChatParams, + SendbirdError, + SessionHandler, +} from '@sendbird/chat'; +import type { + GroupChannel, + GroupChannelCreateParams, + GroupChannelModule, + Member, + SendbirdGroupChat, +} from '@sendbird/chat/groupChannel'; +import type { + OpenChannel, + OpenChannelCreateParams, + OpenChannelModule, + SendbirdOpenChat, +} from '@sendbird/chat/openChannel'; +import type { + FileMessageCreateParams, + UserMessage, + UserMessageCreateParams, + UserMessageUpdateParams, +} from '@sendbird/chat/message'; +import { Module, ModuleNamespaces } from '@sendbird/chat/lib/__definition'; +import { SBUConfig } from '@sendbird/uikit-tools'; + +import { PartialDeep } from '../../utils/typeHelpers/partialDeep'; +import { CoreMessageType } from '../../utils'; +import { LoggerInterface } from '../Logger'; +import { MarkAsReadSchedulerType } from '../hooks/useMarkAsReadScheduler'; +import { MarkAsDeliveredSchedulerType } from '../hooks/useMarkAsDeliveredScheduler'; +import { SBUGlobalPubSub } from '../pubSub/topics'; +import { EmojiManager } from '../emojiManager'; +import { StringSet } from '../../ui/Label/stringSet'; + +/* -------------------------------------------------------------------------- */ +/* Legacy */ +/* -------------------------------------------------------------------------- */ + +export type ReplyType = 'NONE' | 'QUOTE_REPLY' | 'THREAD'; +export type ConfigureSessionTypes = (sdk: SendbirdChat | SendbirdGroupChat | SendbirdOpenChat) => SessionHandler; +// Sendbird state dispatcher +export type CustomUseReducerDispatcher = React.Dispatch<{ + type: string; + payload: any; +}>; + +/* -------------------------------------------------------------------------- */ +/* Common Types */ +/* -------------------------------------------------------------------------- */ + +// Image compression settings +export type ImageCompressionOutputFormatType = 'preserve' | 'png' | 'jpeg'; + +export interface ImageCompressionOptions { + compressionRate?: number; + resizingWidth?: number | string; + resizingHeight?: number | string; + outputFormat?: ImageCompressionOutputFormatType; +} + +// Logger type +export type Logger = LoggerInterface; + +// Roles for a user in a channel +export const Role = { + OPERATOR: 'operator', + NONE: 'none', +} as const; + +export type RoleType = typeof Role[keyof typeof Role]; + +export type HTMLTextDirection = 'ltr' | 'rtl'; + +export interface RenderUserProfileProps { + user: User | Member; + currentUserId: string; + close(): void; + avatarRef: MutableRefObject; +} + +export interface UserListQuery { + hasNext?: boolean; + next(): Promise>; + get isLoading(): boolean; +} + +/* -------------------------------------------------------------------------- */ +/* Stores */ +/* -------------------------------------------------------------------------- */ + +// AppInfo +export interface MessageTemplatesInfo { + token: string; // This server-side token gets updated on every CRUD operation on message template table. + templatesMap: Record; +} + +export interface WaitingTemplateKeyData { + requestedAt: number; + erroredMessageIds: number[]; +} + +export type ProcessedMessageTemplate = { + version: number; + uiTemplate: string; // This is stringified ui_template.body.items + colorVariables?: Record; +}; + +export interface AppInfoStateType { + messageTemplatesInfo?: MessageTemplatesInfo; + /** + * This represents template keys that are currently waiting for its fetch response. + * Whenever initialized, request succeeds or fails, it needs to be updated. + */ + waitingTemplateKeysMap: Record; +} + +/* -------------------------------------------------------------------------- */ +/* Event Handlers Types */ +/* -------------------------------------------------------------------------- */ + +export interface SBUEventHandlers { + reaction?: { + onPressUserProfile?(member: User): void; + }; + connection?: { + onConnected?(user: User): void; + onFailed?(error: SendbirdError): void; + }; + modal?: { + onMounted?(params: { id: string; close(): void }): void | (() => void); + }; + message?: { + onSendMessageFailed?: (message: CoreMessageType, error: unknown) => void; + onUpdateMessageFailed?: (message: CoreMessageType, error: unknown) => void; + onFileUploadFailed?: (error: unknown) => void; + }; +} + +/* -------------------------------------------------------------------------- */ +/* Sendbird State Types */ +/* -------------------------------------------------------------------------- */ + +interface VoiceRecordOptions { + maxRecordingTime?: number; + minRecordingTime?: number; +} + +export interface SendbirdConfig { + logLevel?: string | Array; + pubSub?: SBUGlobalPubSub; + userMention?: { + maxMentionCount?: number; + maxSuggestionCount?: number; + }; + isREMUnitEnabled?: boolean; +} + +export interface CommonUIKitConfigProps { + /** @deprecated Please use `uikitOptions.common.enableUsingDefaultUserProfile` instead * */ + disableUserProfile?: boolean; + /** @deprecated Please use `uikitOptions.groupChannel.replyType` instead * */ + replyType?: 'NONE' | 'QUOTE_REPLY' | 'THREAD'; + /** @deprecated Please use `uikitOptions.groupChannel.enableReactions` instead * */ + isReactionEnabled?: boolean; + /** @deprecated Please use `uikitOptions.groupChannel.enableMention` instead * */ + isMentionEnabled?: boolean; + /** @deprecated Please use `uikitOptions.groupChannel.enableVoiceMessage` instead * */ + isVoiceMessageEnabled?: boolean; + /** @deprecated Please use `uikitOptions.groupChannelList.enableTypingIndicator` instead * */ + isTypingIndicatorEnabledOnChannelList?: boolean; + /** @deprecated Please use `uikitOptions.groupChannelList.enableMessageReceiptStatus` instead * */ + isMessageReceiptStatusEnabledOnChannelList?: boolean; + /** @deprecated Please use `uikitOptions.groupChannelSettings.enableMessageSearch` instead * */ + showSearchIcon?: boolean; +} +export type SendbirdChatInitParams = Omit, 'appId'>; +export type CustomExtensionParams = Record; + +export type UIKitOptions = PartialDeep<{ + common: SBUConfig['common']; + groupChannel: SBUConfig['groupChannel']['channel']; + groupChannelList: SBUConfig['groupChannel']['channelList']; + groupChannelSettings: SBUConfig['groupChannel']['setting']; + openChannel: SBUConfig['openChannel']['channel']; +}>; + +export interface SendbirdProviderProps extends CommonUIKitConfigProps, React.PropsWithChildren { + appId: string; + userId: string; + accessToken?: string; + customApiHost?: string; + customWebSocketHost?: string; + configureSession?: ConfigureSessionTypes; + theme?: 'light' | 'dark'; + config?: SendbirdConfig; + nickname?: string; + colorSet?: Record; + stringSet?: Partial; + dateLocale?: Locale; + profileUrl?: string; + voiceRecord?: VoiceRecordOptions; + userListQuery?: () => UserListQuery; + imageCompression?: ImageCompressionOptions; + allowProfileEdit?: boolean; + disableMarkAsDelivered?: boolean; + breakpoint?: string | boolean; + htmlTextDirection?: HTMLTextDirection; + forceLeftToRightMessageLayout?: boolean; + uikitOptions?: UIKitOptions; + isUserIdUsedForNickname?: boolean; + sdkInitParams?: SendbirdChatInitParams; + customExtensionParams?: CustomExtensionParams; + isMultipleFilesMessageEnabled?: boolean; + // UserProfile + renderUserProfile?: (props: RenderUserProfileProps) => React.ReactElement; + onStartDirectMessage?: (channel: GroupChannel) => void; + /** + * @deprecated Please use `onStartDirectMessage` instead. It's renamed. + */ + onUserProfileMessage?: (channel: GroupChannel) => void; + + // Customer provided callbacks + eventHandlers?: SBUEventHandlers; +} + +export interface SendbirdStateConfig { + renderUserProfile?: (props: RenderUserProfileProps) => React.ReactElement; + onStartDirectMessage?: (props: GroupChannel) => void; + allowProfileEdit: boolean; + isOnline: boolean; + userId: string; + appId: string; + accessToken?: string; + theme: string; + htmlTextDirection: HTMLTextDirection; + forceLeftToRightMessageLayout: boolean; + pubSub: SBUGlobalPubSub; + logger: Logger; + setCurrentTheme: (theme: 'light' | 'dark') => void; + userListQuery?: () => UserListQuery; + uikitUploadSizeLimit: number; + uikitMultipleFilesMessageLimit: number; + voiceRecord: { + maxRecordingTime: number; + minRecordingTime: number; + }; + userMention: { + maxMentionCount: number, + maxSuggestionCount: number, + }; + imageCompression: ImageCompressionOptions; + markAsReadScheduler: MarkAsReadSchedulerType; + markAsDeliveredScheduler: MarkAsDeliveredSchedulerType; + disableMarkAsDelivered: boolean; + isMultipleFilesMessageEnabled: boolean; + // Remote configs set from dashboard by UIKit feature configuration + common: { + enableUsingDefaultUserProfile: SBUConfig['common']['enableUsingDefaultUserProfile']; + }, + groupChannel: { + enableOgtag: SBUConfig['groupChannel']['channel']['enableOgtag']; + enableTypingIndicator: SBUConfig['groupChannel']['channel']['enableTypingIndicator']; + enableReactions: SBUConfig['groupChannel']['channel']['enableReactions']; + enableMention: SBUConfig['groupChannel']['channel']['enableMention']; + replyType: SBUConfig['groupChannel']['channel']['replyType']; + threadReplySelectType: SBUConfig['groupChannel']['channel']['threadReplySelectType']; + enableVoiceMessage: SBUConfig['groupChannel']['channel']['enableVoiceMessage']; + typingIndicatorTypes: SBUConfig['groupChannel']['channel']['typingIndicatorTypes']; + enableDocument: SBUConfig['groupChannel']['channel']['input']['enableDocument']; + enableFeedback: SBUConfig['groupChannel']['channel']['enableFeedback']; + enableSuggestedReplies: SBUConfig['groupChannel']['channel']['enableSuggestedReplies']; + showSuggestedRepliesFor: SBUConfig['groupChannel']['channel']['showSuggestedRepliesFor']; + suggestedRepliesDirection: SBUConfig['groupChannel']['channel']['suggestedRepliesDirection']; + enableMarkdownForUserMessage: SBUConfig['groupChannel']['channel']['enableMarkdownForUserMessage']; + enableFormTypeMessage: SBUConfig['groupChannel']['channel']['enableFormTypeMessage']; + /** + * @deprecated Currently, this feature is turned off by default. If you wish to use this feature, contact us: {@link https://dashboard.sendbird.com/settings/contact_us?category=feedback_and_feature_requests&product=UIKit} + */ + enableReactionsSupergroup: never; + }, + groupChannelList: { + enableTypingIndicator: SBUConfig['groupChannel']['channelList']['enableTypingIndicator']; + enableMessageReceiptStatus: SBUConfig['groupChannel']['channelList']['enableMessageReceiptStatus']; + }, + groupChannelSettings: { + enableMessageSearch: SBUConfig['groupChannel']['setting']['enableMessageSearch']; + }, + openChannel: { + enableOgtag: SBUConfig['openChannel']['channel']['enableOgtag']; + enableDocument: SBUConfig['openChannel']['channel']['input']['enableDocument']; + }, + /** + * @deprecated Please use `onStartDirectMessage` instead. It's renamed. + */ + onUserProfileMessage?: (props: GroupChannel) => void; + /** + * @deprecated Please use `!config.common.enableUsingDefaultUserProfile` instead. + * Note that you should use the negation of `config.common.enableUsingDefaultUserProfile` + * to replace `disableUserProfile`. + */ + disableUserProfile: boolean; + /** @deprecated Please use `config.groupChannel.enableReactions` instead * */ + isReactionEnabled: boolean; + /** @deprecated Please use `config.groupChannel.enableMention` instead * */ + isMentionEnabled: boolean; + /** @deprecated Please use `config.groupChannel.enableVoiceMessage` instead * */ + isVoiceMessageEnabled?: boolean; + /** @deprecated Please use `config.groupChannel.replyType` instead * */ + replyType: ReplyType; + /** @deprecated Please use `config.groupChannelSettings.enableMessageSearch` instead * */ + showSearchIcon?: boolean; + /** @deprecated Please use `config.groupChannelList.enableTypingIndicator` instead * */ + isTypingIndicatorEnabledOnChannelList?: boolean; + /** @deprecated Please use `config.groupChannelList.enableMessageReceiptStatus` instead * */ + isMessageReceiptStatusEnabledOnChannelList?: boolean; + /** @deprecated Please use setCurrentTheme instead * */ + setCurrenttheme: (theme: 'light' | 'dark') => void; +} + +export type SendbirdChatType = SendbirdChat & ModuleNamespaces<[GroupChannelModule, OpenChannelModule]>; + +export interface SdkStore { + error: boolean; + initialized: boolean; + loading: boolean; + sdk: SendbirdChat & ModuleNamespaces<[GroupChannelModule, OpenChannelModule]>; +} + +export interface UserStore { + initialized: boolean; + loading: boolean; + user: User; +} + +export interface AppInfoStore { + messageTemplatesInfo?: MessageTemplatesInfo; + /** + * This represents template keys that are currently waiting for its fetch response. + * Whenever initialized, request succeeds or fails, it needs to be updated. + */ + waitingTemplateKeysMap: Record; +} + +export interface SendbirdStateStore { + sdkStore: SdkStore; + userStore: UserStore; + appInfoStore: AppInfoStore; +} + +export type SendbirdState = { + config: SendbirdStateConfig; + stores: SendbirdStateStore; + // dispatchers: { + // sdkDispatcher: React.Dispatch, + // userDispatcher: React.Dispatch, + // appInfoDispatcher: React.Dispatch, + // reconnect: ReconnectType, + // }, + // Customer provided callbacks + eventHandlers?: SBUEventHandlers; + emojiManager: EmojiManager; + utils: SendbirdProviderUtils; +}; + +/* -------------------------------------------------------------------------- */ +/* Utility Types */ +/* -------------------------------------------------------------------------- */ + +export interface SendbirdProviderUtils { + updateMessageTemplatesInfo: ( + templateKeys: string[], + messageId: number, + createdAt: number + ) => Promise; + getCachedTemplate: (key: string) => ProcessedMessageTemplate | null; +} + +// Selectors for state access +export interface sendbirdSelectorsInterface { + getSdk: (store: SendbirdState) => SendbirdChat | undefined; + getConnect: (store: SendbirdState) => (userId: string, accessToken?: string) => Promise; + getDisconnect: (store: SendbirdState) => () => Promise; + getUpdateUserInfo: ( + store: SendbirdState + ) => (nickName: string, profileUrl?: string) => Promise; + getCreateGroupChannel: ( + store: SendbirdState + ) => (channelParams: GroupChannelCreateParams) => Promise; + getCreateOpenChannel: ( + store: SendbirdState + ) => (channelParams: OpenChannelCreateParams) => Promise; + getGetGroupChannel: ( + store: SendbirdState + ) => (channelUrl: string, isSelected?: boolean) => Promise; + getGetOpenChannel: ( + store: SendbirdState + ) => (channelUrl: string) => Promise; + getLeaveGroupChannel: ( + store: SendbirdState + ) => (channel: GroupChannel) => Promise; + getEnterOpenChannel: ( + store: SendbirdState + ) => (channel: OpenChannel) => Promise; + getExitOpenChannel: ( + store: SendbirdState + ) => (channel: OpenChannel) => Promise; + getFreezeChannel: ( + store: SendbirdState + ) => (channel: GroupChannel | OpenChannel) => Promise; + getUnFreezeChannel: ( + store: SendbirdState + ) => (channel: GroupChannel | OpenChannel) => Promise; + getSendUserMessage: ( + store: SendbirdState + ) => ( + channel: GroupChannel | OpenChannel, + userMessageParams: UserMessageCreateParams + ) => any; // Replace with specific type + getSendFileMessage: ( + store: SendbirdState + ) => ( + channel: GroupChannel | OpenChannel, + fileMessageParams: FileMessageCreateParams + ) => any; // Replace with specific type + getUpdateUserMessage: ( + store: SendbirdState + ) => ( + channel: GroupChannel | OpenChannel, + messageId: string | number, + params: UserMessageUpdateParams + ) => Promise; +} diff --git a/src/lib/Sendbird/utils.ts b/src/lib/Sendbird/utils.ts new file mode 100644 index 0000000000..c708e1aaf0 --- /dev/null +++ b/src/lib/Sendbird/utils.ts @@ -0,0 +1,93 @@ +import SendbirdChat, { DeviceOsPlatform, SendbirdChatWith, SendbirdPlatform, SendbirdProduct, SessionHandler } from '@sendbird/chat'; +import { GroupChannelModule } from '@sendbird/chat/groupChannel'; +import { OpenChannelModule } from '@sendbird/chat/openChannel'; + +import type { AppInfoStore, CustomExtensionParams, SdkStore, SendbirdState, UserStore } from './types'; +import { LoggerInterface } from '../Logger'; + +type UpdateAppInfoStoreType = (state: SendbirdState, payload: AppInfoStore) => SendbirdState; +export const updateAppInfoStore: UpdateAppInfoStoreType = (state, payload) => { + return { + ...state, + stores: { + ...state.stores, + appInfoStore: { + ...state.stores.appInfoStore, + ...payload, + }, + }, + }; +}; +type UpdateSdkStoreType = (state: SendbirdState, payload: Partial) => SendbirdState; +export const updateSdkStore: UpdateSdkStoreType = (state, payload) => { + return { + ...state, + stores: { + ...state.stores, + sdkStore: { + ...state.stores.sdkStore, + ...payload, + }, + }, + }; +}; +type UpdateUserStoreType = (state: SendbirdState, payload: Partial) => SendbirdState; +export const updateUserStore: UpdateUserStoreType = (state, payload) => { + return { + ...state, + stores: { + ...state.stores, + userStore: { + ...state.stores.userStore, + ...payload, + }, + }, + }; +}; + +export function initSDK({ + appId, + customApiHost, + customWebSocketHost, + sdkInitParams = {}, +}: { + appId: string; + customApiHost?: string; + customWebSocketHost?: string; + sdkInitParams?: Record; +}) { + const params = Object.assign(sdkInitParams, { + appId, + modules: [new GroupChannelModule(), new OpenChannelModule()], + // newInstance: isNewApp, + localCacheEnabled: true, + }); + + if (customApiHost) params.customApiHost = customApiHost; + if (customWebSocketHost) params.customWebSocketHost = customWebSocketHost; + return SendbirdChat.init(params); +} + +const APP_VERSION_STRING = '__react_dev_mode__'; +/** + * Sets up the Sendbird SDK after initialization. + * Configures necessary settings, adds extensions, sets the platform, and configures the session handler if provided. + */ +export function setupSDK( + sdk: SendbirdChatWith<[GroupChannelModule, OpenChannelModule]>, + params: { logger: LoggerInterface; sessionHandler?: SessionHandler; isMobile?: boolean; customExtensionParams?: CustomExtensionParams }, +) { + const { logger, sessionHandler, isMobile, customExtensionParams } = params; + + logger.info?.('SendbirdProvider | useConnect/setupConnection/setVersion', { version: APP_VERSION_STRING }); + sdk.addExtension('sb_uikit', APP_VERSION_STRING); + sdk.addSendbirdExtensions( + [{ product: SendbirdProduct.UIKIT_CHAT, version: APP_VERSION_STRING, platform: SendbirdPlatform?.JS }], + { platform: isMobile ? DeviceOsPlatform.MOBILE_WEB : DeviceOsPlatform.WEB }, + customExtensionParams, + ); + if (sessionHandler) { + logger.info?.('SendbirdProvider | useConnect/setupConnection/configureSession', sessionHandler); + sdk.setSessionHandler(sessionHandler); + } +} diff --git a/src/lib/SendbirdSdkContext.tsx b/src/lib/SendbirdSdkContext.tsx deleted file mode 100644 index bd3b867b73..0000000000 --- a/src/lib/SendbirdSdkContext.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import type { SendBirdState } from './types'; - -type ContextAwareComponentType = { - (props: any): JSX.Element; - displayName: string; -}; - -export const SendbirdSdkContext = React.createContext(null); - -/** - * @deprecated This function is deprecated. Use `useSendbirdStateContext` instead. - * */ -const withSendbirdContext = (OriginalComponent: any, mapStoreToProps: Record): ContextAwareComponentType => { - const ContextAwareComponent = (props: any) => ( - - {(context) => { - if (mapStoreToProps && typeof mapStoreToProps !== 'function') { - // eslint-disable-next-line no-console - console.warn('Second parameter to withSendbirdContext must be a pure function'); - } - const mergedProps = (mapStoreToProps && typeof mapStoreToProps === 'function') - ? { ...mapStoreToProps(context), ...props } - : { ...context, ...props }; - return ; - }} - - ); - - const componentName = OriginalComponent.displayName || OriginalComponent.name || 'Component'; - ContextAwareComponent.displayName = `SendbirdAware${componentName}`; - - return ContextAwareComponent; -}; - -export default withSendbirdContext; diff --git a/src/lib/SendbirdState.tsx b/src/lib/SendbirdState.tsx deleted file mode 100644 index 11b79babab..0000000000 --- a/src/lib/SendbirdState.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Dispatch } from 'react'; -import { LoggerInterface } from './Logger'; - -export type CustomUseReducerDispatcher = Dispatch<{ - type: string; - payload: any; -}>; - -export type Logger = LoggerInterface; diff --git a/src/lib/UserProfileContext.tsx b/src/lib/UserProfileContext.tsx index 92f5d41042..eaf82aa942 100644 --- a/src/lib/UserProfileContext.tsx +++ b/src/lib/UserProfileContext.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { RenderUserProfileProps } from '../types'; -import useSendbirdStateContext from '../hooks/useSendbirdStateContext'; +import useSendbird from './Sendbird/context/hooks/useSendbird'; interface UserProfileContextInterface { isOpenChannel: boolean; @@ -44,7 +44,8 @@ export const UserProfileProvider = ({ onStartDirectMessage: _onStartDirectMessage, children, }: UserProfileProviderProps) => { - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const onStartDirectMessage = _onStartDirectMessage ?? _onUserProfileMessage ?? config.onStartDirectMessage; return ( diff --git a/src/lib/dux/appInfo/actionTypes.ts b/src/lib/dux/appInfo/actionTypes.ts deleted file mode 100644 index f90c4ebdca..0000000000 --- a/src/lib/dux/appInfo/actionTypes.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CreateAction } from '../../../utils/typeHelpers/reducers/createAction'; -import { MessageTemplatesInfo, ProcessedMessageTemplate } from './initialState'; - -export const APP_INFO_ACTIONS = { - INITIALIZE_MESSAGE_TEMPLATES_INFO: 'INITIALIZE_MESSAGE_TEMPLATES_INFO', - UPSERT_MESSAGE_TEMPLATES: 'UPSERT_MESSAGE_TEMPLATES', - UPSERT_WAITING_TEMPLATE_KEYS: 'UPSERT_WAITING_TEMPLATE_KEYS', - MARK_ERROR_WAITING_TEMPLATE_KEYS: 'MARK_ERROR_WAITING_TEMPLATE_KEYS', -} as const; - -export type TemplatesMapData = { - key: string; - template: ProcessedMessageTemplate; -}; - -type APP_INFO_PAYLOAD_TYPES = { - [APP_INFO_ACTIONS.INITIALIZE_MESSAGE_TEMPLATES_INFO]: MessageTemplatesInfo, - [APP_INFO_ACTIONS.UPSERT_MESSAGE_TEMPLATES]: TemplatesMapData[], - [APP_INFO_ACTIONS.UPSERT_WAITING_TEMPLATE_KEYS]: { keys: string[], requestedAt: number }, - [APP_INFO_ACTIONS.MARK_ERROR_WAITING_TEMPLATE_KEYS]: { keys: string[], messageId: number }, -}; - -export type AppInfoActionTypes = CreateAction; diff --git a/src/lib/dux/appInfo/initialState.ts b/src/lib/dux/appInfo/initialState.ts deleted file mode 100644 index 54b99449bf..0000000000 --- a/src/lib/dux/appInfo/initialState.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type ProcessedMessageTemplate = { - version: number; - uiTemplate: string; // This is stringified ui_template.body.items - colorVariables?: Record; -}; - -export interface MessageTemplatesInfo { - token: string; // This server-side token gets updated on every CRUD operation on message template table. - templatesMap: Record; -} - -export interface WaitingTemplateKeyData { - requestedAt: number; - erroredMessageIds: number[]; -} - -export interface AppInfoStateType { - messageTemplatesInfo?: MessageTemplatesInfo; - /** - * This represents template keys that are currently waiting for its fetch response. - * Whenever initialized, request succeeds or fails, it needs to be updated. - */ - waitingTemplateKeysMap: Record; -} - -const initialState: AppInfoStateType = { - waitingTemplateKeysMap: {}, -}; - -export default initialState; diff --git a/src/lib/dux/appInfo/reducers.ts b/src/lib/dux/appInfo/reducers.ts deleted file mode 100644 index 3494608c67..0000000000 --- a/src/lib/dux/appInfo/reducers.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { match } from 'ts-pattern'; -import { AppInfoStateType, WaitingTemplateKeyData } from './initialState'; -import { APP_INFO_ACTIONS, AppInfoActionTypes } from './actionTypes'; - -export default function reducer(state: AppInfoStateType, action: AppInfoActionTypes): AppInfoStateType { - return match(action) - .with( - { type: APP_INFO_ACTIONS.INITIALIZE_MESSAGE_TEMPLATES_INFO }, - ({ payload }) => { - return { - messageTemplatesInfo: payload, - waitingTemplateKeysMap: {}, - }; - }) - .with( - { type: APP_INFO_ACTIONS.UPSERT_MESSAGE_TEMPLATES }, - ({ payload }) => { - const templatesInfo = state.messageTemplatesInfo; - if (!templatesInfo) return state; // Not initialized. Ignore. - - const waitingTemplateKeysMap = { ...state.waitingTemplateKeysMap }; - payload.forEach((templatesMapData) => { - const { key, template } = templatesMapData; - templatesInfo.templatesMap[key] = template; - delete waitingTemplateKeysMap[key]; - }); - return { - ...state, - waitingTemplateKeysMap, - messageTemplatesInfo: templatesInfo, - }; - }) - .with( - { type: APP_INFO_ACTIONS.UPSERT_WAITING_TEMPLATE_KEYS }, - ({ payload }) => { - const { keys, requestedAt } = payload; - const waitingTemplateKeysMap = { ...state.waitingTemplateKeysMap }; - keys.forEach((key) => { - waitingTemplateKeysMap[key] = { - erroredMessageIds: waitingTemplateKeysMap[key]?.erroredMessageIds ?? [], - requestedAt, - }; - }); - return { - ...state, - waitingTemplateKeysMap, - }; - }) - .with( - { type: APP_INFO_ACTIONS.MARK_ERROR_WAITING_TEMPLATE_KEYS }, - ({ payload }) => { - const { keys, messageId } = payload; - const waitingTemplateKeysMap = { ...state.waitingTemplateKeysMap }; - keys.forEach((key) => { - const waitingTemplateKeyData: WaitingTemplateKeyData | undefined = waitingTemplateKeysMap[key]; - if (waitingTemplateKeyData && waitingTemplateKeyData.erroredMessageIds.indexOf(messageId) === -1) { - waitingTemplateKeyData.erroredMessageIds.push(messageId); - } - }); - return { - ...state, - waitingTemplateKeysMap, - }; - }) - .otherwise(() => { - return state; - }); -} diff --git a/src/lib/dux/appInfo/utils.ts b/src/lib/dux/appInfo/utils.ts deleted file mode 100644 index ea1ce6f4c9..0000000000 --- a/src/lib/dux/appInfo/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ProcessedMessageTemplate } from './initialState'; -import { SendbirdMessageTemplate } from '../../../ui/TemplateMessageItemBody/types'; - -/** - * Takes JSON parsed template and then returns processed message template for storing it in global state. - */ -export const getProcessedTemplate = (parsedTemplate: SendbirdMessageTemplate): ProcessedMessageTemplate => { - return { - version: Number(parsedTemplate.ui_template.version), - uiTemplate: JSON.stringify(parsedTemplate.ui_template.body.items), - colorVariables: parsedTemplate.color_variables, - }; -}; - -export const getProcessedTemplatesMap = ( - parsedTemplates: SendbirdMessageTemplate[], -): Record => { - const processedTemplates = {}; - parsedTemplates.forEach((template) => { - processedTemplates[template.key] = getProcessedTemplate(template); - }); - return processedTemplates; -}; diff --git a/src/lib/dux/sdk/actionTypes.ts b/src/lib/dux/sdk/actionTypes.ts deleted file mode 100644 index 2b416c0d3b..0000000000 --- a/src/lib/dux/sdk/actionTypes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { CreateAction } from '../../../utils/typeHelpers/reducers/createAction'; -import { SdkStoreStateType } from './initialState'; - -export const SDK_ACTIONS = { - INIT_SDK: 'INIT_SDK', - SET_SDK_LOADING: 'SET_SDK_LOADING', - RESET_SDK: 'RESET_SDK', - SDK_ERROR: 'SDK_ERROR', -} as const; - -type SDK_PAYLOAD_TYPES = { - [SDK_ACTIONS.SET_SDK_LOADING]: boolean, - [SDK_ACTIONS.INIT_SDK]: SdkStoreStateType['sdk'], - [SDK_ACTIONS.SDK_ERROR]: null, - [SDK_ACTIONS.RESET_SDK]: null, -}; - -export type SdkActionTypes = CreateAction; diff --git a/src/lib/dux/sdk/initialState.ts b/src/lib/dux/sdk/initialState.ts deleted file mode 100644 index fc9e50eb9c..0000000000 --- a/src/lib/dux/sdk/initialState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { SdkStore } from '../../types'; - -export interface SdkStoreStateType { - initialized: SdkStore['initialized'] - loading: SdkStore['loading'] - sdk: SdkStore['sdk'], - error: SdkStore['error']; -} - -const initialState: SdkStoreStateType = { - initialized: false, - loading: false, - sdk: {} as SdkStore['sdk'], - error: false, -}; - -export default initialState; diff --git a/src/lib/dux/sdk/reducers.ts b/src/lib/dux/sdk/reducers.ts deleted file mode 100644 index 443226e9aa..0000000000 --- a/src/lib/dux/sdk/reducers.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { match } from 'ts-pattern'; -import { SdkActionTypes, SDK_ACTIONS } from './actionTypes'; -import initialState, { SdkStoreStateType } from './initialState'; - -export default function reducer(state: SdkStoreStateType, action: SdkActionTypes): SdkStoreStateType { - return match(action) - .with({ type: SDK_ACTIONS.SET_SDK_LOADING }, ({ payload }) => { - return { - ...state, - initialized: false, - loading: payload, - }; - }) - .with({ type: SDK_ACTIONS.SDK_ERROR }, () => { - return { - ...state, - initialized: false, - loading: false, - error: true, - }; - }) - .with({ type: SDK_ACTIONS.INIT_SDK }, ({ payload }) => { - return { - sdk: payload, - initialized: true, - loading: false, - error: false, - }; - }) - .with({ type: SDK_ACTIONS.RESET_SDK }, () => { - return initialState; - }) - .otherwise(() => { - return state; - }); -} diff --git a/src/lib/dux/user/actionTypes.ts b/src/lib/dux/user/actionTypes.ts deleted file mode 100644 index 85e4c762fc..0000000000 --- a/src/lib/dux/user/actionTypes.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { User } from '@sendbird/chat'; -import { CreateAction } from '../../../utils/typeHelpers/reducers/createAction'; - -export const USER_ACTIONS = { - INIT_USER: 'INIT_USER', - RESET_USER: 'RESET_USER', - UPDATE_USER_INFO: 'UPDATE_USER_INFO', -} as const; - -type USER_PAYLOAD_TYPES = { - [USER_ACTIONS.INIT_USER]: User, - [USER_ACTIONS.RESET_USER]: null, - [USER_ACTIONS.UPDATE_USER_INFO]: User, -}; - -export type UserActionTypes = CreateAction; diff --git a/src/lib/dux/user/initialState.ts b/src/lib/dux/user/initialState.ts deleted file mode 100644 index af5da7577b..0000000000 --- a/src/lib/dux/user/initialState.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { User } from '@sendbird/chat'; - -export interface UserStoreStateType { - initialized: boolean; - loading: boolean; - user: User; -} - -const initialState: UserStoreStateType = { - initialized: false, - loading: false, - user: {} as User, -}; - -export default initialState; diff --git a/src/lib/dux/user/reducers.ts b/src/lib/dux/user/reducers.ts deleted file mode 100644 index 27bea4de59..0000000000 --- a/src/lib/dux/user/reducers.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { match } from 'ts-pattern'; -import { UserActionTypes, USER_ACTIONS } from './actionTypes'; -import initialState, { UserStoreStateType } from './initialState'; - -export default function reducer(state: UserStoreStateType, action: UserActionTypes): UserStoreStateType { - return match(action) - .with({ type: USER_ACTIONS.INIT_USER }, ({ payload }) => { - return { - initialized: true, - loading: false, - user: payload, - }; - }) - .with({ type: USER_ACTIONS.RESET_USER }, () => { - return initialState; - }) - .with({ type: USER_ACTIONS.UPDATE_USER_INFO }, ({ payload }) => { - return { - ...state, - user: payload, - }; - }) - .otherwise(() => { - return state; - }); -} diff --git a/src/lib/emojiManager.tsx b/src/lib/emojiManager.tsx index cc9a2c664f..1c51a3cd3f 100644 --- a/src/lib/emojiManager.tsx +++ b/src/lib/emojiManager.tsx @@ -12,8 +12,7 @@ */ import type { Emoji, EmojiCategory, EmojiContainer } from '@sendbird/chat'; -import type { SendbirdChatType } from './types'; -import { Logger } from './SendbirdState'; +import type { SendbirdChatType, Logger } from './Sendbird/types'; import { match } from 'ts-pattern'; import { Reaction } from '@sendbird/chat/message'; diff --git a/src/lib/hooks/__tests__/schedulerFactory.spec.ts b/src/lib/hooks/__tests__/schedulerFactory.spec.ts index 4d5b6405d5..4d17f4a316 100644 --- a/src/lib/hooks/__tests__/schedulerFactory.spec.ts +++ b/src/lib/hooks/__tests__/schedulerFactory.spec.ts @@ -2,7 +2,7 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; import { schedulerFactory } from '../schedulerFactory'; import { LoggerFactory } from '../../Logger'; -import { Logger } from '../../SendbirdState'; +import type { Logger } from '../../Sendbird/types'; jest.useFakeTimers(); jest.spyOn(global, 'setInterval'); diff --git a/src/lib/hooks/__tests__/useMarkAsDeliveredScheduler.spec.ts b/src/lib/hooks/__tests__/useMarkAsDeliveredScheduler.spec.ts index 5c93304a32..ca42115ebd 100644 --- a/src/lib/hooks/__tests__/useMarkAsDeliveredScheduler.spec.ts +++ b/src/lib/hooks/__tests__/useMarkAsDeliveredScheduler.spec.ts @@ -3,7 +3,7 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; import { useMarkAsDeliveredScheduler } from '../useMarkAsDeliveredScheduler'; import { LoggerFactory } from '../../Logger'; -import { Logger } from '../../SendbirdState'; +import type { Logger } from '../../Sendbird/types'; jest.useFakeTimers(); diff --git a/src/lib/hooks/__tests__/useMarkAsReadScheduler.spec.ts b/src/lib/hooks/__tests__/useMarkAsReadScheduler.spec.ts index 64fa42ddd4..9c4e6e92b0 100644 --- a/src/lib/hooks/__tests__/useMarkAsReadScheduler.spec.ts +++ b/src/lib/hooks/__tests__/useMarkAsReadScheduler.spec.ts @@ -3,7 +3,7 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; import { useMarkAsReadScheduler } from '../useMarkAsReadScheduler'; import { LoggerFactory } from '../../Logger'; -import { Logger } from '../../SendbirdState'; +import type { Logger } from '../../Sendbird/types'; const logger = LoggerFactory('all') as Logger; describe('useMarkAsReadScheduler', () => { diff --git a/src/lib/hooks/schedulerFactory.ts b/src/lib/hooks/schedulerFactory.ts index aa923618a9..7ce2f32f9d 100644 --- a/src/lib/hooks/schedulerFactory.ts +++ b/src/lib/hooks/schedulerFactory.ts @@ -1,5 +1,5 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { Logger } from '../SendbirdState'; +import type { Logger } from '../Sendbird/types'; const TIMEOUT = 2000; diff --git a/src/lib/hooks/useConnect/__test__/data.mocks.ts b/src/lib/hooks/useConnect/__test__/data.mocks.ts deleted file mode 100644 index 9bc5579c9f..0000000000 --- a/src/lib/hooks/useConnect/__test__/data.mocks.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { User } from '@sendbird/chat'; -import { LoggerFactory } from '../../../Logger'; -import { ConnectTypes, DisconnectSdkTypes, SetupConnectionTypes, StaticTypes, TriggerTypes } from '../types'; - -export const mockUser = { - userId: 'test-user-id', - nickname: 'test-nickname', - profileUrl: 'test-profile-url', -} as unknown as User; - -export const mockUser2 = { - userId: 'test-user-id2', - nickname: 'test-nickname2', - profileUrl: 'test-profile-url2', -} as unknown as User; - -export const mockSdk = { - connect: jest.fn().mockImplementation((userId) => { - if (userId === mockUser2.userId) { - return Promise.resolve(mockUser2); - } - if (userId === mockUser.userId) { - return Promise.resolve(mockUser); - } - if (userId?.length > 0) { - return Promise.resolve({ userId: userId }); - } - return Promise.reject(); - }), - disconnect: jest.fn().mockImplementation(() => Promise.resolve(true)), - disconnectWebSocket: jest.fn().mockImplementation(() => Promise.resolve(true)), - updateCurrentUserInfo: jest.fn().mockImplementation((user) => Promise.resolve(user)), - setSessionHandler: jest.fn(), - addExtension: jest.fn(), - addSendbirdExtensions: jest.fn(), - getUIKitConfiguration: jest.fn().mockImplementation(() => Promise.resolve({})), -} as unknown as ConnectTypes['sdk']; - -export const mockSdkDispatcher = jest.fn() as unknown as ConnectTypes['sdkDispatcher']; -export const mockUserDispatcher = jest.fn() as unknown as ConnectTypes['userDispatcher']; -export const mockAppInfoDispatcher = jest.fn() as unknown as ConnectTypes['appInfoDispatcher']; -export const mockInitDashboardConfigs = jest.fn().mockImplementation(() => Promise.resolve({})) as unknown as ConnectTypes['initDashboardConfigs']; - -export const defaultStaticParams: StaticTypes = { - nickname: 'test-nickname', - profileUrl: 'test-profile-url', - sdk: mockSdk, - logger: LoggerFactory('all'), - sdkDispatcher: mockSdkDispatcher, - userDispatcher: mockUserDispatcher, - appInfoDispatcher: mockAppInfoDispatcher, - initDashboardConfigs: mockInitDashboardConfigs, - initializeMessageTemplatesInfo: jest.fn().mockImplementation(() => Promise.resolve()), -}; - -export const defaultTriggerParams: TriggerTypes = { - userId: 'test-user-id', - appId: 'test-app-id', - accessToken: 'test-access-token', -}; - -export const defaultConnectParams: ConnectTypes = { - ...defaultStaticParams, - ...defaultTriggerParams, -}; - -export const defaultSetupConnectionParams: SetupConnectionTypes = { - ...defaultConnectParams, -}; - -export const defaultDisconnectSdkParams: DisconnectSdkTypes = { - sdkDispatcher: mockSdkDispatcher, - userDispatcher: mockUserDispatcher, - sdk: mockSdk, - logger: LoggerFactory('all'), -}; - -export function generateDisconnectSdkParams(overrides?: Partial): DisconnectSdkTypes { - return { - ...defaultDisconnectSdkParams, - ...overrides, - }; -} - -export function generateSetUpConnectionParams(overrides?: Partial): SetupConnectionTypes { - return { - ...defaultSetupConnectionParams, - ...overrides, - }; -} diff --git a/src/lib/hooks/useConnect/__test__/disconnectSdk.spec.ts b/src/lib/hooks/useConnect/__test__/disconnectSdk.spec.ts deleted file mode 100644 index 9f66cfa56d..0000000000 --- a/src/lib/hooks/useConnect/__test__/disconnectSdk.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { SDK_ACTIONS } from '../../../dux/sdk/actionTypes'; -import { USER_ACTIONS } from '../../../dux/user/actionTypes'; -import { disconnectSdk } from '../disconnectSdk'; -import { generateDisconnectSdkParams } from './data.mocks'; - -describe('useConnect/disconnectSdk', () => { - it('should call disconnectSdk when there is proper SDK', async () => { - // setup - const disconnectProps = generateDisconnectSdkParams(); - const mockDisconnect = disconnectProps.sdk.disconnectWebSocket as jest.Mock; - - // execute - await disconnectSdk(disconnectProps); - - // verify - expect(disconnectProps.sdkDispatcher).toHaveBeenCalledBefore(mockDisconnect); - expect(disconnectProps.sdkDispatcher).toBeCalledWith({ type: SDK_ACTIONS.SET_SDK_LOADING, payload: true }); - expect(disconnectProps.sdk.disconnectWebSocket).toHaveBeenCalled(); - expect(disconnectProps.sdkDispatcher).toBeCalledWith({ type: SDK_ACTIONS.RESET_SDK }); - expect(disconnectProps.userDispatcher).toBeCalledWith({ type: USER_ACTIONS.RESET_USER }); - }); - - it('should not call disconnectSdk when there is no SDK', async () => { - const disconnectProps = generateDisconnectSdkParams({ sdk: undefined }); - await disconnectSdk(disconnectProps); - expect(disconnectProps.sdkDispatcher).toBeCalledWith({ type: SDK_ACTIONS.SET_SDK_LOADING, payload: true }); - expect(disconnectProps.sdkDispatcher).not.toBeCalledWith({ type: SDK_ACTIONS.RESET_SDK }); - }); -}); diff --git a/src/lib/hooks/useConnect/__test__/setupConnection.spec.ts b/src/lib/hooks/useConnect/__test__/setupConnection.spec.ts deleted file mode 100644 index 0cb13c75ad..0000000000 --- a/src/lib/hooks/useConnect/__test__/setupConnection.spec.ts +++ /dev/null @@ -1,291 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable global-require */ -import { SDK_ACTIONS } from '../../../dux/sdk/actionTypes'; -import { USER_ACTIONS } from '../../../dux/user/actionTypes'; -import { getMissingParamError, setUpConnection, initSDK, getConnectSbError } from '../setupConnection'; -import { SetupConnectionTypes } from '../types'; -import { generateSetUpConnectionParams, mockSdk, mockUser, mockUser2 } from './data.mocks'; -import { SendbirdError } from '@sendbird/chat'; - -// todo: combine after mock-sdk is implemented -jest.mock('@sendbird/chat', () => { - const originalModule = jest.requireActual('@sendbird/chat'); - return { - init: jest.fn(() => mockSdk), - ...originalModule, - }; -}); - -describe('useConnect/setupConnection', () => { - it('should call SDK_ERROR when there is no appId', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const params = { ...setUpConnectionProps, appId: undefined }; - const errorMessage = getMissingParamError({ userId: params.userId, appId: params.appId }); - - await expect(setUpConnection(params as unknown as SetupConnectionTypes)).rejects.toMatch(errorMessage); - - expect(mockSdk.connect).not.toBeCalledWith({ type: SDK_ACTIONS.SET_SDK_LOADING, payload: true }); - expect(setUpConnectionProps.sdkDispatcher).toBeCalledWith({ type: SDK_ACTIONS.SDK_ERROR }); - }); - - it('should call SDK_ERROR when there is no userId', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const params = { ...setUpConnectionProps, userId: undefined }; - const errorMessage = getMissingParamError({ userId: params.userId, appId: params.appId }); - - await expect(setUpConnection(params as unknown as SetupConnectionTypes)).rejects.toMatch(errorMessage); - - expect(setUpConnectionProps.sdkDispatcher).toHaveBeenCalledWith({ type: SDK_ACTIONS.SET_SDK_LOADING, payload: true }); - expect(mockSdk.connect).not.toBeCalledWith({ type: SDK_ACTIONS.SET_SDK_LOADING, payload: true }); - expect(setUpConnectionProps.sdkDispatcher).toBeCalledWith({ type: SDK_ACTIONS.SDK_ERROR }); - }); - - it('should replace nickname with userId when isUserIdUsedForNickname is true', async () => { - const newUser = { - userId: 'new-userid', - nickname: '', - profileUrl: 'new-user-profile-url', - }; - const setUpConnectionProps = generateSetUpConnectionParams(); - await setUpConnection({ - ...setUpConnectionProps, - ...newUser, - isUserIdUsedForNickname: true, - }); - - const updatedUser = { nickname: newUser.userId, profileUrl: newUser.profileUrl }; - expect(mockSdk.updateCurrentUserInfo).toHaveBeenCalledWith(updatedUser); - expect(setUpConnectionProps.userDispatcher).toHaveBeenCalledWith({ - type: USER_ACTIONS.UPDATE_USER_INFO, - payload: updatedUser, - }); - }); - - it('should not replace nickname with userId when isUserIdUsedForNickname is false', async () => { - const newUser = { - userId: 'new-userid', - nickname: '', - profileUrl: 'new-user-profile-url', - }; - const setUpConnectionProps = generateSetUpConnectionParams(); - await setUpConnection({ - ...setUpConnectionProps, - ...newUser, - isUserIdUsedForNickname: false, - }); - - const updatedUser = { nickname: '', profileUrl: newUser.profileUrl }; - expect(mockSdk.updateCurrentUserInfo).toHaveBeenCalledWith(updatedUser); - expect(setUpConnectionProps.userDispatcher).toHaveBeenCalledWith({ - type: USER_ACTIONS.UPDATE_USER_INFO, - payload: updatedUser, - }); - }); - - it('should call setUpConnection when there is proper SDK', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - await setUpConnection(setUpConnectionProps); - expect(setUpConnectionProps.sdkDispatcher).toHaveBeenNthCalledWith(1, { - type: SDK_ACTIONS.SET_SDK_LOADING, - payload: true, - }); - expect(mockSdk.connect).toHaveBeenCalledWith(setUpConnectionProps.userId, setUpConnectionProps.accessToken); - expect(setUpConnectionProps.sdkDispatcher).toHaveBeenNthCalledWith(2, { - type: SDK_ACTIONS.INIT_SDK, - payload: mockSdk, - }); - expect(setUpConnectionProps.userDispatcher).toHaveBeenCalledOnceWith({ - type: USER_ACTIONS.INIT_USER, - payload: mockUser, - }); - }); - - it('should call connect with only userId when there is no access token', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const params = { ...setUpConnectionProps, accessToken: undefined }; - await setUpConnection(params); - expect(mockSdk.connect).toHaveBeenCalledWith(mockUser.userId, undefined); - }); - - it('should call connect with userId & access token', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const params = { ...setUpConnectionProps, accessToken: setUpConnectionProps.accessToken }; - await setUpConnection(params); - expect(mockSdk.connect).toHaveBeenCalledWith(mockUser.userId, setUpConnectionProps.accessToken); - }); - - it('should call configureSession if provided', async () => { - const configureSession = jest.fn().mockImplementation(() => 'mock_session'); - const setUpConnectionProps = generateSetUpConnectionParams(); - await setUpConnection({ ...setUpConnectionProps, configureSession }); - expect(configureSession).toHaveBeenCalledWith(mockSdk); - }); - - it('should call updateCurrentUserInfo when', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const newNickname = 'newNickname'; - const newprofileUrl = 'newprofileUrl'; - await setUpConnection({ - ...setUpConnectionProps, - userId: mockUser2.userId, - nickname: newNickname, - profileUrl: newprofileUrl, - }); - expect(mockSdk.updateCurrentUserInfo).toHaveBeenCalledWith({ nickname: 'newNickname', profileUrl: 'newprofileUrl' }); - expect(setUpConnectionProps.userDispatcher).toHaveBeenCalledWith({ - type: USER_ACTIONS.INIT_USER, - payload: { - nickname: 'test-nickname2', - profileUrl: 'test-profile-url2', - userId: 'test-user-id2', - }, - }); - }); - - it('should call connectCbError if connection fails', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - setUpConnectionProps.userId = ''; - const errorMessage = getMissingParamError({ - userId: '', - appId: setUpConnectionProps.appId, - }); - - await expect(setUpConnection(setUpConnectionProps)).rejects.toMatch(errorMessage); - - expect(setUpConnectionProps.sdkDispatcher).toHaveBeenCalledWith({ - type: SDK_ACTIONS.SDK_ERROR, - }); - }); - - it('should call onConnectionFailed callback when connection fails', async () => { - const onConnectionFailed = jest.fn(); - const setUpConnectionProps = generateSetUpConnectionParams(); - setUpConnectionProps.eventHandlers = { connection: { onFailed: onConnectionFailed } }; - - const error = new Error('test-error'); - // @ts-expect-error - mockSdk.connect.mockRejectedValueOnce(error); - const expected = getConnectSbError(error as SendbirdError); - // // Ensure that the onConnectionFailed callback is called with the correct error message - await expect(setUpConnection(setUpConnectionProps)).rejects.toStrictEqual(expected); - // Ensure that onConnectionFailed callback is called with the expected error object - expect(onConnectionFailed).toHaveBeenCalledWith(error); - }); - - it('should call onConnected callback when connection succeeded', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - setUpConnectionProps.eventHandlers = { connection: { onConnected: jest.fn() } }; - - const user = { userId: 'test-user-id', nickname: 'test-nickname', profileUrl: 'test-profile-url' }; - // @ts-expect-error - mockSdk.connect.mockResolvedValueOnce(user); - - await expect(setUpConnection(setUpConnectionProps)).resolves.toStrictEqual(undefined); - expect(setUpConnectionProps.eventHandlers.connection.onConnected).toHaveBeenCalledWith(user); - }); -}); - -describe('useConnect/setupConnection/initSDK', () => { - it('should call init with correct appId', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const { appId, customApiHost, customWebSocketHost } = setUpConnectionProps; - const newSdk = initSDK({ appId, customApiHost, customWebSocketHost }); - // @ts-ignore - expect(require('@sendbird/chat').init).toBeCalledWith({ - appId, - newInstance: false, - localCacheEnabled: true, - modules: [ - // @ts-ignore - new (require('@sendbird/chat/groupChannel').GroupChannelModule)(), - // @ts-ignore - new (require('@sendbird/chat/openChannel').OpenChannelModule)(), - ], - }); - expect(newSdk).toEqual(mockSdk); - }); - - it('should call init with correct customApiHost & customWebSocketHost', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const { appId, customApiHost, customWebSocketHost } = setUpConnectionProps; - const newSdk = initSDK({ appId, customApiHost, customWebSocketHost }); - // @ts-ignore - expect(require('@sendbird/chat').init).toBeCalledWith({ - appId, - newInstance: false, - localCacheEnabled: true, - modules: [ - // @ts-ignore - new (require('@sendbird/chat/groupChannel').GroupChannelModule)(), - // @ts-ignore - new (require('@sendbird/chat/openChannel').OpenChannelModule)(), - ], - customApiHost, - customWebSocketHost, - }); - expect(newSdk).toEqual(mockSdk); - }); - - it('should call init with sdkInitParams', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const { appId, sdkInitParams } = setUpConnectionProps; - const newSdk = initSDK({ appId, sdkInitParams }); - // @ts-ignore - expect(require('@sendbird/chat').init).toBeCalledWith({ - appId, - newInstance: false, - localCacheEnabled: true, - modules: [ - // @ts-ignore - new (require('@sendbird/chat/groupChannel').GroupChannelModule)(), - // @ts-ignore - new (require('@sendbird/chat/openChannel').OpenChannelModule)(), - ], - sdkInitParams, - }); - expect(newSdk).toEqual(mockSdk); - }); - - it('should call init with customExtensionParams', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const { appId, customExtensionParams } = setUpConnectionProps; - const newSdk = initSDK({ appId, customExtensionParams }); - // @ts-ignore - expect(require('@sendbird/chat').init).toBeCalledWith({ - appId, - newInstance: false, - localCacheEnabled: true, - modules: [ - // @ts-ignore - new (require('@sendbird/chat/groupChannel').GroupChannelModule)(), - // @ts-ignore - new (require('@sendbird/chat/openChannel').OpenChannelModule)(), - ], - customExtensionParams, - }); - expect(newSdk).toEqual(mockSdk); - }); - it('should override default localCacheEnabled when provided in sdkInitParams', async () => { - const setUpConnectionProps = generateSetUpConnectionParams(); - const { appId } = setUpConnectionProps; - const sdkInitParams = { - localCacheEnabled: false, - }; - - const newSdk = initSDK({ appId, sdkInitParams }); - - // @ts-ignore - expect(require('@sendbird/chat').init).toBeCalledWith({ - appId, - newInstance: false, - modules: [ - // @ts-ignore - new (require('@sendbird/chat/groupChannel').GroupChannelModule)(), - // @ts-ignore - new (require('@sendbird/chat/openChannel').OpenChannelModule)(), - ], - localCacheEnabled: false, - }); - expect(newSdk).toEqual(mockSdk); - }); -}); diff --git a/src/lib/hooks/useConnect/connect.ts b/src/lib/hooks/useConnect/connect.ts deleted file mode 100644 index d22ca7c05a..0000000000 --- a/src/lib/hooks/useConnect/connect.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { disconnectSdk } from './disconnectSdk'; -import { setUpConnection } from './setupConnection'; -import { ConnectTypes } from './types'; - -export async function connect({ - logger, - sdkDispatcher, - userDispatcher, - appInfoDispatcher, - initDashboardConfigs, - userId, - appId, - isNewApp = false, - customApiHost, - customWebSocketHost, - configureSession, - nickname, - profileUrl, - accessToken, - sdk, - sdkInitParams, - customExtensionParams, - isMobile, - eventHandlers, - isUserIdUsedForNickname, - initializeMessageTemplatesInfo, -}: ConnectTypes): Promise { - await disconnectSdk({ - logger, - sdkDispatcher, - userDispatcher, - sdk, - }); - await setUpConnection({ - logger, - sdkDispatcher, - userDispatcher, - appInfoDispatcher, - initDashboardConfigs, - userId, - appId, - isNewApp, - customApiHost, - customWebSocketHost, - configureSession, - nickname, - profileUrl, - accessToken, - sdkInitParams, - customExtensionParams, - isMobile, - eventHandlers, - isUserIdUsedForNickname, - initializeMessageTemplatesInfo, - }); -} diff --git a/src/lib/hooks/useConnect/disconnectSdk.ts b/src/lib/hooks/useConnect/disconnectSdk.ts deleted file mode 100644 index 210a24bbf0..0000000000 --- a/src/lib/hooks/useConnect/disconnectSdk.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { SDK_ACTIONS } from '../../dux/sdk/actionTypes'; -import { USER_ACTIONS } from '../../dux/user/actionTypes'; -import { DisconnectSdkTypes } from './types'; - -export async function disconnectSdk({ - sdkDispatcher, - userDispatcher, - sdk, -}: DisconnectSdkTypes): Promise { - return new Promise((resolve) => { - sdkDispatcher({ type: SDK_ACTIONS.SET_SDK_LOADING, payload: true }); - if (sdk?.disconnectWebSocket) { - sdk.disconnectWebSocket() - .then(() => { - sdkDispatcher({ type: SDK_ACTIONS.RESET_SDK }); - userDispatcher({ type: USER_ACTIONS.RESET_USER }); - }) - .finally(() => { - resolve(true); - }); - } else { - resolve(true); - } - }); -} diff --git a/src/lib/hooks/useConnect/index.ts b/src/lib/hooks/useConnect/index.ts deleted file mode 100644 index 8b7c6ff10c..0000000000 --- a/src/lib/hooks/useConnect/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useCallback, useEffect, useRef } from 'react'; - -import { ReconnectType, StaticTypes, TriggerTypes } from './types'; -import { connect } from './connect'; - -export default function useConnect(triggerTypes: TriggerTypes, staticTypes: StaticTypes): ReconnectType { - const { userId, appId, accessToken, isMobile, isUserIdUsedForNickname } = triggerTypes; - const { - logger, - nickname, - profileUrl, - configureSession, - customApiHost, - customWebSocketHost, - sdk, - sdkDispatcher, - userDispatcher, - appInfoDispatcher, - initDashboardConfigs, - sdkInitParams, - customExtensionParams, - eventHandlers, - initializeMessageTemplatesInfo, - } = staticTypes; - - // Note: This is a workaround to prevent the creation of multiple SDK instances when React strict mode is enabled. - const connectDeps = useRef<{ appId: string, userId: string }>({ - appId: '', - userId: '', - }); - - useEffect(() => { - logger?.info?.('SendbirdProvider | useConnect/useEffect', { userId, appId, accessToken }); - - const isNewApp = connectDeps.current.appId !== appId; - if (connectDeps.current.appId === appId && connectDeps.current.userId === userId) { - return; - } else { - connectDeps.current = { appId, userId }; - } - - connect({ - userId, - appId, - isNewApp, - accessToken, - logger, - nickname, - profileUrl, - configureSession, - customApiHost, - customWebSocketHost, - sdk, - sdkDispatcher, - userDispatcher, - appInfoDispatcher, - initDashboardConfigs, - isUserIdUsedForNickname, - sdkInitParams, - customExtensionParams, - isMobile, - eventHandlers, - initializeMessageTemplatesInfo, - }).catch(error => { - logger?.error?.('SendbirdProvider | useConnect/useEffect', error); - }); - }, [userId, appId]); - - const reconnect = useCallback(async () => { - logger?.info?.('SendbirdProvider | useConnect/reconnect/useCallback', { sdk }); - - try { - await connect({ - userId, - appId, - accessToken, - logger, - nickname, - profileUrl, - configureSession, - customApiHost, - customWebSocketHost, - sdk, - sdkDispatcher, - userDispatcher, - appInfoDispatcher, - initDashboardConfigs, - isUserIdUsedForNickname, - sdkInitParams, - customExtensionParams, - isMobile, - eventHandlers, - initializeMessageTemplatesInfo, - }); - } catch (error) { - logger?.error?.('SendbirdProvider | useConnect/reconnect/useCallback', error); - } - }, [sdk]); - return reconnect; -} diff --git a/src/lib/hooks/useConnect/setupConnection.ts b/src/lib/hooks/useConnect/setupConnection.ts deleted file mode 100644 index 208fe2e279..0000000000 --- a/src/lib/hooks/useConnect/setupConnection.ts +++ /dev/null @@ -1,239 +0,0 @@ -import SendbirdChat, { - DeviceOsPlatform, - SendbirdChatWith, - SendbirdError, - SendbirdErrorCode, - SendbirdPlatform, - SendbirdProduct, - SessionHandler, - User, -} from '@sendbird/chat'; -import { OpenChannelModule } from '@sendbird/chat/openChannel'; -import { GroupChannelModule } from '@sendbird/chat/groupChannel'; - -import { SDK_ACTIONS } from '../../dux/sdk/actionTypes'; -import { USER_ACTIONS } from '../../dux/user/actionTypes'; - -import { isTextuallyNull } from '../../../utils'; - -import { SetupConnectionTypes } from './types'; -import { CustomExtensionParams, SendbirdChatInitParams } from '../../types'; -import { LoggerInterface } from '../../Logger'; - -const APP_VERSION_STRING = '__react_dev_mode__'; - -const { INIT_SDK, SET_SDK_LOADING, RESET_SDK, SDK_ERROR } = SDK_ACTIONS; -const { INIT_USER, UPDATE_USER_INFO, RESET_USER } = USER_ACTIONS; - -export function getMissingParamError({ userId, appId }: { userId?: string; appId?: string }): string { - return `SendbirdProvider | useConnect/setupConnection/Connection failed UserId: ${userId} or appId: ${appId} missing`; -} -export function getConnectSbError(error?: SendbirdError): string { - return `SendbirdProvider | useConnect/setupConnection/Connection failed. ${error?.code || ''} ${error?.message || ''}`; -} - -export async function setUpConnection({ - logger, - sdkDispatcher, - userDispatcher, - initDashboardConfigs, - userId, - appId, - isNewApp, - customApiHost, - customWebSocketHost, - configureSession, - nickname, - profileUrl, - accessToken, - isUserIdUsedForNickname, - sdkInitParams, - customExtensionParams, - isMobile = false, - eventHandlers, - initializeMessageTemplatesInfo, -}: SetupConnectionTypes): Promise { - logger.info?.('SendbirdProvider | useConnect/setupConnection/init', { userId, appId }); - sdkDispatcher({ type: SET_SDK_LOADING, payload: true }); - - if (!userId || !appId) { - const errorMessage = getMissingParamError({ userId, appId }); - logger.error?.(errorMessage); - sdkDispatcher({ type: SDK_ERROR }); - return Promise.reject(errorMessage); - } - - return new Promise((resolve, reject) => { - logger.info?.(`SendbirdProvider | useConnect/setupConnection/connect connecting using ${accessToken ?? userId}`); - - const sdk = initSDK({ appId, customApiHost, customWebSocketHost, isNewApp, sdkInitParams }); - const sessionHandler = typeof configureSession === 'function' ? configureSession(sdk) : undefined; - setupSDK(sdk, { logger, sessionHandler, customExtensionParams, isMobile }); - - sdk - .connect(userId, accessToken) - .then((user) => onConnected(user)) - .catch(async (error) => { - // NOTE: The part that connects via the SDK must be callable directly by the customer. - // we should refactor this in next major version. - if (shouldRetryWithValidSessionToken(error) && sessionHandler) { - try { - const sessionToken = await new Promise(sessionHandler.onSessionTokenRequired); - if (sessionToken) { - logger.info?.( - `SendbirdProvider | useConnect/setupConnection/connect retry connect with valid session token: ${sessionToken.slice(0, 10) + '...'}`, - ); - const user = await sdk.connect(userId, sessionToken); - return onConnected(user); - } - } catch (error) { - // NOTE: Filter out the error from `onSessionTokenRequired`. - if (error instanceof SendbirdError) { - // connect in offline mode - // if (sdk.isCacheEnabled && sdk.currentUser) return onConnected(sdk.currentUser); - return onConnectFailed(error); - } - } - } - - return onConnectFailed(error); - }); - - const onConnected = async (user: User) => { - logger.info?.('SendbirdProvider | useConnect/setupConnection/onConnected', user); - sdkDispatcher({ type: INIT_SDK, payload: sdk }); - userDispatcher({ type: INIT_USER, payload: user }); - - try { - await initializeMessageTemplatesInfo(sdk); - } catch (error) { - logger.error?.('SendbirdProvider | useConnect/setupConnection/upsertMessageTemplateListInLocalStorage failed', { error }); - } - - try { - await initDashboardConfigs(sdk); - logger.info?.('SendbirdProvider | useConnect/setupConnection/getUIKitConfiguration success'); - } catch (error) { - logger.error?.('SendbirdProvider | useConnect/setupConnection/getUIKitConfiguration failed', { error }); - } - - try { - // use nickname/profileUrl if provided or set userID as nickname - if ((nickname !== user.nickname || profileUrl !== user.profileUrl) && !(isTextuallyNull(nickname) && isTextuallyNull(profileUrl))) { - logger.info?.('SendbirdProvider | useConnect/setupConnection/updateCurrentUserInfo', { nickname, profileUrl }); - const updateParams = { - nickname: nickname || user.nickname || (isUserIdUsedForNickname ? user.userId : ''), - profileUrl: profileUrl || user.profileUrl, - }; - - const updatedUser = await sdk.updateCurrentUserInfo(updateParams); - logger.info?.('SendbirdProvider | useConnect/setupConnection/updateCurrentUserInfo success', updateParams); - userDispatcher({ type: UPDATE_USER_INFO, payload: updatedUser }); - } - } catch { - // NO-OP - } - - resolve(); - eventHandlers?.connection?.onConnected?.(user); - }; - - const onConnectFailed = async (e: SendbirdError) => { - if (sdk.isCacheEnabled && shouldClearCache(e)) { - logger.error?.(`SendbirdProvider | useConnect/setupConnection/connect clear cache [${e.code}/${e.message}]`); - await sdk.clearCachedData(); - } - - const errorMessage = getConnectSbError(e); - logger.error?.(errorMessage, { e, appId, userId }); - userDispatcher({ type: RESET_USER }); - sdkDispatcher({ type: RESET_SDK }); - sdkDispatcher({ type: SDK_ERROR }); - - reject(errorMessage); - eventHandlers?.connection?.onFailed?.(e); - }; - }); -} - -/** - * Initializes the Sendbird SDK with the provided parameters. - * */ -export function initSDK({ - appId, - isNewApp = false, - customApiHost, - customWebSocketHost, - sdkInitParams = {}, -}: { - appId: string; - isNewApp?: boolean; - customApiHost?: string; - customWebSocketHost?: string; - sdkInitParams?: SendbirdChatInitParams; - customExtensionParams?: CustomExtensionParams; -}) { - // eslint-disable-next-line prefer-object-spread -- not to break the existing types - const params = Object.assign({}, { - appId, - modules: [new GroupChannelModule(), new OpenChannelModule()], - newInstance: isNewApp, - localCacheEnabled: true, - }, sdkInitParams); - - if (customApiHost) params.customApiHost = customApiHost; - if (customWebSocketHost) params.customWebSocketHost = customWebSocketHost; - return SendbirdChat.init(params); -} - -/** - * Sets up the Sendbird SDK after initialization. - * Configures necessary settings, adds extensions, sets the platform, and configures the session handler if provided. - */ -function setupSDK( - sdk: SendbirdChatWith<[GroupChannelModule, OpenChannelModule]>, - params: { logger: LoggerInterface; sessionHandler?: SessionHandler; isMobile?: boolean; customExtensionParams?: CustomExtensionParams }, -) { - const { logger, sessionHandler, isMobile, customExtensionParams } = params; - - logger.info?.('SendbirdProvider | useConnect/setupConnection/setVersion', { version: APP_VERSION_STRING }); - sdk.addExtension('sb_uikit', APP_VERSION_STRING); - sdk.addSendbirdExtensions( - [{ product: SendbirdProduct.UIKIT_CHAT, version: APP_VERSION_STRING, platform: SendbirdPlatform?.JS }], - { platform: isMobile ? DeviceOsPlatform.MOBILE_WEB : DeviceOsPlatform.WEB }, - customExtensionParams, - ); - if (sessionHandler) { - logger.info?.('SendbirdProvider | useConnect/setupConnection/configureSession', sessionHandler); - sdk.setSessionHandler(sessionHandler); - } -} - -function shouldClearCache(error: unknown): error is SendbirdError { - if (!(error instanceof SendbirdError)) return false; - - return [ - SendbirdErrorCode.USER_AUTH_DEACTIVATED, - SendbirdErrorCode.USER_AUTH_DELETED_OR_NOT_FOUND, - SendbirdErrorCode.SESSION_TOKEN_EXPIRED, - SendbirdErrorCode.SESSION_REVOKED, - ].includes(error.code); -} - -function shouldRetryWithValidSessionToken(error: unknown): error is SendbirdError { - if (!(error instanceof SendbirdError)) return false; - - return [ - SendbirdErrorCode.SESSION_TOKEN_EXPIRED, - /** - * Note: INVALID_TOKEN has been added arbitrarily due to legacy constraints - * - * In the useEffect of the useConnect hook, authentication is being performed - * but changes of the `accessToken` is not being detected. - * `disconnectSdk` is called when connect is called redundantly for the same user ID, causing issues, so `accessToken` has been excluded form the deps. - * - * In case the `accessToken` is missed, an additional attempt to connect is made - * */ - SendbirdErrorCode.INVALID_TOKEN, - ].includes(error.code); -} diff --git a/src/lib/hooks/useConnect/types.ts b/src/lib/hooks/useConnect/types.ts deleted file mode 100644 index 0217cc7d1c..0000000000 --- a/src/lib/hooks/useConnect/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -import SendbirdChat, { SessionHandler } from '@sendbird/chat'; -import { SendbirdGroupChat } from '@sendbird/chat/groupChannel'; -import { SendbirdOpenChat } from '@sendbird/chat/openChannel'; - -import { SdkActionTypes } from '../../dux/sdk/actionTypes'; -import { UserActionTypes } from '../../dux/user/actionTypes'; - -import { Logger } from '../../SendbirdState'; - -import { SendbirdChatInitParams, CustomExtensionParams, SBUEventHandlers } from '../../types'; -import { AppInfoActionTypes } from '../../dux/appInfo/actionTypes'; - -export type SdkDispatcher = React.Dispatch; -export type UserDispatcher = React.Dispatch; -export type AppInfoDispatcher = React.Dispatch; - -export type TriggerTypes = { - userId: string; - appId: string; - // todo: doulbe check this type before merge - accessToken?: string; - isUserIdUsedForNickname?: boolean; - isNewApp?: boolean; - isMobile?: boolean; -}; - -export type ConfigureSessionTypes = (sdk: SendbirdChat | SendbirdGroupChat | SendbirdOpenChat) => SessionHandler; - -export type StaticTypes = { - nickname: string; - profileUrl: string; - configureSession?: ConfigureSessionTypes; - customApiHost?: string; - customWebSocketHost?: string; - sdk: SendbirdChat; - logger: Logger; - sdkDispatcher: SdkDispatcher; - userDispatcher: UserDispatcher; - appInfoDispatcher: AppInfoDispatcher; - initDashboardConfigs: (sdk: SendbirdChat) => Promise; - sdkInitParams?: SendbirdChatInitParams; - customExtensionParams?: CustomExtensionParams; - eventHandlers?: SBUEventHandlers; - initializeMessageTemplatesInfo: (sdk: SendbirdChat) => Promise; -}; - -export type ConnectTypes = TriggerTypes & StaticTypes; - -export type SetupConnectionTypes = Omit; - -export type DisconnectSdkTypes = { - sdkDispatcher: SdkDispatcher; - userDispatcher: UserDispatcher; - sdk: SendbirdChat; - logger: Logger; -}; - -export type ReconnectType = () => void; diff --git a/src/lib/hooks/useMarkAsDeliveredScheduler.ts b/src/lib/hooks/useMarkAsDeliveredScheduler.ts index 15253787d2..8881a5cdf4 100644 --- a/src/lib/hooks/useMarkAsDeliveredScheduler.ts +++ b/src/lib/hooks/useMarkAsDeliveredScheduler.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { schedulerFactory } from './schedulerFactory'; -import { Logger } from '../SendbirdState'; +import { Logger } from '../Sendbird/types'; import { useUnmount } from '../../hooks/useUnmount'; export type MarkAsDeliveredSchedulerType = { @@ -30,7 +30,7 @@ export function useMarkAsDeliveredScheduler({ try { await channel.markAsDelivered(); } catch (error) { - logger.warning('Channel: Mark as delivered failed', { channel, error }); + logger?.warning('Channel: Mark as delivered failed', { channel, error }); } }, }), []); diff --git a/src/lib/hooks/useMarkAsReadScheduler.ts b/src/lib/hooks/useMarkAsReadScheduler.ts index f0d31bd969..3bd9c8cc74 100644 --- a/src/lib/hooks/useMarkAsReadScheduler.ts +++ b/src/lib/hooks/useMarkAsReadScheduler.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { schedulerFactory } from './schedulerFactory'; -import { Logger } from '../SendbirdState'; +import type { Logger } from '../Sendbird/types'; import { useUnmount } from '../../hooks/useUnmount'; export type MarkAsReadSchedulerType = { diff --git a/src/lib/hooks/useMessageTemplateUtils.ts b/src/lib/hooks/useMessageTemplateUtils.ts index e0a6fef403..50907bfcc3 100644 --- a/src/lib/hooks/useMessageTemplateUtils.ts +++ b/src/lib/hooks/useMessageTemplateUtils.ts @@ -1,19 +1,39 @@ -import React from 'react'; -import { AppInfoStateType, MessageTemplatesInfo, ProcessedMessageTemplate } from '../dux/appInfo/initialState'; -import { SendbirdMessageTemplate } from '../../ui/TemplateMessageItemBody/types'; -import { getProcessedTemplate, getProcessedTemplatesMap } from '../dux/appInfo/utils'; import SendbirdChat from '@sendbird/chat'; -import { APP_INFO_ACTIONS, AppInfoActionTypes } from '../dux/appInfo/actionTypes'; +import { AppInfoStateType, MessageTemplatesInfo, ProcessedMessageTemplate } from '../Sendbird/types'; +import { SendbirdMessageTemplate } from '../../ui/TemplateMessageItemBody/types'; import { CACHED_MESSAGE_TEMPLATES_KEY, CACHED_MESSAGE_TEMPLATES_TOKEN_KEY } from '../../utils/consts'; import { LoggerInterface } from '../Logger'; +import useSendbird from '../Sendbird/context/hooks/useSendbird'; +import { useCallback } from 'react'; const MESSAGE_TEMPLATES_FETCH_LIMIT = 20; +/** + * Takes JSON parsed template and then returns processed message template for storing it in global state. + */ +export const getProcessedTemplate = (parsedTemplate: SendbirdMessageTemplate): ProcessedMessageTemplate => { + return { + version: Number(parsedTemplate.ui_template.version), + uiTemplate: JSON.stringify(parsedTemplate.ui_template.body.items), + colorVariables: parsedTemplate.color_variables, + }; +}; + +export const getProcessedTemplatesMap = ( + parsedTemplates: SendbirdMessageTemplate[], +): Record => { + const processedTemplates = {}; + parsedTemplates.forEach((template) => { + processedTemplates[template.key] = getProcessedTemplate(template); + }); + return processedTemplates; +}; + interface UseMessageTemplateUtilsProps { sdk: SendbirdChat, logger: LoggerInterface, appInfoStore: AppInfoStateType, - appInfoDispatcher: React.Dispatch, + actions: ReturnType['actions'], } export interface UseMessageTemplateUtilsWrapper { @@ -22,22 +42,15 @@ export interface UseMessageTemplateUtilsWrapper { initializeMessageTemplatesInfo: (readySdk: SendbirdChat) => Promise; } -const { - INITIALIZE_MESSAGE_TEMPLATES_INFO, - UPSERT_MESSAGE_TEMPLATES, - UPSERT_WAITING_TEMPLATE_KEYS, - MARK_ERROR_WAITING_TEMPLATE_KEYS, -} = APP_INFO_ACTIONS; - export default function useMessageTemplateUtils({ sdk, logger, appInfoStore, - appInfoDispatcher, + actions, }: UseMessageTemplateUtilsProps): UseMessageTemplateUtilsWrapper { const messageTemplatesInfo: MessageTemplatesInfo | undefined = appInfoStore?.messageTemplatesInfo; - const getCachedTemplate = (key: string): ProcessedMessageTemplate | null => { + const getCachedTemplate = useCallback((key: string): ProcessedMessageTemplate | null => { if (!messageTemplatesInfo) return null; let cachedTemplate: ProcessedMessageTemplate | null = null; @@ -46,7 +59,7 @@ export default function useMessageTemplateUtils({ cachedTemplate = cachedMessageTemplates[key] ?? null; } return cachedTemplate; - }; + }, [appInfoStore?.messageTemplatesInfo]); /** * Fetches a single message template by given key and then @@ -107,7 +120,7 @@ export default function useMessageTemplateUtils({ token: sdkMessageTemplateToken, templatesMap: getProcessedTemplatesMap(parsedTemplates), }; - appInfoDispatcher({ type: INITIALIZE_MESSAGE_TEMPLATES_INFO, payload: newMessageTemplatesInfo }); + actions.initMessageTemplateInfo({ payload: newMessageTemplatesInfo }); localStorage.setItem(CACHED_MESSAGE_TEMPLATES_TOKEN_KEY, sdkMessageTemplateToken); localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify(parsedTemplates)); } else if ( @@ -120,7 +133,7 @@ export default function useMessageTemplateUtils({ token: sdkMessageTemplateToken, templatesMap: getProcessedTemplatesMap(parsedTemplates), }; - appInfoDispatcher({ type: INITIALIZE_MESSAGE_TEMPLATES_INFO, payload: newMessageTemplatesInfo }); + actions.initMessageTemplateInfo({ payload: newMessageTemplatesInfo }); } }; @@ -128,72 +141,64 @@ export default function useMessageTemplateUtils({ * If given message is a template message with template key and if the key does not exist in the cache, * update the cache by fetching the template. */ - const updateMessageTemplatesInfo = async ( + const updateMessageTemplatesInfo = useCallback(async ( templateKeys: string[], messageId: number, requestedAt: number, ): Promise => { - if (appInfoDispatcher) { - appInfoDispatcher({ - type: UPSERT_WAITING_TEMPLATE_KEYS, - payload: { + actions.upsertWaitingTemplateKeys({ keys: templateKeys, requestedAt } as any); + const newParsedTemplates: SendbirdMessageTemplate[] | null = []; + try { + let hasMore = true; + let token = null; + while (hasMore) { + const result = await sdk.message.getMessageTemplatesByToken(token, { keys: templateKeys, - requestedAt, - }, - }); - const newParsedTemplates: SendbirdMessageTemplate[] | null = []; - try { - let hasMore = true; - let token = null; - while (hasMore) { - const result = await sdk.message.getMessageTemplatesByToken(token, { - keys: templateKeys, - }); - result.templates.forEach((newTemplate) => { - newParsedTemplates.push(JSON.parse(newTemplate.template)); - }); - hasMore = result.hasMore; - token = result.token; - } - } catch (e) { - logger?.error?.('Sendbird | fetchProcessedMessageTemplates failed', e, templateKeys); + }); + result.templates.forEach((newTemplate) => { + newParsedTemplates.push(JSON.parse(newTemplate.template)); + }); + hasMore = result.hasMore; + token = result.token; } - if (newParsedTemplates.length > 0) { - // Update cache - const cachedMessageTemplates: string | null = localStorage.getItem(CACHED_MESSAGE_TEMPLATES_KEY); - if (cachedMessageTemplates) { - const parsedTemplates: SendbirdMessageTemplate[] = JSON.parse(cachedMessageTemplates); - const existingKeys = parsedTemplates.map((parsedTemplate) => parsedTemplate.key); - newParsedTemplates.forEach((newParsedTemplate) => { - if (!existingKeys.includes(newParsedTemplate.key)) { - parsedTemplates.push(newParsedTemplate); - } - }); - localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify(parsedTemplates)); - } else { - localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify([newParsedTemplates])); - } - // Update memory - appInfoDispatcher({ - type: UPSERT_MESSAGE_TEMPLATES, - payload: newParsedTemplates.map((newParsedTemplate) => { - return { - key: newParsedTemplate.key, - template: getProcessedTemplate(newParsedTemplate), - }; - }), + } catch (e) { + logger?.error?.('Sendbird | fetchProcessedMessageTemplates failed', e, templateKeys); + } + if (newParsedTemplates.length > 0) { + // Update cache + const cachedMessageTemplates: string | null = localStorage.getItem(CACHED_MESSAGE_TEMPLATES_KEY); + if (cachedMessageTemplates) { + const parsedTemplates: SendbirdMessageTemplate[] = JSON.parse(cachedMessageTemplates); + const existingKeys = parsedTemplates.map((parsedTemplate) => parsedTemplate.key); + newParsedTemplates.forEach((newParsedTemplate) => { + if (!existingKeys.includes(newParsedTemplate.key)) { + parsedTemplates.push(newParsedTemplate); + } }); + localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify(parsedTemplates)); } else { - appInfoDispatcher({ - type: MARK_ERROR_WAITING_TEMPLATE_KEYS, - payload: { - keys: templateKeys, - messageId, - }, - }); + localStorage.setItem(CACHED_MESSAGE_TEMPLATES_KEY, JSON.stringify([newParsedTemplates])); } + // Update memory + actions.upsertMessageTemplates({ + payload: newParsedTemplates.map((newParsedTemplate) => { + return { + key: newParsedTemplate.key, + template: getProcessedTemplate(newParsedTemplate), + }; + }), + } as any); + } else { + actions.markErrorWaitingTemplateKeys({ + keys: templateKeys, + messageId, + } as any); } - }; + }, [ + actions.upsertMessageTemplates, + actions.upsertWaitingTemplateKeys, + sdk.message?.getMessageTemplatesByToken, + ]); return { getCachedTemplate, updateMessageTemplatesInfo, diff --git a/src/lib/selectors.ts b/src/lib/selectors.ts index 6f33e11b9d..d46826a817 100644 --- a/src/lib/selectors.ts +++ b/src/lib/selectors.ts @@ -11,10 +11,10 @@ import { FileMessage, FileMessageCreateParams, SendableMessage, UserMessageUpdat import { SdkStore, - SendBirdState, - SendBirdStateConfig, - SendBirdStateStore, -} from './types'; + SendbirdState, + SendbirdStateConfig, + SendbirdStateStore, +} from './Sendbird/types'; import { noop } from '../utils/utils'; import { SendableMessageType } from '../utils'; import { PublishingModuleType } from '../modules/internalInterfaces'; @@ -58,9 +58,9 @@ import { PublishingModuleType } from '../modules/internalInterfaces'; /** * const sdk = selectors.getSdk(state); */ -export const getSdk = (state: SendBirdState) => { +export const getSdk = (state: SendbirdState) => { const { stores = {} } = state; - const { sdkStore = {} } = stores as SendBirdStateStore; + const { sdkStore = {} } = stores as SendbirdStateStore; const { sdk } = sdkStore as SdkStore; return sdk; }; @@ -68,9 +68,9 @@ export const getSdk = (state: SendBirdState) => { /** * const pubSub = selectors.getPubSub(state); */ -export const getPubSub = (state: SendBirdState) => { +export const getPubSub = (state: SendbirdState) => { const { config = {} } = state; - const { pubSub } = config as SendBirdStateConfig; + const { pubSub } = config as SendbirdStateConfig; return pubSub; }; @@ -82,7 +82,7 @@ export const getPubSub = (state: SendBirdState) => { * .then((user) => {}) * .catch((error) => {}) */ -export const getConnect = (state: SendBirdState) => ( +export const getConnect = (state: SendbirdState) => ( (userId: string, accessToken?: string): Promise => ( new Promise((resolve, reject) => { const sdk = getSdk(state); @@ -111,7 +111,7 @@ export const getConnect = (state: SendBirdState) => ( * .then(() => {}) * .catch((error) => {}) */ -export const getDisconnect = (state: SendBirdState) => ( +export const getDisconnect = (state: SendbirdState) => ( (): Promise => ( new Promise((resolve, reject) => { const sdk = getSdk(state); @@ -134,7 +134,7 @@ export const getDisconnect = (state: SendBirdState) => ( * .then((user) => {}) * .catch((error) => {}) */ -export const getUpdateUserInfo = (state: SendBirdState) => ( +export const getUpdateUserInfo = (state: SendbirdState) => ( (nickname: string, profileUrl?: string): Promise => ( new Promise((resolve, reject) => { const sdk = getSdk(state); @@ -163,7 +163,7 @@ export const getUpdateUserInfo = (state: SendBirdState) => ( * .then((channel) => {}) * .catch((error) => {}) */ -export const getCreateGroupChannel = (state: SendBirdState) => ( +export const getCreateGroupChannel = (state: SendbirdState) => ( (params: GroupChannelCreateParams): Promise => ( new Promise((resolve, reject) => { const sdk = getSdk(state); @@ -196,7 +196,7 @@ export const getCreateGroupChannel = (state: SendBirdState) => ( * .then((channel) => {}) * .catch((error) => {}) */ -export const getCreateOpenChannel = (state: SendBirdState) => ( +export const getCreateOpenChannel = (state: SendbirdState) => ( (params: OpenChannelCreateParams): Promise => ( new Promise((resolve, reject) => { const sdk = getSdk(state); @@ -230,7 +230,7 @@ export const getCreateOpenChannel = (state: SendBirdState) => ( * }) * .catch((error) => {}) */ -export const getGetGroupChannel = (state: SendBirdState) => ( +export const getGetGroupChannel = (state: SendbirdState) => ( (channelUrl: string): Promise => ( new Promise((resolve, reject) => { const sdk = getSdk(state); @@ -264,7 +264,7 @@ export const getGetGroupChannel = (state: SendBirdState) => ( * }) * .catch((error) => {}) */ -export const getGetOpenChannel = (state: SendBirdState) => ( +export const getGetOpenChannel = (state: SendbirdState) => ( (channelUrl: string): Promise => ( new Promise((resolve, reject) => { const sdk = getSdk(state); @@ -294,7 +294,7 @@ export const getGetOpenChannel = (state: SendBirdState) => ( * .then((channel) => {}) * .catch((error) => {}) */ -export const getLeaveGroupChannel = (state: SendBirdState) => ( +export const getLeaveGroupChannel = (state: SendbirdState) => ( (channelUrl: string): Promise => ( new Promise((resolve, reject) => { getGetGroupChannel(state)?.(channelUrl) @@ -317,7 +317,7 @@ export const getLeaveGroupChannel = (state: SendBirdState) => ( * .then((channel) => {}) * .catch((error) => {}) */ -export const getEnterOpenChannel = (state: SendBirdState) => ( +export const getEnterOpenChannel = (state: SendbirdState) => ( (channelUrl: string): Promise => ( new Promise((resolve, reject) => { getGetOpenChannel(state)?.(channelUrl) @@ -340,7 +340,7 @@ export const getEnterOpenChannel = (state: SendBirdState) => ( * .then((channel) => {}) * .catch((error) => {}) */ -export const getExitOpenChannel = (state: SendBirdState) => ( +export const getExitOpenChannel = (state: SendbirdState) => ( (channelUrl: string): Promise => ( new Promise((resolve, reject) => { getGetOpenChannel(state)?.(channelUrl) @@ -462,7 +462,7 @@ export class UikitMessageHandler { * .onSucceeded((message) => {}) */ -export const getSendUserMessage = (state: SendBirdState, publishingModules: PublishingModuleType[] = []) => ( +export const getSendUserMessage = (state: SendbirdState, publishingModules: PublishingModuleType[] = []) => ( (channel: GroupChannel | OpenChannel, params: UserMessageCreateParams): UikitMessageHandler => { const handler = new UikitMessageHandler(); const pubSub = getPubSub(state); @@ -502,7 +502,7 @@ export const getSendUserMessage = (state: SendBirdState, publishingModules: Publ * .onFailed((error, message) => {}) * .onSucceeded((message) => {}) */ -export const getSendFileMessage = (state: SendBirdState, publishingModules: PublishingModuleType[] = []) => ( +export const getSendFileMessage = (state: SendbirdState, publishingModules: PublishingModuleType[] = []) => ( (channel: GroupChannel | OpenChannel, params: FileMessageCreateParams): UikitMessageHandler => { const handler = new UikitMessageHandler(); const pubSub = getPubSub(state); @@ -542,7 +542,7 @@ export const getSendFileMessage = (state: SendBirdState, publishingModules: Publ * .then((message) => {}) * .catch((error) => {}) */ -export const getUpdateUserMessage = (state: SendBirdState, publishingModules: PublishingModuleType[] = []) => ( +export const getUpdateUserMessage = (state: SendbirdState, publishingModules: PublishingModuleType[] = []) => ( (channel: GroupChannel | OpenChannel, messageId: number, params: UserMessageUpdateParams): Promise => ( new Promise((resolve, reject) => { const pubSub = getPubSub(state); @@ -570,7 +570,7 @@ export const getUpdateUserMessage = (state: SendBirdState, publishingModules: Pu * .then((message) => {}) * .catch((error) => {}) */ -// const getUpdateFileMessage = (state: SendBirdState) => ( +// const getUpdateFileMessage = (state: SendbirdState) => ( // (channel: GroupChannel | OpenChannel, messageId: number, params: FileMessageUpdateParams) => ( // new Promise((resolve, reject) => { // const pubSub = getPubSub(state); @@ -596,7 +596,7 @@ export const getUpdateUserMessage = (state: SendBirdState, publishingModules: Pu * .then((deletedMessage) => {}) * .catch((error) => {}) */ -export const getDeleteMessage = (state: SendBirdState) => ( +export const getDeleteMessage = (state: SendbirdState) => ( (channel: GroupChannel | OpenChannel, message: SendableMessageType): Promise => ( new Promise((resolve, reject) => { const pubSub = getPubSub(state); @@ -623,7 +623,7 @@ export const getDeleteMessage = (state: SendBirdState) => ( * .then(() => {}) * .catch((error) => {}) */ -export const getResendUserMessage = (state: SendBirdState, publishingModules: PublishingModuleType[] = []) => ( +export const getResendUserMessage = (state: SendbirdState, publishingModules: PublishingModuleType[] = []) => ( (channel: GroupChannel | OpenChannel, failedMessage: UserMessage): Promise => ( new Promise((resolve, reject) => { const pubSub = getPubSub(state); @@ -650,7 +650,7 @@ export const getResendUserMessage = (state: SendBirdState, publishingModules: Pu * .then(() => {}) * .catch((error) => {}) */ -export const getResendFileMessage = (state: SendBirdState, publishingModules: PublishingModuleType[] = []) => ( +export const getResendFileMessage = (state: SendbirdState, publishingModules: PublishingModuleType[] = []) => ( (channel: GroupChannel | OpenChannel, failedMessage: FileMessage, blob: Blob): Promise => ( new Promise((resolve, reject) => { const pubSub = getPubSub(state); diff --git a/src/lib/types.ts b/src/lib/types.ts deleted file mode 100644 index ae4b57d057..0000000000 --- a/src/lib/types.ts +++ /dev/null @@ -1,316 +0,0 @@ -import React from 'react'; -import type SendbirdChat from '@sendbird/chat'; -import type { User, SendbirdChatParams, SendbirdError } from '@sendbird/chat'; - -import type { - GroupChannel, - GroupChannelCreateParams, - GroupChannelModule, -} from '@sendbird/chat/groupChannel'; -import type { - OpenChannel, - OpenChannelCreateParams, - OpenChannelModule, -} from '@sendbird/chat/openChannel'; -import type { - FileMessage, - FileMessageCreateParams, - UserMessage, - UserMessageCreateParams, - UserMessageUpdateParams, -} from '@sendbird/chat/message'; -import { SBUConfig } from '@sendbird/uikit-tools'; -import { Module, ModuleNamespaces } from '@sendbird/chat/lib/__definition'; - -import type { - HTMLTextDirection, - RenderUserProfileProps, - ReplyType, - UserListQuery, -} from '../types'; -import type { ImageCompressionOptions } from './Sendbird'; -import { UikitMessageHandler } from './selectors'; -import { Logger } from './SendbirdState'; -import { MarkAsReadSchedulerType } from './hooks/useMarkAsReadScheduler'; -import { MarkAsDeliveredSchedulerType } from './hooks/useMarkAsDeliveredScheduler'; -import { PartialDeep } from '../utils/typeHelpers/partialDeep'; -import { CoreMessageType } from '../utils'; -import { UserActionTypes } from './dux/user/actionTypes'; -import { SdkActionTypes } from './dux/sdk/actionTypes'; -import { ReconnectType } from './hooks/useConnect/types'; -import { SBUGlobalPubSub } from './pubSub/topics'; -import { EmojiManager } from './emojiManager'; -import { MessageTemplatesInfo, ProcessedMessageTemplate, WaitingTemplateKeyData } from './dux/appInfo/initialState'; -import { AppInfoActionTypes } from './dux/appInfo/actionTypes'; - -// note to SDK team: -// using enum inside .d.ts won’t work for jest, but const enum will work. -export const Role = { - OPERATOR: 'operator', - NONE: 'none', -} as const; - -export interface SBUEventHandlers { - reaction?: { - onPressUserProfile?(member: User): void; - }, - connection?: { - onConnected?(user: User): void; - onFailed?(error: SendbirdError): void; - }, - modal?: { - onMounted?(params: { id: string; close(): void; }): void | (() => void); - }; - message?: { - onSendMessageFailed?: (message: CoreMessageType, error: unknown) => void; - onUpdateMessageFailed?: (message: CoreMessageType, error: unknown) => void; - onFileUploadFailed?: (error: unknown) => void; - } -} - -export interface SendBirdStateConfig { - renderUserProfile?: (props: RenderUserProfileProps) => React.ReactElement; - onStartDirectMessage?: (props: GroupChannel) => void; - allowProfileEdit: boolean; - isOnline: boolean; - userId: string; - appId: string; - accessToken?: string; - theme: string; - htmlTextDirection: HTMLTextDirection; - forceLeftToRightMessageLayout: boolean; - pubSub: SBUGlobalPubSub; - logger: Logger; - setCurrentTheme: (theme: 'light' | 'dark') => void; - userListQuery?: () => UserListQuery; - uikitUploadSizeLimit: number; - uikitMultipleFilesMessageLimit: number; - voiceRecord: { - maxRecordingTime: number; - minRecordingTime: number; - }; - userMention: { - maxMentionCount: number, - maxSuggestionCount: number, - }; - imageCompression: ImageCompressionOptions; - markAsReadScheduler: MarkAsReadSchedulerType; - markAsDeliveredScheduler: MarkAsDeliveredSchedulerType; - disableMarkAsDelivered: boolean; - isMultipleFilesMessageEnabled: boolean; - // Remote configs set from dashboard by UIKit feature configuration - common: { - enableUsingDefaultUserProfile: SBUConfig['common']['enableUsingDefaultUserProfile']; - }, - groupChannel: { - enableOgtag: SBUConfig['groupChannel']['channel']['enableOgtag']; - enableTypingIndicator: SBUConfig['groupChannel']['channel']['enableTypingIndicator']; - enableReactions: SBUConfig['groupChannel']['channel']['enableReactions']; - enableMention: SBUConfig['groupChannel']['channel']['enableMention']; - replyType: SBUConfig['groupChannel']['channel']['replyType']; - threadReplySelectType: SBUConfig['groupChannel']['channel']['threadReplySelectType']; - enableVoiceMessage: SBUConfig['groupChannel']['channel']['enableVoiceMessage']; - typingIndicatorTypes: SBUConfig['groupChannel']['channel']['typingIndicatorTypes']; - enableDocument: SBUConfig['groupChannel']['channel']['input']['enableDocument']; - enableFeedback: SBUConfig['groupChannel']['channel']['enableFeedback']; - enableSuggestedReplies: SBUConfig['groupChannel']['channel']['enableSuggestedReplies']; - showSuggestedRepliesFor: SBUConfig['groupChannel']['channel']['showSuggestedRepliesFor']; - suggestedRepliesDirection: SBUConfig['groupChannel']['channel']['suggestedRepliesDirection']; - enableMarkdownForUserMessage: SBUConfig['groupChannel']['channel']['enableMarkdownForUserMessage']; - enableFormTypeMessage: SBUConfig['groupChannel']['channel']['enableFormTypeMessage']; - /** - * @deprecated Currently, this feature is turned off by default. If you wish to use this feature, contact us: {@link https://dashboard.sendbird.com/settings/contact_us?category=feedback_and_feature_requests&product=UIKit} - */ - enableReactionsSupergroup: never; - }, - groupChannelList: { - enableTypingIndicator: SBUConfig['groupChannel']['channelList']['enableTypingIndicator']; - enableMessageReceiptStatus: SBUConfig['groupChannel']['channelList']['enableMessageReceiptStatus']; - }, - groupChannelSettings: { - enableMessageSearch: SBUConfig['groupChannel']['setting']['enableMessageSearch']; - }, - openChannel: { - enableOgtag: SBUConfig['openChannel']['channel']['enableOgtag']; - enableDocument: SBUConfig['openChannel']['channel']['input']['enableDocument']; - }, - /** - * @deprecated Please use `onStartDirectMessage` instead. It's renamed. - */ - onUserProfileMessage?: (props: GroupChannel) => void; - /** - * @deprecated Please use `!config.common.enableUsingDefaultUserProfile` instead. - * Note that you should use the negation of `config.common.enableUsingDefaultUserProfile` - * to replace `disableUserProfile`. - */ - disableUserProfile: boolean; - /** @deprecated Please use `config.groupChannel.enableReactions` instead * */ - isReactionEnabled: boolean; - /** @deprecated Please use `config.groupChannel.enableMention` instead * */ - isMentionEnabled: boolean; - /** @deprecated Please use `config.groupChannel.enableVoiceMessage` instead * */ - isVoiceMessageEnabled?: boolean; - /** @deprecated Please use `config.groupChannel.replyType` instead * */ - replyType: ReplyType; - /** @deprecated Please use `config.groupChannelSettings.enableMessageSearch` instead * */ - showSearchIcon?: boolean; - /** @deprecated Please use `config.groupChannelList.enableTypingIndicator` instead * */ - isTypingIndicatorEnabledOnChannelList?: boolean; - /** @deprecated Please use `config.groupChannelList.enableMessageReceiptStatus` instead * */ - isMessageReceiptStatusEnabledOnChannelList?: boolean; - /** @deprecated Please use setCurrentTheme instead * */ - setCurrenttheme: (theme: 'light' | 'dark') => void; -} - -export type SendbirdChatType = SendbirdChat & ModuleNamespaces<[GroupChannelModule, OpenChannelModule]>; -export interface SdkStore { - error: boolean; - initialized: boolean; - loading: boolean; - sdk: SendbirdChatType; -} - -export interface UserStore { - initialized: boolean; - loading: boolean; - user: User; -} - -export interface AppInfoStore { - messageTemplatesInfo?: MessageTemplatesInfo; - waitingTemplateKeysMap: Record; -} - -export interface SendBirdStateStore { - sdkStore: SdkStore; - userStore: UserStore; - appInfoStore: AppInfoStore; -} - -export type SendBirdState = { - config: SendBirdStateConfig; - stores: SendBirdStateStore; - dispatchers: { - sdkDispatcher: React.Dispatch, - userDispatcher: React.Dispatch, - appInfoDispatcher: React.Dispatch, - reconnect: ReconnectType, - }, - // Customer provided callbacks - eventHandlers?: SBUEventHandlers; - emojiManager: EmojiManager; - utils: SendbirdProviderUtils; -}; - -type GetSdk = SendbirdChat | undefined; -type GetConnect = ( - userId: string, - accessToken?: string -) => Promise; -type GetDisconnect = () => Promise; -type GetUpdateUserInfo = ( - nickName: string, - profileUrl?: string -) => Promise; -type GetCreateGroupChannel = (channelParams: GroupChannelCreateParams) => Promise; -type GetCreateOpenChannel = (channelParams: OpenChannelCreateParams) => Promise; -type GetGetGroupChannel = ( - channelUrl: string, - isSelected?: boolean, -) => Promise; -type GetGetOpenChannel = ( - channelUrl: string, -) => Promise; -type GetLeaveGroupChannel = (channel: GroupChannel) => Promise; -type GetEnterOpenChannel = (channel: OpenChannel) => Promise; -type GetExitOpenChannel = (channel: OpenChannel) => Promise; -type GetFreezeChannel = (channel: GroupChannel | OpenChannel) => Promise; -type GetUnFreezeChannel = (channel: GroupChannel | OpenChannel) => Promise; -type GetSendUserMessage = ( - channel: GroupChannel | OpenChannel, - userMessageParams: UserMessageCreateParams, -) => UikitMessageHandler; -type GetSendFileMessage = ( - channel: GroupChannel | OpenChannel, - fileMessageParams: FileMessageCreateParams -) => UikitMessageHandler; -type GetUpdateUserMessage = ( - channel: GroupChannel | OpenChannel, - messageId: string | number, - params: UserMessageUpdateParams -) => Promise; -// type getUpdateFileMessage = ( -// channel: GroupChannel | OpenChannel, -// messageId: string | number, -// params: FileMessageUpdateParams, -// ) => Promise; -type GetDeleteMessage = ( - channel: GroupChannel | OpenChannel, - message: CoreMessageType -) => Promise; -type GetResendUserMessage = ( - channel: GroupChannel | OpenChannel, - failedMessage: UserMessage -) => Promise; -type GetResendFileMessage = ( - channel: GroupChannel | OpenChannel, - failedMessage: FileMessage -) => Promise; - -export interface sendbirdSelectorsInterface { - getSdk: (store: SendBirdState) => GetSdk; - getConnect: (store: SendBirdState) => GetConnect - getDisconnect: (store: SendBirdState) => GetDisconnect; - getUpdateUserInfo: (store: SendBirdState) => GetUpdateUserInfo; - getCreateGroupChannel: (store: SendBirdState) => GetCreateGroupChannel; - getCreateOpenChannel: (store: SendBirdState) => GetCreateOpenChannel; - getGetGroupChannel: (store: SendBirdState) => GetGetGroupChannel; - getGetOpenChannel: (store: SendBirdState) => GetGetOpenChannel; - getLeaveGroupChannel: (store: SendBirdState) => GetLeaveGroupChannel; - getEnterOpenChannel: (store: SendBirdState) => GetEnterOpenChannel; - getExitOpenChannel: (store: SendBirdState) => GetExitOpenChannel; - getFreezeChannel: (store: SendBirdState) => GetFreezeChannel; - getUnFreezeChannel: (store: SendBirdState) => GetUnFreezeChannel; - getSendUserMessage: (store: SendBirdState) => GetSendUserMessage; - getSendFileMessage: (store: SendBirdState) => GetSendFileMessage; - getUpdateUserMessage: (store: SendBirdState) => GetUpdateUserMessage; - // getUpdateFileMessage: (store: SendBirdState) => GetUpdateFileMessage; - getDeleteMessage: (store: SendBirdState) => GetDeleteMessage; - getResendUserMessage: (store: SendBirdState) => GetResendUserMessage; - getResendFileMessage: (store: SendBirdState) => GetResendFileMessage; -} - -export interface CommonUIKitConfigProps { - /** @deprecated Please use `uikitOptions.common.enableUsingDefaultUserProfile` instead * */ - disableUserProfile?: boolean; - /** @deprecated Please use `uikitOptions.groupChannel.replyType` instead * */ - replyType?: 'NONE' | 'QUOTE_REPLY' | 'THREAD'; - /** @deprecated Please use `uikitOptions.groupChannel.enableReactions` instead * */ - isReactionEnabled?: boolean; - /** @deprecated Please use `uikitOptions.groupChannel.enableMention` instead * */ - isMentionEnabled?: boolean; - /** @deprecated Please use `uikitOptions.groupChannel.enableVoiceMessage` instead * */ - isVoiceMessageEnabled?: boolean; - /** @deprecated Please use `uikitOptions.groupChannelList.enableTypingIndicator` instead * */ - isTypingIndicatorEnabledOnChannelList?: boolean; - /** @deprecated Please use `uikitOptions.groupChannelList.enableMessageReceiptStatus` instead * */ - isMessageReceiptStatusEnabledOnChannelList?: boolean; - /** @deprecated Please use `uikitOptions.groupChannelSettings.enableMessageSearch` instead * */ - showSearchIcon?: boolean; -} - -export type UIKitOptions = PartialDeep<{ - common: SBUConfig['common']; - groupChannel: SBUConfig['groupChannel']['channel']; - groupChannelList: SBUConfig['groupChannel']['channelList']; - groupChannelSettings: SBUConfig['groupChannel']['setting']; - openChannel: SBUConfig['openChannel']['channel']; -}>; - -export type SendbirdChatInitParams = Omit, 'appId'>; -export type CustomExtensionParams = Record; - -export type SendbirdProviderUtils = { - updateMessageTemplatesInfo: (templateKeys: string[], messageId: number, createdAt: number) => Promise; - getCachedTemplate: (key: string) => ProcessedMessageTemplate | null; -}; diff --git a/src/lib/utils/__tests__/uikitConfigMapper.spec.ts b/src/lib/utils/__tests__/uikitConfigMapper.spec.ts index 5171b6632b..e9160db993 100644 --- a/src/lib/utils/__tests__/uikitConfigMapper.spec.ts +++ b/src/lib/utils/__tests__/uikitConfigMapper.spec.ts @@ -1,6 +1,6 @@ +import type { CommonUIKitConfigProps, UIKitOptions } from '../../Sendbird/types'; import { getCaseResolvedReplyType } from '../resolvedReplyType'; import { uikitConfigMapper } from '../uikitConfigMapper'; -import { CommonUIKitConfigProps, UIKitOptions } from '../../types'; const mockLegacyConfig = { // common related diff --git a/src/lib/utils/uikitConfigMapper.ts b/src/lib/utils/uikitConfigMapper.ts index 574dfc0175..9ecdbb4719 100644 --- a/src/lib/utils/uikitConfigMapper.ts +++ b/src/lib/utils/uikitConfigMapper.ts @@ -1,4 +1,4 @@ -import { UIKitOptions, CommonUIKitConfigProps } from '../types'; +import type { UIKitOptions, CommonUIKitConfigProps } from '../Sendbird/types'; import { getCaseResolvedReplyType } from './resolvedReplyType'; export function uikitConfigMapper({ diff --git a/src/modules/App/AppLayout.tsx b/src/modules/App/AppLayout.tsx index 62aa7e2526..c0d1600116 100644 --- a/src/modules/App/AppLayout.tsx +++ b/src/modules/App/AppLayout.tsx @@ -6,9 +6,9 @@ import { useMediaQueryContext } from '../../lib/MediaQueryContext'; import { DesktopLayout } from './DesktopLayout'; import { MobileLayout } from './MobileLayout'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { SendableMessageType } from '../../utils'; import { getCaseResolvedReplyType } from '../../lib/utils/resolvedReplyType'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export const AppLayout = (props: AppLayoutProps) => { const { @@ -21,8 +21,8 @@ export const AppLayout = (props: AppLayoutProps) => { enableLegacyChannelModules, } = props; - const globalStore = useSendbirdStateContext(); - const globalConfigs = globalStore.config; + const { state } = useSendbird(); + const globalConfigs = state.config; const [showThread, setShowThread] = useState(false); const [threadTargetMessage, setThreadTargetMessage] = useState(null); diff --git a/src/modules/App/MobileLayout.tsx b/src/modules/App/MobileLayout.tsx index fae2ee3bc9..c1766fe8cc 100644 --- a/src/modules/App/MobileLayout.tsx +++ b/src/modules/App/MobileLayout.tsx @@ -13,11 +13,11 @@ import ChannelList from '../ChannelList'; import ChannelSettings from '../ChannelSettings'; import MessageSearch from '../MessageSearch'; import Thread from '../Thread'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import uuidv4 from '../../utils/uuid'; import { ALL, useVoicePlayerContext } from '../../hooks/VoicePlayer'; import { SendableMessageType } from '../../utils'; import { APP_LAYOUT_ROOT } from './const'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; enum PANELS { CHANNEL_LIST = 'CHANNEL_LIST', @@ -48,7 +48,7 @@ export const MobileLayout: React.FC = (props: MobileLayoutPro } = props; const [panel, setPanel] = useState(PANELS.CHANNEL_LIST); - const store = useSendbirdStateContext(); + const { state: store } = useSendbird(); const sdk = store?.stores?.sdkStore?.sdk; const userId = store?.config?.userId; diff --git a/src/modules/App/types.ts b/src/modules/App/types.ts index 2fe213a97d..7d7db6bafa 100644 --- a/src/modules/App/types.ts +++ b/src/modules/App/types.ts @@ -8,7 +8,7 @@ import { SendBirdProviderConfig, HTMLTextDirection, } from '../../types'; -import { CustomExtensionParams, SBUEventHandlers, SendbirdChatInitParams } from '../../lib/types'; +import { CustomExtensionParams, SBUEventHandlers, SendbirdChatInitParams } from '../../lib/Sendbird/types'; import { SendableMessageType } from '../../utils'; export interface AppLayoutProps { diff --git a/src/modules/Channel/components/Message/index.tsx b/src/modules/Channel/components/Message/index.tsx index a55423a4fe..67b9ef301e 100644 --- a/src/modules/Channel/components/Message/index.tsx +++ b/src/modules/Channel/components/Message/index.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useChannelContext } from '../../context/ChannelProvider'; import { getSuggestedReplies } from '../../../../utils'; import { isDisabledBecauseFrozen, isDisabledBecauseMuted } from '../../context/utils'; import MessageView, { MessageProps } from '../../../GroupChannel/components/Message/MessageView'; import FileViewer from '../FileViewer'; import RemoveMessageModal from '../RemoveMessageModal'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; const Message = (props: MessageProps) => { - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { initialized, currentGroupChannel, diff --git a/src/modules/Channel/components/MessageList/index.tsx b/src/modules/Channel/components/MessageList/index.tsx index dadde67620..d9e7d93794 100644 --- a/src/modules/Channel/components/MessageList/index.tsx +++ b/src/modules/Channel/components/MessageList/index.tsx @@ -13,7 +13,6 @@ import { isAboutSame } from '../../context/utils'; import UnreadCount from '../UnreadCount'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { MessageProvider } from '../../../Message/context/MessageProvider'; import { useHandleOnScrollCallback } from '../../../../hooks/useHandleOnScrollCallback'; import { useSetScrollToBottom } from './hooks/useSetScrollToBottom'; @@ -26,6 +25,7 @@ import { GroupChannelMessageListProps } from '../../../GroupChannel/components/M import { GroupChannelUIBasicProps } from '../../../GroupChannel/components/GroupChannelUI/GroupChannelUIView'; import { deleteNullish } from '../../../../utils/utils'; import { getHTMLTextDirection } from '../../../../utils'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; const SCROLL_BOTTOM_PADDING = 50; @@ -72,7 +72,7 @@ export const MessageList = (props: MessageListProps) => { typingMembers, } = useChannelContext(); - const store = useSendbirdStateContext(); + const { state: store } = useSendbird(); const allMessagesFiltered = typeof filterMessageList === 'function' ? allMessages.filter(filterMessageList) : allMessages; const markAsReadScheduler = store.config.markAsReadScheduler; diff --git a/src/modules/Channel/context/ChannelProvider.tsx b/src/modules/Channel/context/ChannelProvider.tsx index 8027d44cee..3c0aae6108 100644 --- a/src/modules/Channel/context/ChannelProvider.tsx +++ b/src/modules/Channel/context/ChannelProvider.tsx @@ -21,7 +21,6 @@ import type { EmojiContainer, SendbirdError, User } from '@sendbird/chat'; import { ReplyType, Nullable } from '../../../types'; import { UserProfileProvider, UserProfileProviderProps } from '../../../lib/UserProfileContext'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import { CoreMessageType, SendableMessageType } from '../../../utils'; import { ThreadReplySelectType } from './const'; @@ -51,6 +50,7 @@ import { useSendMultipleFilesMessage } from './hooks/useSendMultipleFilesMessage import { useHandleChannelPubsubEvents } from './hooks/useHandleChannelPubsubEvents'; import { PublishingModuleType } from '../../internalInterfaces'; import { ChannelActionTypes } from './dux/actionTypes'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export { ThreadReplySelectType } from './const'; // export for external usage @@ -199,8 +199,7 @@ const ChannelProvider = (props: ChannelContextProps) => { scrollBehavior = 'auto', reconnectOnIdle = true, } = props; - - const globalStore = useSendbirdStateContext(); + const { state: globalStore } = useSendbird(); const { config } = globalStore; const replyType = props.replyType ?? getCaseResolvedReplyType(config.groupChannel.replyType).upperCase; const { diff --git a/src/modules/Channel/context/hooks/__test__/useSendMultipleFilesMessage.spec.ts b/src/modules/Channel/context/hooks/__test__/useSendMultipleFilesMessage.spec.ts index eeece91155..2d97fd5238 100644 --- a/src/modules/Channel/context/hooks/__test__/useSendMultipleFilesMessage.spec.ts +++ b/src/modules/Channel/context/hooks/__test__/useSendMultipleFilesMessage.spec.ts @@ -8,7 +8,7 @@ import { UseSendMFMStaticParams, useSendMultipleFilesMessage, } from '../useSendMultipleFilesMessage'; -import { Logger } from '../../../../../lib/SendbirdState'; +import type { Logger } from '../../../../../lib/Sendbird/types'; import { MockMessageRequestHandlerType, getMockMessageRequestHandler, diff --git a/src/modules/Channel/context/hooks/useGetChannel.ts b/src/modules/Channel/context/hooks/useGetChannel.ts index 872c689c8c..ed08b5c620 100644 --- a/src/modules/Channel/context/hooks/useGetChannel.ts +++ b/src/modules/Channel/context/hooks/useGetChannel.ts @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import * as messageActionTypes from '../dux/actionTypes'; import { ChannelActionTypes } from '../dux/actionTypes'; -import { SdkStore } from '../../../../lib/types'; +import type { SdkStore } from '../../../../lib/Sendbird/types'; import { LoggerInterface } from '../../../../lib/Logger'; import { MarkAsReadSchedulerType } from '../../../../lib/hooks/useMarkAsReadScheduler'; diff --git a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts index ba1e7f34ea..9641668235 100644 --- a/src/modules/Channel/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/Channel/context/hooks/useHandleChannelEvents.ts @@ -6,11 +6,11 @@ import { scrollIntoLast } from '../utils'; import uuidv4 from '../../../../utils/uuid'; import compareIds from '../../../../utils/compareIds'; import * as messageActions from '../dux/actionTypes'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { SendableMessageType } from '../../../../utils'; import { ChannelActionTypes } from '../dux/actionTypes'; import { LoggerInterface } from '../../../../lib/Logger'; -import { SdkStore } from '../../../../lib/types'; +import type { SdkStore } from '../../../../lib/Sendbird/types'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; /** * Handles ChannelEvents and send values to dispatcher using messagesDispatcher @@ -47,7 +47,7 @@ function useHandleChannelEvents({ setQuoteMessage, messagesDispatcher, }: StaticParams): void { - const store = useSendbirdStateContext(); + const { state: store } = useSendbird(); const { markAsReadScheduler, markAsDeliveredScheduler, diff --git a/src/modules/Channel/context/hooks/useHandleReconnect.ts b/src/modules/Channel/context/hooks/useHandleReconnect.ts index 6672afd072..efa20d4f7e 100644 --- a/src/modules/Channel/context/hooks/useHandleReconnect.ts +++ b/src/modules/Channel/context/hooks/useHandleReconnect.ts @@ -5,12 +5,11 @@ import { MessageListParams, ReplyType } from '@sendbird/chat/message'; import * as utils from '../utils'; import { PREV_RESULT_SIZE, NEXT_RESULT_SIZE } from '../const'; import * as messageActionTypes from '../dux/actionTypes'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import { MarkAsReadSchedulerType } from '../../../../lib/hooks/useMarkAsReadScheduler'; import useReconnectOnIdle from './useReconnectOnIdle'; import { ChannelActionTypes } from '../dux/actionTypes'; import { CoreMessageType } from '../../../../utils'; -import { SdkStore } from '../../../../lib/types'; import { SCROLL_BOTTOM_DELAY_FOR_FETCH } from '../../../../utils/consts'; interface DynamicParams { diff --git a/src/modules/Channel/context/hooks/useHandleReconnectForChannelList.ts b/src/modules/Channel/context/hooks/useHandleReconnectForChannelList.ts index 5bc91eab38..2852257aab 100644 --- a/src/modules/Channel/context/hooks/useHandleReconnectForChannelList.ts +++ b/src/modules/Channel/context/hooks/useHandleReconnectForChannelList.ts @@ -4,9 +4,8 @@ import { GroupChannel, GroupChannelListQuery, } from '@sendbird/chat/groupChannel'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import useReconnectOnIdle from './useReconnectOnIdle'; -import { SdkStore } from '../../../../lib/types'; import { ChannelListActionTypes } from '../../../ChannelList/dux/actionTypes'; import { GroupChannelListQueryParamsInternal } from '../../../ChannelList/context/ChannelListProvider'; import { MarkAsDeliveredSchedulerType } from '../../../../lib/hooks/useMarkAsDeliveredScheduler'; diff --git a/src/modules/Channel/context/hooks/useHandleUploadFiles.tsx b/src/modules/Channel/context/hooks/useHandleUploadFiles.tsx index 6292365f91..31877b5c0a 100644 --- a/src/modules/Channel/context/hooks/useHandleUploadFiles.tsx +++ b/src/modules/Channel/context/hooks/useHandleUploadFiles.tsx @@ -1,8 +1,7 @@ import React, { useCallback } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; +import { Logger } from '../../../../lib/Sendbird/types'; import { SendMFMFunctionType } from './useSendMultipleFilesMessage'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { SendableMessageType, isImage } from '../../../../utils'; // TODO: get SendFileMessageFunctionType from Channel import { SendFileMessageFunctionType } from '../../../Thread/context/hooks/useSendFileMessage'; @@ -13,6 +12,7 @@ import { ModalFooter } from '../../../../ui/Modal'; import { FileMessage, MultipleFilesMessage } from '@sendbird/chat/message'; import { compressImages } from '../../../../utils/compressImages'; import { ONE_MiB } from '../../../../utils/consts'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; /** * The handleUploadFiles is a function sending a FileMessage and MultipleFilesMessage @@ -36,7 +36,7 @@ export const useHandleUploadFiles = ({ logger, }: useHandleUploadFilesStaticProps) => { const { stringSet } = useLocalization(); - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { imageCompression } = config; const uikitUploadSizeLimit = config?.uikitUploadSizeLimit; const uikitMultipleFilesMessageLimit = config?.uikitMultipleFilesMessageLimit; diff --git a/src/modules/Channel/context/hooks/useScrollCallback.ts b/src/modules/Channel/context/hooks/useScrollCallback.ts index ab676d7c2d..38a5e45b20 100644 --- a/src/modules/Channel/context/hooks/useScrollCallback.ts +++ b/src/modules/Channel/context/hooks/useScrollCallback.ts @@ -9,7 +9,7 @@ import { MessageListParams as MessageListParamsInternal } from '../ChannelProvid import { LoggerInterface } from '../../../../lib/Logger'; import { ChannelActionTypes } from '../dux/actionTypes'; import { CoreMessageType } from '../../../../utils'; -import { SdkStore } from '../../../../lib/types'; +import type { SdkStore } from '../../../../lib/Sendbird/types'; type UseScrollCallbackOptions = { currentGroupChannel: GroupChannel | null; diff --git a/src/modules/Channel/context/hooks/useScrollDownCallback.ts b/src/modules/Channel/context/hooks/useScrollDownCallback.ts index c61d459ef1..63d0896adf 100644 --- a/src/modules/Channel/context/hooks/useScrollDownCallback.ts +++ b/src/modules/Channel/context/hooks/useScrollDownCallback.ts @@ -1,11 +1,11 @@ import React, { useCallback } from 'react'; +import type { SdkStore } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; import { ChannelActionTypes } from '../dux/actionTypes'; import { NEXT_RESULT_SIZE } from '../const'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { LoggerInterface } from '../../../../lib/Logger'; -import { SdkStore } from '../../../../lib/types'; import { ReplyType as ReplyTypeInternal } from '../../../../types'; import { MessageListParams as MessageListParamsInternal } from '../ChannelProvider'; import { BaseMessage, MessageListParams, ReplyType } from '@sendbird/chat/message'; diff --git a/src/modules/Channel/context/hooks/useSendFileMessageCallback.ts b/src/modules/Channel/context/hooks/useSendFileMessageCallback.ts index 37cabca5f0..fc13966c3c 100644 --- a/src/modules/Channel/context/hooks/useSendFileMessageCallback.ts +++ b/src/modules/Channel/context/hooks/useSendFileMessageCallback.ts @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { FileMessage, FileMessageCreateParams } from '@sendbird/chat/message'; +import type { SendbirdState } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; import { ChannelActionTypes } from '../dux/actionTypes'; import * as utils from '../utils'; @@ -9,13 +10,12 @@ import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { PublishingModuleType } from '../../../internalInterfaces'; import { LoggerInterface } from '../../../../lib/Logger'; import { SendableMessageType } from '../../../../utils'; -import { SendBirdState } from '../../../../lib/types'; import { SCROLL_BOTTOM_DELAY_FOR_SEND } from '../../../../utils/consts'; type UseSendFileMessageCallbackOptions = { currentGroupChannel: null | GroupChannel; onBeforeSendFileMessage?: (file: File, quoteMessage?: SendableMessageType) => FileMessageCreateParams; - imageCompression?: SendBirdState['config']['imageCompression']; + imageCompression?: SendbirdState['config']['imageCompression']; }; type UseSendFileMessageCallbackParams = { logger: LoggerInterface; diff --git a/src/modules/Channel/context/hooks/useSendMultipleFilesMessage.ts b/src/modules/Channel/context/hooks/useSendMultipleFilesMessage.ts index f196b6fdd3..bff9d72de0 100644 --- a/src/modules/Channel/context/hooks/useSendMultipleFilesMessage.ts +++ b/src/modules/Channel/context/hooks/useSendMultipleFilesMessage.ts @@ -3,7 +3,7 @@ import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { MultipleFilesMessageCreateParams, UploadableFileInfo } from '@sendbird/chat/message'; import { MultipleFilesMessage } from '@sendbird/chat/message'; -import type { Logger } from '../../../../lib/SendbirdState'; +import type { Logger } from '../../../../lib/Sendbird/types'; import type { Nullable } from '../../../../types'; import PUBSUB_TOPICS from '../../../../lib/pubSub/topics'; import { scrollIntoLast as scrollIntoLastForChannel } from '../utils'; diff --git a/src/modules/Channel/context/hooks/useSendVoiceMessageCallback.ts b/src/modules/Channel/context/hooks/useSendVoiceMessageCallback.ts index 1855f5c2be..a3ee4243db 100644 --- a/src/modules/Channel/context/hooks/useSendVoiceMessageCallback.ts +++ b/src/modules/Channel/context/hooks/useSendVoiceMessageCallback.ts @@ -3,7 +3,7 @@ import type { FileMessage, FileMessageCreateParams } from '@sendbird/chat/messag import type { GroupChannel } from '@sendbird/chat/groupChannel'; import { MessageMetaArray } from '@sendbird/chat/message'; -import type { Logger } from '../../../../lib/SendbirdState'; +import type { Logger } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; import * as utils from '../utils'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; diff --git a/src/modules/ChannelList/components/ChannelListUI/index.tsx b/src/modules/ChannelList/components/ChannelListUI/index.tsx index 10a1a6b578..1802c6ede7 100644 --- a/src/modules/ChannelList/components/ChannelListUI/index.tsx +++ b/src/modules/ChannelList/components/ChannelListUI/index.tsx @@ -5,11 +5,11 @@ import ChannelPreviewAction from '../ChannelPreviewAction'; import { useChannelListContext } from '../../context/ChannelListProvider'; import * as channelListActions from '../../dux/actionTypes'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { GroupChannelListUIView } from '../../../GroupChannelList/components/GroupChannelListUI/GroupChannelListUIView'; import AddChannel from '../AddChannel'; import { GroupChannelListItemBasicProps } from '../../../GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView'; import { noop } from '../../../../utils/utils'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface ChannelPreviewProps extends Omit { onLeaveChannel(channel?: GroupChannel, onLeaveChannelCb?: (channel: GroupChannel, error?: unknown) => void): Promise; @@ -38,7 +38,8 @@ const ChannelListUI: React.FC = (props: ChannelListUIProps) onProfileEditSuccess, } = useChannelListContext(); - const { stores, config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { stores, config } = state; const { logger, isOnline = false } = config; const sdk = stores.sdkStore.sdk; diff --git a/src/modules/ChannelList/components/ChannelPreview/index.tsx b/src/modules/ChannelList/components/ChannelPreview/index.tsx index af4b6d9b5d..9920dbaad4 100644 --- a/src/modules/ChannelList/components/ChannelPreview/index.tsx +++ b/src/modules/ChannelList/components/ChannelPreview/index.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { SendableMessageType } from '../../../../utils'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { useChannelListContext } from '../../context/ChannelListProvider'; import { getChannelTitle } from './utils'; import { GroupChannelListItemBasicProps, GroupChannelListItemView } from '../../../GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface ChannelPreviewInterface extends GroupChannelListItemBasicProps { /** @deprecated Please use `isSelected` instead */ @@ -22,7 +22,8 @@ const ChannelPreview = ({ onClick, tabIndex, }: ChannelPreviewInterface) => { - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { stringSet } = useLocalization(); const { isTypingIndicatorEnabled = false, isMessageReceiptStatusEnabled = false } = useChannelListContext(); diff --git a/src/modules/ChannelList/context/ChannelListProvider.tsx b/src/modules/ChannelList/context/ChannelListProvider.tsx index 4964ed9b49..f7fe2b0fd1 100644 --- a/src/modules/ChannelList/context/ChannelListProvider.tsx +++ b/src/modules/ChannelList/context/ChannelListProvider.tsx @@ -25,13 +25,13 @@ import * as channelListActions from '../dux/actionTypes'; import { ChannelListActionTypes } from '../dux/actionTypes'; import { UserProfileProvider, UserProfileProviderProps } from '../../../lib/UserProfileContext'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import channelListReducers from '../dux/reducers'; import channelListInitialState from '../dux/initialState'; import { CHANNEL_TYPE } from '../../CreateChannel/types'; import useActiveChannelUrl from './hooks/useActiveChannelUrl'; import { useFetchChannelList } from './hooks/useFetchChannelList'; import useHandleReconnectForChannelList from '../../Channel/context/hooks/useHandleReconnectForChannelList'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export interface ApplicationUserListQueryInternal { limit?: number; @@ -136,8 +136,8 @@ const ChannelListProvider: React.FC = (props: ChannelL const disableAutoSelect = props?.disableAutoSelect || !!activeChannelUrl; const onChannelSelect = props?.onChannelSelect || noop; // fetch store from - const globalStore = useSendbirdStateContext(); - const { config, stores } = globalStore; + const { state } = useSendbird(); + const { config, stores } = state; const { sdkStore } = stores; const { pubSub, logger } = config; const { diff --git a/src/modules/ChannelList/context/hooks/useActiveChannelUrl.ts b/src/modules/ChannelList/context/hooks/useActiveChannelUrl.ts index e33d24956c..cab6046a45 100644 --- a/src/modules/ChannelList/context/hooks/useActiveChannelUrl.ts +++ b/src/modules/ChannelList/context/hooks/useActiveChannelUrl.ts @@ -1,8 +1,7 @@ import { useEffect } from 'react'; import * as messageActionTypes from '../../dux/actionTypes'; import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { Logger } from '../../../../lib/SendbirdState'; -import { SdkStore } from '../../../../lib/types'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; export type DynamicProps = { activeChannelUrl?: string; diff --git a/src/modules/ChannelList/context/hooks/useFetchChannelList.ts b/src/modules/ChannelList/context/hooks/useFetchChannelList.ts index f891dafe5f..eeb047ba79 100644 --- a/src/modules/ChannelList/context/hooks/useFetchChannelList.ts +++ b/src/modules/ChannelList/context/hooks/useFetchChannelList.ts @@ -1,8 +1,8 @@ import React, { useCallback } from 'react'; import { GroupChannel, GroupChannelListQuery } from '@sendbird/chat/groupChannel'; +import type { Logger } from '../../../../lib/Sendbird/types'; import { Nullable } from '../../../../types'; -import { Logger } from '../../../../lib/SendbirdState'; import { MarkAsDeliveredSchedulerType } from '../../../../lib/hooks/useMarkAsDeliveredScheduler'; import * as channelListActions from '../../dux/actionTypes'; import { ChannelListActionTypes } from '../../dux/actionTypes'; diff --git a/src/modules/ChannelList/utils.ts b/src/modules/ChannelList/utils.ts index 3a2d542450..97b8f71f80 100644 --- a/src/modules/ChannelList/utils.ts +++ b/src/modules/ChannelList/utils.ts @@ -7,7 +7,7 @@ import { } from '@sendbird/chat/groupChannel'; import * as channelActions from './dux/actionTypes'; import topics, { SBUGlobalPubSub } from '../../lib/pubSub/topics'; -import { SdkStore } from '../../lib/types'; +import { SdkStore } from '../../lib/Sendbird/types'; import React from 'react'; import { ChannelListInitialStateType } from './dux/initialState'; import { ChannelListActionTypes } from './dux/actionTypes'; diff --git a/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx index 0438dd5dd7..cad11298ce 100644 --- a/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx +++ b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { ChannelSettingsProvider, useChannelSettingsContext } from '../context/ChannelSettingsProvider'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; -import { SendbirdSdkContext } from '../../../lib/SendbirdSdkContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; +import { SendbirdContext } from '../../../lib/Sendbird/context/SendbirdContext'; -jest.mock('../../../hooks/useSendbirdStateContext'); +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird'); jest.mock('../context/hooks/useSetChannel'); const mockLogger = { @@ -33,17 +33,19 @@ describe('ChannelSettingsProvider', () => { let wrapper; beforeEach(() => { - useSendbirdStateContext.mockReturnValue({ - stores: { sdkStore: { sdk: {}, initialized: true } }, - config: { logger: mockLogger }, + useSendbird.mockReturnValue({ + state: { + stores: { sdkStore: { sdk: {}, initialized: true } }, + config: { logger: mockLogger }, + }, }); wrapper = ({ children }) => ( - + {children} - + ); jest.clearAllMocks(); @@ -56,9 +58,11 @@ describe('ChannelSettingsProvider', () => { }); it('logs a warning if SDK is not initialized', () => { - useSendbirdStateContext.mockReturnValue({ - stores: { sdkStore: { sdk: null, initialized: false } }, - config: { logger: mockLogger }, + useSendbird.mockReturnValue({ + state: { + stores: { sdkStore: { sdk: null, initialized: false } }, + config: { logger: mockLogger }, + }, }); renderHook(() => useChannelSettingsContext(), { wrapper }); diff --git a/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx index d31914917e..f0c8ab8009 100644 --- a/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx +++ b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx @@ -1,14 +1,20 @@ import React from 'react'; import '@testing-library/jest-dom/extend-expect'; -import { render, screen } from '@testing-library/react'; +import { render, renderHook, screen } from '@testing-library/react'; import ChannelSettingsUI from '../components/ChannelSettingsUI'; import { LocalizationContext } from '../../../lib/LocalizationContext'; import * as useChannelSettingsModule from '../context/useChannelSettings'; -import { SendbirdSdkContext } from '../../../lib/SendbirdSdkContext'; +import { SendbirdContext } from '../../../lib/Sendbird/context/SendbirdContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; jest.mock('../context/useChannelSettings'); +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(), +})); + const mockStringSet = { CHANNEL_SETTING__HEADER__TITLE: 'Channel information', CHANNEL_SETTING__OPERATORS__TITLE: 'Operators', @@ -46,16 +52,24 @@ describe('ChannelSettings Integration Tests', () => { }); return render( - + - , + , ); }; beforeEach(() => { jest.clearAllMocks(); + const stateContextValue = { + state: { + config: {}, + stores: {}, + }, + }; + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); }); it('renders all necessary texts correctly', () => { diff --git a/src/modules/ChannelSettings/components/ChannelProfile/index.tsx b/src/modules/ChannelSettings/components/ChannelProfile/index.tsx index e5439d2ba2..ccd6a6c570 100644 --- a/src/modules/ChannelSettings/components/ChannelProfile/index.tsx +++ b/src/modules/ChannelSettings/components/ChannelProfile/index.tsx @@ -2,7 +2,6 @@ import './channel-profile.scss'; import React, { useState, useContext, useMemo } from 'react'; import useChannelSettings from '../../context/useChannelSettings'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import ChannelAvatar from '../../../../ui/ChannelAvatar'; @@ -12,9 +11,10 @@ import Label, { LabelColors, } from '../../../../ui/Label'; import EditDetailsModal from '../EditDetailsModal'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; const ChannelProfile: React.FC = () => { - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const { state: { channel } } = useChannelSettings(); const { stringSet } = useContext(LocalizationContext); const [showModal, setShowModal] = useState(false); diff --git a/src/modules/ChannelSettings/components/ChannelSettingsUI/ChannelSettingsHeader.tsx b/src/modules/ChannelSettings/components/ChannelSettingsUI/ChannelSettingsHeader.tsx index 7f0c3e2e95..c82698a0bf 100644 --- a/src/modules/ChannelSettings/components/ChannelSettingsUI/ChannelSettingsHeader.tsx +++ b/src/modules/ChannelSettings/components/ChannelSettingsUI/ChannelSettingsHeader.tsx @@ -1,10 +1,10 @@ import React, { MouseEvent } from 'react'; import { useLocalization } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { IconTypes } from '../../../../ui/Icon'; import Header, { type HeaderCustomProps } from '../../../../ui/Header'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ChannelSettingsHeaderProps extends HeaderCustomProps { onCloseClick?: (e: MouseEvent) => void; @@ -17,7 +17,8 @@ export const ChannelSettingsHeader = ({ renderRight, }: ChannelSettingsHeaderProps) => { const { stringSet } = useLocalization(); - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { logger } = config; return ( diff --git a/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx b/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx index 4eb515350b..67c4925185 100644 --- a/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx +++ b/src/modules/ChannelSettings/components/ChannelSettingsUI/index.tsx @@ -2,7 +2,6 @@ import './channel-settings-ui.scss'; import React, { ReactNode, useState } from 'react'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import useChannelSettings from '../../context/useChannelSettings'; import { useLocalization } from '../../../../lib/LocalizationContext'; import useMenuItems from './hooks/useMenuItems'; @@ -19,6 +18,7 @@ import ChannelProfile from '../ChannelProfile'; import LeaveChannelModal from '../LeaveChannel'; import MenuItem from './MenuItem'; import MenuListByRole from './MenuListByRole'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface ModerationPanelProps { menuItems: ReturnType; @@ -46,9 +46,8 @@ const ChannelSettingsUI = (props: ChannelSettingsUIProps) => { renderPlaceholderError, renderPlaceholderLoading, } = deleteNullish(props); - const { - config: { isOnline }, - } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { isOnline } = state.config; const { state: { channel, diff --git a/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx b/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx index 62f9579033..00c162a623 100644 --- a/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx +++ b/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useContext } from 'react'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import useChannelSettings from '../../context/useChannelSettings'; import Modal from '../../../../ui/Modal'; @@ -13,6 +12,7 @@ import TextButton from '../../../../ui/TextButton'; import ChannelAvatar from '../../../../ui/ChannelAvatar/index'; import uuidv4 from '../../../../utils/uuid'; import { FileCompat } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export type EditDetailsProps = { onSubmit: () => void; @@ -35,7 +35,7 @@ const EditDetails: React.FC = (props: EditDetailsProps) => { } = useChannelSettings(); const title = channel?.name; - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const userId = state?.config?.userId; const theme = state?.config?.theme; const logger = state?.config?.logger; diff --git a/src/modules/ChannelSettings/components/LeaveChannel/index.tsx b/src/modules/ChannelSettings/components/LeaveChannel/index.tsx index 9302dbb671..51f6d4e296 100644 --- a/src/modules/ChannelSettings/components/LeaveChannel/index.tsx +++ b/src/modules/ChannelSettings/components/LeaveChannel/index.tsx @@ -3,7 +3,6 @@ import './leave-channel.scss'; import React from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { noop } from '../../../../utils/utils'; import Modal from '../../../../ui/Modal'; @@ -15,6 +14,7 @@ import Label, { LabelColors, } from '../../../../ui/Label'; import useChannelSettings from '../../context/useChannelSettings'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export type LeaveChannelProps = { onSubmit: () => void; @@ -29,7 +29,7 @@ const LeaveChannel: React.FC = (props: LeaveChannelProps) => const { state: { channel, onLeaveChannel } } = useChannelSettings(); const { stringSet } = useLocalization(); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const logger = state?.config?.logger; const isOnline = state?.config?.isOnline; const { isMobile } = useMediaQueryContext(); diff --git a/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx b/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx index 1a274ade7d..8e0e9beeb4 100644 --- a/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx +++ b/src/modules/ChannelSettings/components/ModerationPanel/InviteUsersModal.tsx @@ -4,11 +4,11 @@ import { User } from '@sendbird/chat'; import Modal from '../../../../ui/Modal'; import { ButtonTypes } from '../../../../ui/Button'; import UserListItem, { type UserListItemProps } from '../../../../ui/UserListItem'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; import { UserListQuery } from '../../../../types'; import useChannelSettings from '../../context/useChannelSettings'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; type UserId = string; export interface InviteUsersModalProps { @@ -26,7 +26,7 @@ export function InviteUsersModal({ const [userListQuery, setUserListQuery] = useState(null); const [selectedUsers, setSelectedUsers] = useState>({}); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const sdk = state?.stores?.sdkStore?.sdk; const globalUserListQuery = state?.config?.userListQuery; diff --git a/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts b/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts index 9866a3f860..1cbdfb5643 100644 --- a/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts +++ b/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts @@ -3,8 +3,7 @@ import { GroupChannelHandler } from '@sendbird/chat/groupChannel'; import uuidv4 from '../../../../utils/uuid'; import compareIds from '../../../../utils/compareIds'; -import type { SdkStore } from '../../../../lib/types'; -import type { Logger } from '../../../../lib/SendbirdState'; +import type { SdkStore, Logger } from '../../../../lib/Sendbird/types'; interface UseChannelHandlerProps { sdk: SdkStore['sdk']; diff --git a/src/modules/ChannelSettings/context/hooks/useSetChannel.ts b/src/modules/ChannelSettings/context/hooks/useSetChannel.ts index 53c98df135..fbd9905ea7 100644 --- a/src/modules/ChannelSettings/context/hooks/useSetChannel.ts +++ b/src/modules/ChannelSettings/context/hooks/useSetChannel.ts @@ -1,6 +1,5 @@ import { useEffect } from 'react'; -import type { Logger } from '../../../../lib/SendbirdState'; -import { SdkStore } from '../../../../lib/types'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import useChannelSettings from '../useChannelSettings'; interface Props { diff --git a/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx index 37e0dc87a0..e4ddfbf7d4 100644 --- a/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx +++ b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx @@ -6,35 +6,37 @@ import '@testing-library/jest-dom/extend-expect'; import { LocalizationContext } from '../../../../../lib/LocalizationContext'; import CreateChannelUI from '../index'; -jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ +jest.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: jest.fn(() => ({ - stores: { - userStore: { - user: { - userId: ' test-user-id', + state: { + stores: { + userStore: { + user: { + userId: ' test-user-id', + }, }, - }, - sdkStore: { - sdk: { - currentUser: { - userId: 'test-user-id', + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, + createApplicationUserListQuery: () => ({ + next: () => Promise.resolve([{ userId: 'test-user-id' }]), + isLoading: false, + }), }, - createApplicationUserListQuery: () => ({ - next: () => Promise.resolve([{ userId: 'test-user-id' }]), - isLoading: false, - }), + initialized: true, }, - initialized: true, }, - }, - config: { - logger: console, - userId: 'test-user-id', - groupChannel: { - enableMention: true, + config: { + logger: console, + userId: 'test-user-id', + groupChannel: { + enableMention: true, + }, + isOnline: true, }, - isOnline: true, }, })), })); diff --git a/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx b/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx index c16ff47bf6..9b4a8fb5d8 100644 --- a/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx +++ b/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx @@ -7,21 +7,23 @@ import { CHANNEL_TYPE } from '../../../types'; import * as useCreateChannelModule from '../../../context/useCreateChannel'; import { LocalizationContext } from '../../../../../lib/LocalizationContext'; -jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - currentUser: { - userId: 'test-user-id', - }, +const mockState = { + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', }, - initialized: true, }, + initialized: true, }, - config: { logger: console }, - })), + }, + config: { logger: console }, +}; +jest.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), + useSendbird: jest.fn(() => ({ state: mockState })), })); jest.mock('../../../context/useCreateChannel'); diff --git a/src/modules/CreateChannel/components/InviteUsers/index.tsx b/src/modules/CreateChannel/components/InviteUsers/index.tsx index 04965d7264..32c7ede757 100644 --- a/src/modules/CreateChannel/components/InviteUsers/index.tsx +++ b/src/modules/CreateChannel/components/InviteUsers/index.tsx @@ -4,7 +4,7 @@ import type { GroupChannelCreateParams } from '@sendbird/chat/groupChannel'; import './invite-users.scss'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; import Modal from '../../../../ui/Modal'; import Label, { LabelColors, LabelTypography } from '../../../../ui/Label'; @@ -41,9 +41,7 @@ const InviteUsers: React.FC = ({ }, } = useCreateChannel(); - const globalStore = useSendbirdStateContext(); - const userId = globalStore?.config?.userId; - const sdk = globalStore?.stores?.sdkStore?.sdk; + const { state: { config: { userId }, stores: { sdkStore: { sdk } } } } = useSendbird(); const idsToFilter = [userId]; const [users, setUsers] = useState([]); const [selectedUsers, setSelectedUsers] = useState>({}); diff --git a/src/modules/CreateChannel/components/InviteUsers/utils.ts b/src/modules/CreateChannel/components/InviteUsers/utils.ts index fc7d19a8b4..23cbf7c1b7 100644 --- a/src/modules/CreateChannel/components/InviteUsers/utils.ts +++ b/src/modules/CreateChannel/components/InviteUsers/utils.ts @@ -1,7 +1,7 @@ import type { ApplicationUserListQuery } from '@sendbird/chat'; import type { GroupChannelCreateParams } from '@sendbird/chat/groupChannel'; import { CHANNEL_TYPE } from '../../types'; -import { SdkStore } from '../../../../lib/types'; +import type { SdkStore } from '../../../../lib/Sendbird/types'; export const filterUser = (idsToFilter: string[]) => (currentId: string): boolean => idsToFilter?.includes(currentId); diff --git a/src/modules/CreateChannel/components/SelectChannelType.tsx b/src/modules/CreateChannel/components/SelectChannelType.tsx index 9414aa9792..305d43a875 100644 --- a/src/modules/CreateChannel/components/SelectChannelType.tsx +++ b/src/modules/CreateChannel/components/SelectChannelType.tsx @@ -1,7 +1,6 @@ import React, { useContext } from 'react'; import * as sendbirdSelectors from '../../../lib/selectors'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import { LocalizationContext } from '../../../lib/LocalizationContext'; import Label, { LabelColors, LabelTypography } from '../../../ui/Label'; @@ -15,6 +14,7 @@ import { } from '../utils'; import { CHANNEL_TYPE } from '../types'; import useCreateChannel from '../context/useCreateChannel'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export interface SelectChannelTypeProps { onCancel?(): void; @@ -22,9 +22,8 @@ export interface SelectChannelTypeProps { const SelectChannelType: React.FC = (props: SelectChannelTypeProps) => { const { onCancel } = props; - const store = useSendbirdStateContext(); - - const sdk = sendbirdSelectors.getSdk(store); + const { state } = useSendbird(); + const sdk = sendbirdSelectors.getSdk(state); const { actions: { diff --git a/src/modules/CreateChannel/context/CreateChannelProvider.tsx b/src/modules/CreateChannel/context/CreateChannelProvider.tsx index f9d229e19b..a7fbfced1d 100644 --- a/src/modules/CreateChannel/context/CreateChannelProvider.tsx +++ b/src/modules/CreateChannel/context/CreateChannelProvider.tsx @@ -5,12 +5,12 @@ import type { GroupChannelCreateParams, } from '@sendbird/chat/groupChannel'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import { CHANNEL_TYPE } from '../types'; -import { SendbirdChatType } from '../../../lib/types'; +import { SendbirdChatType } from '../../../lib/Sendbird/types'; import { createStore } from '../../../utils/storeManager'; import { useStore } from '../../../hooks/useStore'; import useCreateChannel from './useCreateChannel'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; const CreateChannelContext = React.createContext> | null>(null); @@ -111,8 +111,8 @@ const CreateChannelManager: React.FC = (props: Creat } = props; const { updateState } = useCreateChannelStore(); - const store = useSendbirdStateContext(); - const _userListQuery = userListQuery ?? store?.config?.userListQuery; + const { state: { config } } = useSendbird(); + const _userListQuery = userListQuery ?? config?.userListQuery; useEffect(() => { updateState({ diff --git a/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx index 0be826a28e..2c722b6c74 100644 --- a/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx +++ b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx @@ -5,20 +5,22 @@ import { CHANNEL_TYPE } from '../../types'; import useCreateChannel from '../useCreateChannel'; import { renderHook } from '@testing-library/react-hooks'; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - currentUser: { - userId: 'test-user-id', + state: { + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, }, + initialized: true, }, - initialized: true, }, + config: { logger: console }, }, - config: { logger: console }, })), })); diff --git a/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx b/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx index 4a479958b2..bdc83b5e5b 100644 --- a/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx +++ b/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx @@ -5,20 +5,22 @@ import { CreateChannelProvider } from '../CreateChannelProvider'; import { renderHook } from '@testing-library/react'; import useCreateChannel from '../useCreateChannel'; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - currentUser: { - userId: 'test-user-id', + state: { + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', + }, }, + initialized: true, }, - initialized: true, }, + config: { logger: console }, }, - config: { logger: console }, })), })); diff --git a/src/modules/CreateChannel/utils.ts b/src/modules/CreateChannel/utils.ts index d9e44a948f..3bc4d00929 100644 --- a/src/modules/CreateChannel/utils.ts +++ b/src/modules/CreateChannel/utils.ts @@ -1,4 +1,4 @@ -import { SdkStore } from '../../lib/types'; +import type { SdkStore } from '../../lib/Sendbird/types'; export const isBroadcastChannelEnabled = (sdk: SdkStore['sdk']): boolean => { const ALLOW_BROADCAST_CHANNEL = 'allow_broadcast_channel'; diff --git a/src/modules/CreateOpenChannel/context/CreateOpenChannelProvider.tsx b/src/modules/CreateOpenChannel/context/CreateOpenChannelProvider.tsx index 65cb476943..8431ed6d04 100644 --- a/src/modules/CreateOpenChannel/context/CreateOpenChannelProvider.tsx +++ b/src/modules/CreateOpenChannel/context/CreateOpenChannelProvider.tsx @@ -1,8 +1,7 @@ import React, { useCallback } from 'react'; import { OpenChannel, OpenChannelCreateParams } from '@sendbird/chat/openChannel'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; -import { Logger } from '../../../lib/SendbirdState'; -import { SdkStore } from '../../../lib/types'; +import { SdkStore, Logger } from '../../../lib/Sendbird/types'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export interface CreateNewOpenChannelCallbackProps { name: string; @@ -30,7 +29,8 @@ export const CreateOpenChannelProvider: React.FC onCreateChannel, onBeforeCreateChannel, }: CreateOpenChannelProviderProps): React.ReactElement => { - const { stores, config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { stores, config } = state; const { logger } = config; const sdk = stores?.sdkStore?.sdk || null; const sdkInitialized = stores?.sdkStore?.initialized || false; diff --git a/src/modules/EditUserProfile/components/EditUserProfileUI/EditUserProfileUIView.tsx b/src/modules/EditUserProfile/components/EditUserProfileUI/EditUserProfileUIView.tsx index 74a53f9522..bc38c9fdab 100644 --- a/src/modules/EditUserProfile/components/EditUserProfileUI/EditUserProfileUIView.tsx +++ b/src/modules/EditUserProfile/components/EditUserProfileUI/EditUserProfileUIView.tsx @@ -6,7 +6,7 @@ import Avatar from '../../../../ui/Avatar'; import TextButton from '../../../../ui/TextButton'; import Label, { LabelColors, LabelTypography } from '../../../../ui/Label'; import Icon, { IconTypes } from '../../../../ui/Icon'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface EditUserProfileUIViewProps { formRef: MutableRefObject; @@ -20,7 +20,8 @@ export const EditUserProfileUIView = ({ onThemeChange, setProfileImage, }: EditUserProfileUIViewProps) => { - const { stores, config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { stores, config } = state; const { theme, setCurrentTheme } = config; const user = stores.userStore?.user; const { stringSet } = useLocalization(); diff --git a/src/modules/EditUserProfile/components/EditUserProfileUI/index.tsx b/src/modules/EditUserProfile/components/EditUserProfileUI/index.tsx index 9fffc65ee3..37db1baf98 100644 --- a/src/modules/EditUserProfile/components/EditUserProfileUI/index.tsx +++ b/src/modules/EditUserProfile/components/EditUserProfileUI/index.tsx @@ -8,22 +8,22 @@ import React, { import { User } from '@sendbird/chat'; import './edit-user-profile.scss'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useEditUserProfileContext } from '../../context/EditUserProfileProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import { SendBirdState } from '../../../../lib/types'; -import { USER_ACTIONS } from '../../../../lib/dux/user/actionTypes'; import Modal from '../../../../ui/Modal'; import { ButtonTypes } from '../../../../ui/Button'; import { EditUserProfileUIView } from './EditUserProfileUIView'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; +import { SendbirdState } from '../../../../lib/Sendbird/types'; interface HandleUpdateUserInfoParams { - globalContext: SendBirdState; + globalContext: SendbirdState; formRef: MutableRefObject; inputRef: MutableRefObject; profileImage: File | null; onEditProfile?: (user: User) => void; + updateUserInfo: (user: User) => void; } const handleUpdateUserInfo = ({ globalContext, @@ -31,11 +31,11 @@ const handleUpdateUserInfo = ({ inputRef, profileImage, onEditProfile, + updateUserInfo, }: HandleUpdateUserInfoParams) => { - const { stores, dispatchers } = globalContext; + const { stores } = globalContext; const sdk = stores.sdkStore.sdk; const user = stores.userStore.user; - const { userDispatcher } = dispatchers; if (user?.nickname !== '' && !inputRef.current.value) { formRef.current.reportValidity?.(); // might not work in explorer @@ -45,7 +45,7 @@ const handleUpdateUserInfo = ({ nickname: inputRef?.current?.value, profileImage: profileImage ?? undefined, }).then((updatedUser) => { - userDispatcher({ type: USER_ACTIONS.UPDATE_USER_INFO, payload: updatedUser }); + updateUserInfo(updatedUser); onEditProfile?.(updatedUser); }); }; @@ -56,17 +56,18 @@ export interface UseEditUserProfileUIStateParams { export const useEditUserProfileUISates = ({ onEditProfile, }: UseEditUserProfileUIStateParams) => { - const globalContext = useSendbirdStateContext(); + const { state, actions } = useSendbird(); const inputRef = useRef(null); const formRef = useRef(null); const [profileImage, setProfileImage] = useState(null); const updateUserInfo = () => { handleUpdateUserInfo({ - globalContext, + globalContext: state, formRef, inputRef, profileImage, onEditProfile, + updateUserInfo: actions.updateUserInfo, }); }; diff --git a/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx b/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx index b8d7511850..d951d7a81b 100644 --- a/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx +++ b/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx @@ -2,11 +2,11 @@ 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'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; -jest.mock('../../../hooks/useSendbirdStateContext'); +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird'); -const mockUseSendbirdStateContext = useSendbirdStateContext as jest.Mock; +const mockUseSendbird = useSendbird as jest.Mock; describe('GroupChannelUIView Integration Tests', () => { const defaultProps = { @@ -18,16 +18,18 @@ describe('GroupChannelUIView Integration Tests', () => { }; beforeEach(() => { - mockUseSendbirdStateContext.mockImplementation(() => ({ - stores: { - sdkStore: { error: null }, - }, - config: { - logger: { info: jest.fn() }, - isOnline: true, - groupChannel: { - enableTypingIndicator: true, - typingIndicatorTypes: new Set(['text']), + mockUseSendbird.mockImplementation(() => ({ + state: { + stores: { + sdkStore: { error: null }, + }, + config: { + logger: { info: jest.fn() }, + isOnline: true, + groupChannel: { + enableTypingIndicator: true, + typingIndicatorTypes: new Set(['text']), + }, }, }, })); @@ -58,13 +60,15 @@ describe('GroupChannelUIView Integration Tests', () => { }); 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, + mockUseSendbird.mockImplementation(() => ({ + state: { + stores: { + sdkStore: { error: new Error('SDK Error') }, + }, + config: { + logger: { info: jest.fn() }, + isOnline: true, + }, }, })); diff --git a/src/modules/GroupChannel/components/FileViewer/FileViewerView.tsx b/src/modules/GroupChannel/components/FileViewer/FileViewerView.tsx index dccfa3f7b6..62e52effc8 100644 --- a/src/modules/GroupChannel/components/FileViewer/FileViewerView.tsx +++ b/src/modules/GroupChannel/components/FileViewer/FileViewerView.tsx @@ -10,8 +10,8 @@ import Icon, { IconColors, IconTypes } from '../../../../ui/Icon'; import Label, { LabelColors, LabelTypography, LabelStringSet } from '../../../../ui/Label'; import { isImage, isSupportedFileView, isVideo } from '../../../../utils'; import { MODAL_ROOT } from '../../../../hooks/useModal'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import Modal from '../../../../ui/Modal'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; type DeleteMessageTypeLegacy = (message: CoreMessageType) => Promise; export interface FileViewerViewProps extends FileViewerProps { @@ -28,7 +28,8 @@ export const FileViewerView = ({ const { sender, type, url, name = '', threadInfo } = message; const { profileUrl, nickname, userId } = sender; - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; return createPortal( void; @@ -15,8 +15,7 @@ export const FileViewer = (props: FileViewerProps) => { state: { onBeforeDownloadFileMessage }, actions: { deleteMessage }, } = useGroupChannel(); - const { config } = useSendbirdStateContext(); - const { logger } = config; + const { state: { config: { logger } } } = useSendbird(); return ( { - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { userId, theme } = config; const { isMobile } = useMediaQueryContext(); diff --git a/src/modules/GroupChannel/components/GroupChannelUI/GroupChannelUIView.tsx b/src/modules/GroupChannel/components/GroupChannelUI/GroupChannelUIView.tsx index cae0ad0757..1f490865fe 100644 --- a/src/modules/GroupChannel/components/GroupChannelUI/GroupChannelUIView.tsx +++ b/src/modules/GroupChannel/components/GroupChannelUI/GroupChannelUIView.tsx @@ -1,8 +1,6 @@ import './index.scss'; import React from 'react'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; - import TypingIndicator from '../TypingIndicator'; import { TypingIndicatorType } from '../../../../types'; import ConnectionStatus from '../../../../ui/ConnectionStatus'; @@ -14,6 +12,7 @@ import type { GroupChannelMessageListProps } from '../MessageList'; import type { MessageContentProps } from '../../../../ui/MessageContent'; import { SuggestedRepliesProps } from '../SuggestedReplies'; import { deleteNullish } from '../../../../utils/utils'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface GroupChannelUIBasicProps { // Components @@ -103,8 +102,8 @@ export const GroupChannelUIView = (props: GroupChannelUIViewProps) => { renderPlaceholderLoader, renderPlaceholderInvalid, } = deleteNullish(props); - - const { stores, config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { stores, config } = state; const sdkError = stores?.sdkStore?.error; const { logger, isOnline } = config; diff --git a/src/modules/GroupChannel/components/Message/MessageView.tsx b/src/modules/GroupChannel/components/Message/MessageView.tsx index 397312f563..1380305823 100644 --- a/src/modules/GroupChannel/components/Message/MessageView.tsx +++ b/src/modules/GroupChannel/components/Message/MessageView.tsx @@ -6,7 +6,6 @@ import type { FileMessage, UserMessage, UserMessageCreateParams, UserMessageUpda import format from 'date-fns/format'; import { useLocalization } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { MAX_USER_MENTION_COUNT, MAX_USER_SUGGESTION_COUNT, ThreadReplySelectType } from '../../context/const'; import { isDisabledBecauseFrozen, isDisabledBecauseMuted } from '../../context/utils'; import { useDirtyGetMentions } from '../../../Message/hooks/useDirtyGetMentions'; @@ -22,6 +21,7 @@ import SuggestedReplies, { SuggestedRepliesProps } from '../SuggestedReplies'; import SuggestedMentionListView from '../SuggestedMentionList/SuggestedMentionListView'; import type { OnBeforeDownloadFileMessageType } from '../../context/types'; import { classnames, deleteNullish } from '../../../../utils/utils'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface MessageProps { message: EveryMessage; @@ -152,7 +152,7 @@ const MessageView = (props: MessageViewProps) => { } = deleteNullish(props); const { dateLocale, stringSet } = useLocalization(); - const globalStore = useSendbirdStateContext(); + const { state } = useSendbird(); const { userId, @@ -160,7 +160,7 @@ const MessageView = (props: MessageViewProps) => { userMention, logger, groupChannel, - } = globalStore.config; + } = state.config; const maxUserMentionCount = userMention?.maxMentionCount || MAX_USER_MENTION_COUNT; const maxUserSuggestionCount = userMention?.maxSuggestionCount || MAX_USER_SUGGESTION_COUNT; diff --git a/src/modules/GroupChannel/components/Message/index.tsx b/src/modules/GroupChannel/components/Message/index.tsx index c4425b3d91..f86a91f9a8 100644 --- a/src/modules/GroupChannel/components/Message/index.tsx +++ b/src/modules/GroupChannel/components/Message/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { useIIFE } from '@sendbird/uikit-tools'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { getSuggestedReplies, isSendableMessage } from '../../../../utils'; import { isDisabledBecauseFrozen, isDisabledBecauseMuted } from '../../context/utils'; import MessageView, { MessageProps } from './MessageView'; @@ -9,9 +8,11 @@ import FileViewer from '../FileViewer'; import RemoveMessageModal from '../RemoveMessageModal'; import { ThreadReplySelectType } from '../../context/const'; import { useGroupChannel } from '../../context/hooks/useGroupChannel'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export const Message = (props: MessageProps): React.ReactElement => { - const { config, emojiManager } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config, emojiManager } = state; const { state: { loading, diff --git a/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx b/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx index 60aa925083..9e9b385207 100644 --- a/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx +++ b/src/modules/GroupChannel/components/MessageInputWrapper/MessageInputWrapperView.tsx @@ -18,7 +18,6 @@ import { isDisabledBecauseSuggestedReplies, isDisabledBecauseMessageForm, } from '../../context/utils'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import SuggestedMentionList from '../SuggestedMentionList'; import { useDirtyGetMentions } from '../../../Message/hooks/useDirtyGetMentions'; @@ -29,6 +28,7 @@ import MessageInput from '../../../../ui/MessageInput'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; import { MessageInputKeys } from '../../../../ui/MessageInput/const'; import { useHandleUploadFiles } from './useHandleUploadFiles'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface MessageInputWrapperViewProps { // Basic @@ -80,7 +80,8 @@ export const MessageInputWrapperView = React.forwardRef(( } = props; const { stringSet } = useLocalization(); const { isMobile } = useMediaQueryContext(); - const { stores, config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { stores, config } = state; const { isOnline, userMention, logger, groupChannel } = config; const sdk = stores.sdkStore.sdk; const { maxMentionCount, maxSuggestionCount } = userMention; diff --git a/src/modules/GroupChannel/components/MessageInputWrapper/VoiceMessageInputWrapper.tsx b/src/modules/GroupChannel/components/MessageInputWrapper/VoiceMessageInputWrapper.tsx index 5b7a57a393..696c61a581 100644 --- a/src/modules/GroupChannel/components/MessageInputWrapper/VoiceMessageInputWrapper.tsx +++ b/src/modules/GroupChannel/components/MessageInputWrapper/VoiceMessageInputWrapper.tsx @@ -11,9 +11,9 @@ import { VoiceMessageInput } from '../../../../ui/VoiceMessageInput'; import { VoiceMessageInputStatus } from '../../../../ui/VoiceMessageInput/types'; import Modal from '../../../../ui/Modal'; import Button, { ButtonSizes, ButtonTypes } from '../../../../ui/Button'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { VOICE_PLAYER_STATUS } from '../../../../hooks/VoicePlayer/dux/initialState'; import uuidv4 from '../../../../utils/uuid'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export type VoiceMessageInputWrapperProps = { channel?: GroupChannel; @@ -33,7 +33,8 @@ export const VoiceMessageInputWrapper = ({ const [isDisabled, setDisabled] = useState(false); const [showModal, setShowModal] = useState(false); const { stringSet } = useLocalization(); - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { start, stop, diff --git a/src/modules/GroupChannel/components/MessageInputWrapper/useHandleUploadFiles.tsx b/src/modules/GroupChannel/components/MessageInputWrapper/useHandleUploadFiles.tsx index 11252becf8..48bc0c73a3 100644 --- a/src/modules/GroupChannel/components/MessageInputWrapper/useHandleUploadFiles.tsx +++ b/src/modules/GroupChannel/components/MessageInputWrapper/useHandleUploadFiles.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import type { Logger } from '../../../../lib/Sendbird/types'; import { isImage, SendableMessageType } from '../../../../utils'; import { useGlobalModalContext } from '../../../../hooks/useModal'; import { ButtonTypes } from '../../../../ui/Button'; @@ -10,6 +9,7 @@ import { ModalFooter } from '../../../../ui/Modal'; import { FileMessage, MultipleFilesMessage, MultipleFilesMessageCreateParams } from '@sendbird/chat/message'; import { compressImages } from '../../../../utils/compressImages'; import { FileMessageCreateParams } from '@sendbird/chat/lib/__definition'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; /** * The handleUploadFiles is a function sending a FileMessage and MultipleFilesMessage @@ -29,7 +29,8 @@ export const useHandleUploadFiles = ( { logger }: useHandleUploadFilesStaticProps, ) => { const { stringSet } = useLocalization(); - const { config } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config } = state; const { imageCompression } = config; const uikitUploadSizeLimit = config?.uikitUploadSizeLimit; const uikitMultipleFilesMessageLimit = config?.uikitMultipleFilesMessageLimit; diff --git a/src/modules/GroupChannel/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx index 75289ff557..a40098e0d9 100644 --- a/src/modules/GroupChannel/components/MessageList/index.tsx +++ b/src/modules/GroupChannel/components/MessageList/index.tsx @@ -12,7 +12,6 @@ import Message from '../Message'; import UnreadCount from '../UnreadCount'; import FrozenNotification from '../FrozenNotification'; import { SCROLL_BUFFER } from '../../../../utils/consts'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import TypingIndicatorBubble from '../../../../ui/TypingIndicatorBubble'; import { GroupChannelUIBasicProps } from '../GroupChannelUI/GroupChannelUIView'; import { deleteNullish } from '../../../../utils/utils'; @@ -21,6 +20,7 @@ import { MessageProvider } from '../../../Message/context/MessageProvider'; import { getComponentKeyFromMessage } from '../../context/utils'; import { InfiniteList } from './InfiniteList'; import { useGroupChannel } from '../../context/hooks/useGroupChannel'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface GroupChannelMessageListProps { className?: string; @@ -91,7 +91,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { }, } = useGroupChannel(); - const store = useSendbirdStateContext(); + const { state } = useSendbird(); const [unreadSinceDate, setUnreadSinceDate] = useState(); @@ -167,8 +167,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => {
{ currentMessage: message as CoreMessageType, currentChannel: currentChannel!, }); - const isOutgoingMessage = isSendableMessage(message) && message.sender.userId === store.config.userId; + const isOutgoingMessage = isSendableMessage(message) && message.sender.userId === state.config.userId; return ( {renderMessage({ @@ -213,8 +213,8 @@ export const MessageList = (props: GroupChannelMessageListProps) => { }} typingIndicator={ !hasNext() - && store?.config?.groupChannel?.enableTypingIndicator - && store?.config?.groupChannel?.typingIndicatorTypes?.has(TypingIndicatorType.Bubble) && ( + && state?.config?.groupChannel?.enableTypingIndicator + && state?.config?.groupChannel?.typingIndicatorTypes?.has(TypingIndicatorType.Bubble) && ( ) } @@ -228,7 +228,7 @@ export const MessageList = (props: GroupChannelMessageListProps) => { }; const TypingIndicatorBubbleWrapper = (props: { handleScroll: () => void; channelUrl: string }) => { - const { stores } = useSendbirdStateContext(); + const { state: { stores } } = useSendbird(); const [typingMembers, setTypingMembers] = useState([]); useGroupChannelHandler(stores.sdkStore.sdk, { diff --git a/src/modules/GroupChannel/components/SuggestedMentionList/SuggestedMentionListView.tsx b/src/modules/GroupChannel/components/SuggestedMentionList/SuggestedMentionListView.tsx index 252835da9c..54cea87d9e 100644 --- a/src/modules/GroupChannel/components/SuggestedMentionList/SuggestedMentionListView.tsx +++ b/src/modules/GroupChannel/components/SuggestedMentionList/SuggestedMentionListView.tsx @@ -6,13 +6,13 @@ import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import Label, { LabelColors, LabelTypography } from '../../../../ui/Label'; import Icon, { IconColors, IconTypes } from '../../../../ui/Icon'; import SuggestedUserMentionItem from './SuggestedUserMentionItem'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { MAX_USER_MENTION_COUNT, MAX_USER_SUGGESTION_COUNT, USER_MENTION_TEMP_CHAR } from '../../context/const'; import { MessageInputKeys } from '../../../../ui/MessageInput/const'; import uuidv4 from '../../../../utils/uuid'; import { fetchMembersFromChannel, fetchMembersFromQuery } from './utils'; import { classnames } from '../../../../utils/utils'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface SuggestedMentionListViewProps { className?: string; @@ -46,7 +46,8 @@ export const SuggestedMentionListView = (props: SuggestedMentionListViewProps) = maxMentionCount = MAX_USER_MENTION_COUNT, maxSuggestionCount = MAX_USER_SUGGESTION_COUNT, } = props; - const { config, stores } = useSendbirdStateContext(); + const { state } = useSendbird(); + const { config, stores } = state; const { logger } = config; const currentUserId = stores?.sdkStore?.sdk?.currentUser?.userId || ''; const scrollRef = useRef(null); diff --git a/src/modules/GroupChannel/components/TypingIndicator.tsx b/src/modules/GroupChannel/components/TypingIndicator.tsx index a9ff08ceb6..0200e40236 100644 --- a/src/modules/GroupChannel/components/TypingIndicator.tsx +++ b/src/modules/GroupChannel/components/TypingIndicator.tsx @@ -4,8 +4,8 @@ import { GroupChannelHandler } from '@sendbird/chat/groupChannel'; import Label, { LabelTypography, LabelColors } from '../../../ui/Label'; import { LocalizationContext } from '../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import { uuidv4 } from '../../../utils/uuid'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export interface TypingIndicatorTextProps { members: Member[]; @@ -37,9 +37,9 @@ export interface TypingIndicatorProps { } export const TypingIndicator = ({ channelUrl }: TypingIndicatorProps) => { - const globalStore = useSendbirdStateContext(); - const sb = globalStore?.stores?.sdkStore?.sdk; - const logger = globalStore?.config?.logger; + const { state } = useSendbird(); + const sb = state?.stores?.sdkStore?.sdk; + const logger = state?.config?.logger; const [handlerId, setHandlerId] = useState(uuidv4()); const [typingMembers, setTypingMembers] = useState([]); diff --git a/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx b/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx index c5adeb06f9..2ed036a332 100644 --- a/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx +++ b/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx @@ -18,37 +18,39 @@ const mockMessageCollection = { loadPrevious: jest.fn(), loadNext: jest.fn(), }; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - groupChannel: { - getChannel: mockGetChannel, - addGroupChannelHandler: jest.fn(), - removeGroupChannelHandler: jest.fn(), + state: { + stores: { + sdkStore: { + sdk: { + groupChannel: { + getChannel: mockGetChannel, + addGroupChannelHandler: jest.fn(), + removeGroupChannelHandler: jest.fn(), + }, + createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection), }, - createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection), + initialized: true, }, - initialized: true, - }, - }, - config: { - logger: mockLogger, - markAsReadScheduler: { - push: jest.fn(), - }, - groupChannel: { - replyType: 'NONE', - threadReplySelectType: 'PARENT', - }, - groupChannelSettings: { - enableMessageSearch: true, }, - isOnline: true, - pubSub: { - subscribe: () => ({ remove: jest.fn() }), + config: { + logger: mockLogger, + markAsReadScheduler: { + push: jest.fn(), + }, + groupChannel: { + replyType: 'NONE', + threadReplySelectType: 'PARENT', + }, + groupChannelSettings: { + enableMessageSearch: true, + }, + isOnline: true, + pubSub: { + subscribe: () => ({ remove: jest.fn() }), + }, }, }, })), @@ -90,19 +92,21 @@ describe('GroupChannelProvider', () => { const mockError = new Error('Channel fetch failed'); jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ + jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ default: () => ({ - stores: { - sdkStore: { - sdk: { - groupChannel: { - getChannel: jest.fn().mockRejectedValue(mockError), + state: { + stores: { + sdkStore: { + sdk: { + groupChannel: { + getChannel: jest.fn().mockRejectedValue(mockError), + }, }, + initialized: true, }, - initialized: true, }, + config: { logger: console }, }, - config: { logger: console }, }), })); diff --git a/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx b/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx index 31ba50334f..99158ce7cb 100644 --- a/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx +++ b/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx @@ -20,37 +20,39 @@ const mockMessageCollection = { loadPrevious: jest.fn(), loadNext: jest.fn(), }; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - groupChannel: { - getChannel: mockGetChannel, - addGroupChannelHandler: jest.fn(), - removeGroupChannelHandler: jest.fn(), + state: { + stores: { + sdkStore: { + sdk: { + groupChannel: { + getChannel: mockGetChannel, + addGroupChannelHandler: jest.fn(), + removeGroupChannelHandler: jest.fn(), + }, + createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection), }, - createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection), + initialized: true, }, - initialized: true, }, - }, - config: { - logger: mockLogger, - markAsReadScheduler: { - push: jest.fn(), - }, - groupChannel: { - replyType: 'NONE', - threadReplySelectType: 'PARENT', - }, - groupChannelSettings: { - enableMessageSearch: true, - }, - isOnline: true, - pubSub: { - subscribe: () => ({ remove: jest.fn() }), + config: { + logger: mockLogger, + markAsReadScheduler: { + push: jest.fn(), + }, + groupChannel: { + replyType: 'NONE', + threadReplySelectType: 'PARENT', + }, + groupChannelSettings: { + enableMessageSearch: true, + }, + isOnline: true, + pubSub: { + subscribe: () => ({ remove: jest.fn() }), + }, }, }, })), diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index 153f5a0a9f..59653fcec3 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -15,7 +15,7 @@ import type { import { SendableMessageType } from '../../../../utils'; import { getMessageTopOffset } from '../utils'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; import { GroupChannelContext } from '../GroupChannelProvider'; import type { GroupChannelState, MessageActions } from '../types'; import { useMessageActions } from './useMessageActions'; @@ -53,7 +53,7 @@ export const useGroupChannel = () => { const store = useContext(GroupChannelContext); if (!store) throw new Error('useGroupChannel must be used within a GroupChannelProvider'); - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { markAsReadScheduler } = config; const state: GroupChannelState = useSyncExternalStore(store.subscribe, store.getState); diff --git a/src/modules/GroupChannelList/components/AddGroupChannel/AddGroupChannelView.tsx b/src/modules/GroupChannelList/components/AddGroupChannel/AddGroupChannelView.tsx index 0bd6839a12..539c1f7290 100644 --- a/src/modules/GroupChannelList/components/AddGroupChannel/AddGroupChannelView.tsx +++ b/src/modules/GroupChannelList/components/AddGroupChannel/AddGroupChannelView.tsx @@ -4,8 +4,8 @@ import IconButton from '../../../../ui/IconButton'; import Icon, { IconColors, IconTypes } from '../../../../ui/Icon'; import CreateChannel from '../../../CreateChannel'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { CreateChannelProviderProps } from '../../../CreateChannel/context/CreateChannelProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; type Props = { createChannelVisible: boolean; @@ -22,7 +22,7 @@ export const AddGroupChannelView = ({ onCreateChannelClick, onChannelCreated, }: Props) => { - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); return ( <> diff --git a/src/modules/GroupChannelList/components/GroupChannelListHeader/index.tsx b/src/modules/GroupChannelList/components/GroupChannelListHeader/index.tsx index a67cb9a176..bd2f627afe 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListHeader/index.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListHeader/index.tsx @@ -1,12 +1,12 @@ import React from 'react'; import './index.scss'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import Avatar from '../../../../ui/Avatar'; import Label, { LabelColors, LabelTypography } from '../../../../ui/Label'; import Header, { HeaderCustomProps } from '../../../../ui/Header'; import { classnames } from '../../../../utils/utils'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface GroupChannelListHeaderProps extends HeaderCustomProps { /** @deprecated Use the props `renderMiddle` instead */ @@ -26,8 +26,7 @@ export const GroupChannelListHeader = ({ renderMiddle, renderRight, }: GroupChannelListHeaderProps) => { - const { stores } = useSendbirdStateContext(); - const { user } = stores.userStore; + const { state: { stores: { userStore: { user } } } } = useSendbird(); const { stringSet } = useLocalization(); const renderProfile = renderMiddle ?? renderTitle; diff --git a/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx b/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx index 36133cb7b6..704a5fbae0 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListItem/GroupChannelListItemView.tsx @@ -5,7 +5,6 @@ import React, { useState } from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { FileMessage } from '@sendbird/chat/message'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import useLongPress from '../../../../hooks/useLongPress'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; @@ -25,6 +24,7 @@ import MessageStatus from '../../../../ui/MessageStatus'; import Modal from '../../../../ui/Modal'; import TextButton from '../../../../ui/TextButton'; import { getChannelPreviewMessage } from '../../../Message/utils/tokens/tokenize'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface GroupChannelListItemBasicProps { tabIndex: number; @@ -52,7 +52,7 @@ export const GroupChannelListItemView = ({ onLeaveChannel = () => Promise.resolve(), renderChannelAction, }: GroupChannelListItemViewProps) => { - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { theme, userId } = config; const { dateLocale, stringSet } = useLocalization(); const { isMobile } = useMediaQueryContext(); diff --git a/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx b/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx index 3f3074b27f..f312891635 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx @@ -3,10 +3,10 @@ import React from 'react'; import type { SendableMessageType } from '../../../../utils'; import * as utils from './utils'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { GroupChannelListItemBasicProps, GroupChannelListItemView } from './GroupChannelListItemView'; import { useGroupChannelList } from '../../context/useGroupChannelList'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface GroupChannelListItemProps extends GroupChannelListItemBasicProps {} @@ -19,7 +19,7 @@ export const GroupChannelListItem = ({ onClick, tabIndex, }: GroupChannelListItemProps) => { - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { stringSet } = useLocalization(); const { state: { diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/GroupChannelListUIView.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/GroupChannelListUIView.tsx index 11ed52154c..949842bbc2 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListUI/GroupChannelListUIView.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/GroupChannelListUIView.tsx @@ -5,11 +5,11 @@ import type { GroupChannel } from '@sendbird/chat/groupChannel'; import GroupChannelListHeader from '../GroupChannelListHeader'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import EditUserProfile from '../../../EditUserProfile'; import PlaceHolder, { PlaceHolderTypes } from '../../../../ui/PlaceHolder'; import { useOnScrollPositionChangeDetector } from '../../../../hooks/useOnScrollReachedEndDetector'; import { User } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface Props { renderHeader?: (props: void) => React.ReactElement; @@ -52,7 +52,7 @@ export const GroupChannelListUIView = ({ scrollRef, }: Props) => { const [showProfileEdit, setShowProfileEdit] = useState(false); - const { stores } = useSendbirdStateContext(); + const { state: { stores } } = useSendbird(); const renderer = { addChannel: renderAddChannel, diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx index 783750898f..23434b339f 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx @@ -5,33 +5,35 @@ import React from 'react'; import { useGroupChannelList as useGroupChannelListModule } from '../../../context/useGroupChannelList'; import { LocalizationContext } from '../../../../../lib/LocalizationContext'; -jest.mock('../../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: jest.fn(() => ({ - stores: { - userStore: { - user: { - userId: ' test-user-id', - }, +const mockState = { + stores: { + userStore: { + user: { + userId: ' test-user-id', }, - sdkStore: { - sdk: { - currentUser: { - userId: 'test-user-id', - }, + }, + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', }, - initialized: true, }, + initialized: true, }, - config: { - logger: console, - userId: 'test-user-id', - groupChannel: { - enableMention: true, - }, - isOnline: true, + }, + config: { + logger: console, + userId: 'test-user-id', + groupChannel: { + enableMention: true, }, - })), + isOnline: true, + }, +}; +jest.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), + useSendbird: jest.fn(() => ({ state: mockState })), })); jest.mock('../../../context/useGroupChannelList'); diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx index 2136b03589..196c64e817 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx @@ -4,12 +4,12 @@ import React from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import { GroupChannelListUIView } from './GroupChannelListUIView'; import GroupChannelPreviewAction from '../GroupChannelPreviewAction'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { GroupChannelListItem } from '../GroupChannelListItem'; import AddGroupChannel from '../AddGroupChannel'; import { GroupChannelListItemBasicProps } from '../GroupChannelListItem/GroupChannelListItemView'; import { noop } from '../../../../utils/utils'; import { useGroupChannelList } from '../../context/useGroupChannelList'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface GroupChannelItemProps extends GroupChannelListItemBasicProps {} @@ -39,8 +39,7 @@ export const GroupChannelListUI = (props: GroupChannelListUIProps) => { }, } = useGroupChannelList(); - const { stores, config } = useSendbirdStateContext(); - const { logger, isOnline } = config; + const { state: { stores, config: { logger, isOnline } } } = useSendbird(); const sdk = stores.sdkStore.sdk; const renderListItem = (renderProps: { item: GroupChannel; index: number }) => { diff --git a/src/modules/GroupChannelList/components/LeaveGroupChannel/index.tsx b/src/modules/GroupChannelList/components/LeaveGroupChannel/index.tsx index cfe537393e..4fb36474ab 100644 --- a/src/modules/GroupChannelList/components/LeaveGroupChannel/index.tsx +++ b/src/modules/GroupChannelList/components/LeaveGroupChannel/index.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import Modal from '../../../../ui/Modal'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export type LeaveGroupChannelProps = { channel?: GroupChannel; @@ -15,8 +15,7 @@ export const LeaveGroupChannel = ({ onSubmit, onCancel, }: LeaveGroupChannelProps) => { - const { config } = useSendbirdStateContext(); - const { logger, isOnline } = config; + const { state: { config: { logger, isOnline } } } = useSendbird(); const { stringSet } = useLocalization(); if (channel) { return ( diff --git a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx index d07fe617fe..2f5e7d1b33 100644 --- a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx +++ b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx @@ -108,8 +108,7 @@ export const GroupChannelListManager: React.FC = }: GroupChannelListProviderProps) => { const { state: sendbirdState } = useSendbird(); const { config, stores } = sendbirdState; - const { state } = useGroupChannelList(); - const { updateState } = useGroupChannelListStore(); + const { state, updateState } = useGroupChannelListStore(); const { sdkStore } = stores; const sdk = sdkStore.sdk; diff --git a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx index 0032046233..5f4b9d6436 100644 --- a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx @@ -5,21 +5,23 @@ import { } from '../GroupChannelListProvider'; import { act, renderHook, waitFor } from '@testing-library/react'; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - currentUser: { - userId: 'test-user-id', - }, +const mockState = { + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', }, - initialized: true, }, + initialized: true, }, - config: { logger: console }, - })), + }, + config: { logger: console }, +}; +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), + useSendbird: jest.fn(() => ({ state: mockState })), })); jest.mock('@sendbird/uikit-tools', () => ({ diff --git a/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx b/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx index 45e3af19d0..fa9416435c 100644 --- a/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx @@ -3,21 +3,23 @@ import { renderHook } from '@testing-library/react'; import React from 'react'; import { useGroupChannelList } from '../useGroupChannelList'; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - currentUser: { - userId: 'test-user-id', - }, +const mockState = { + stores: { + sdkStore: { + sdk: { + currentUser: { + userId: 'test-user-id', }, - initialized: true, }, + initialized: true, }, - config: { logger: console }, - })), + }, + config: { logger: console }, +}; +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), + useSendbird: jest.fn(() => ({ state: mockState })), })); jest.mock('@sendbird/uikit-tools', () => ({ diff --git a/src/modules/Message/hooks/useDirtyGetMentions.ts b/src/modules/Message/hooks/useDirtyGetMentions.ts index 8229004bbc..d584438f6e 100644 --- a/src/modules/Message/hooks/useDirtyGetMentions.ts +++ b/src/modules/Message/hooks/useDirtyGetMentions.ts @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useState } from 'react'; -import { Logger } from '../../../lib/SendbirdState'; +import { Logger } from '../../../lib/Sendbird/types'; import { getMentionNodes } from '../utils/getMentionNodes'; interface DynamicParams { diff --git a/src/modules/MessageSearch/context/MessageSearchProvider.tsx b/src/modules/MessageSearch/context/MessageSearchProvider.tsx index 8a45a19d67..8c0a9645ac 100644 --- a/src/modules/MessageSearch/context/MessageSearchProvider.tsx +++ b/src/modules/MessageSearch/context/MessageSearchProvider.tsx @@ -5,8 +5,6 @@ import { ClientSentMessages } from '../../../types'; import { SendbirdError } from '@sendbird/chat'; import type { MessageSearchQueryParams } from '@sendbird/chat/lib/__definition'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; - import useSetChannel from './hooks/useSetChannel'; import useGetSearchMessages from './hooks/useGetSearchedMessages'; import useScrollCallback from './hooks/useScrollCallback'; @@ -15,6 +13,7 @@ import { CoreMessageType } from '../../../utils'; import { createStore } from '../../../utils/storeManager'; import { useStore } from '../../../hooks/useStore'; import useMessageSearch from './hooks/useMessageSearch'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export interface MessageSearchProviderProps { channelUrl: string; @@ -68,7 +67,7 @@ const MessageSearchManager: React.FC = ({ onResultClick, }) => { const { state, updateState } = useMessageSearchStore(); - const { config, stores } = useSendbirdStateContext(); + const { state: { config, stores } } = useSendbird(); const sdk = stores?.sdkStore?.sdk; const sdkInit = stores?.sdkStore?.initialized; const { logger } = config; diff --git a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx b/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx index ecd1df6935..60d9c8590e 100644 --- a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx +++ b/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx @@ -6,20 +6,22 @@ import { MessageSearchQuery } from '@sendbird/chat/message'; import { MessageSearchProvider } from '../MessageSearchProvider'; import useMessageSearch from '../hooks/useMessageSearch'; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - createMessageSearchQuery: jest.fn(() => ({ - next: jest.fn().mockResolvedValue([{ messageId: 1 }]), - })), + state: { + stores: { + sdkStore: { + sdk: { + createMessageSearchQuery: jest.fn(() => ({ + next: jest.fn().mockResolvedValue([{ messageId: 1 }]), + })), + }, + initialized: true, }, - initialized: true, }, + config: { logger: console }, }, - config: { logger: console }, })), })); diff --git a/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx b/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx index a2fd2abc22..dfcc16a5e4 100644 --- a/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx +++ b/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx @@ -5,21 +5,23 @@ import useMessageSearch from '../../context/hooks/useMessageSearch'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { MessageSearchQuery } from '@sendbird/chat/message'; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - createMessageSearchQuery: jest.fn(() => ({ - next: jest.fn().mockResolvedValue([{ messageId: 1 }]), - })), - }, - initialized: true, +const mockState = { + stores: { + sdkStore: { + sdk: { + createMessageSearchQuery: jest.fn(() => ({ + next: jest.fn().mockResolvedValue([{ messageId: 1 }]), + })), }, + initialized: true, }, - config: { logger: console }, - })), + }, + config: { logger: console }, +}; +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), + useSendbird: jest.fn(() => ({ state: mockState })), })); describe('useMessageSearch', () => { diff --git a/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts b/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts index f79a942821..b2f2b1cc08 100644 --- a/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts +++ b/src/modules/MessageSearch/context/hooks/useGetSearchedMessages.ts @@ -2,9 +2,8 @@ import { useEffect, useCallback } from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { MessageSearchQueryParams } from '@sendbird/chat/lib/__definition'; import type { SendbirdError } from '@sendbird/chat'; -import type { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import { CoreMessageType } from '../../../../utils'; -import { SdkStore } from '../../../../lib/types'; import useMessageSearch from '../hooks/useMessageSearch'; import { ClientSentMessages } from '../../../../types'; diff --git a/src/modules/MessageSearch/context/hooks/useSetChannel.ts b/src/modules/MessageSearch/context/hooks/useSetChannel.ts index eef0ead99a..d3fe8fcca2 100644 --- a/src/modules/MessageSearch/context/hooks/useSetChannel.ts +++ b/src/modules/MessageSearch/context/hooks/useSetChannel.ts @@ -1,6 +1,5 @@ import { useEffect } from 'react'; -import type { Logger } from '../../../../lib/SendbirdState'; -import { SdkStore } from '../../../../lib/types'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import useMessageSearch from '../hooks/useMessageSearch'; interface MainProps { diff --git a/src/modules/OpenChannel/components/OpenChannelMessage/index.tsx b/src/modules/OpenChannel/components/OpenChannelMessage/index.tsx index 578e57119d..a13ad1eadc 100644 --- a/src/modules/OpenChannel/components/OpenChannelMessage/index.tsx +++ b/src/modules/OpenChannel/components/OpenChannelMessage/index.tsx @@ -18,10 +18,10 @@ import FileViewer from '../../../../ui/FileViewer'; import RemoveMessageModal from './RemoveMessageModal'; import { MessageTypes, SendingMessageStatus, getMessageType } from './utils'; import { useOpenChannelContext } from '../../context/OpenChannelProvider'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import type { RenderMessageProps } from '../../../../types'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { CoreMessageType, SendableMessageType } from '../../../../utils'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export type OpenChannelMessageProps = { renderMessage?: (props: RenderMessageProps) => React.ReactElement; @@ -46,9 +46,9 @@ export default function OpenChannelMessage( const { dateLocale, stringSet } = useLocalization(); const editDisabled = currentOpenChannel?.isFrozen; - const globalState = useSendbirdStateContext(); - const currentUserId = globalState?.config?.userId; - const isOgMessageEnabledInOpenChannel = globalState.config.openChannel.enableOgtag; + const { state } = useSendbird(); + const currentUserId = state?.config?.userId; + const isOgMessageEnabledInOpenChannel = state.config.openChannel.enableOgtag; let sender: User | undefined; if (message?.messageType !== 'admin') { diff --git a/src/modules/OpenChannel/components/OpenChannelMessageList/index.tsx b/src/modules/OpenChannel/components/OpenChannelMessageList/index.tsx index ef60cc5da8..c9b8776563 100644 --- a/src/modules/OpenChannel/components/OpenChannelMessageList/index.tsx +++ b/src/modules/OpenChannel/components/OpenChannelMessageList/index.tsx @@ -11,9 +11,9 @@ import { useOpenChannelContext } from '../../context/OpenChannelProvider'; import OpenChannelMessage from '../OpenChannelMessage'; import { RenderMessageProps } from '../../../../types'; import { MessageProvider } from '../../../Message/context/MessageProvider'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useHandleOnScrollCallback } from '../../../../hooks/useHandleOnScrollCallback'; import { compareMessagesForGrouping } from '../../../../utils/messages'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export type OpenChannelMessageListProps = { renderMessage?: (props: RenderMessageProps) => React.ReactElement; @@ -30,8 +30,8 @@ function OpenChannelMessageList(props: OpenChannelMessageListProps, ref: React.F hasMore, onScroll, } = useOpenChannelContext(); - const store = useSendbirdStateContext(); - const userId = store.config.userId; + const { state } = useSendbird(); + const userId = state.config.userId; const localRef = useRef(null); const scrollRef = ref || localRef; const [showScrollDownButton, setShowScrollDownButton] = useState(false); diff --git a/src/modules/OpenChannel/context/OpenChannelProvider.tsx b/src/modules/OpenChannel/context/OpenChannelProvider.tsx index 7bdcddb28c..0db8233946 100644 --- a/src/modules/OpenChannel/context/OpenChannelProvider.tsx +++ b/src/modules/OpenChannel/context/OpenChannelProvider.tsx @@ -29,7 +29,7 @@ import useUpdateMessageCallback from './hooks/useUpdateMessageCallback'; import useDeleteMessageCallback from './hooks/useDeleteMessageCallback'; import useResendMessageCallback from './hooks/useResendMessageCallback'; import useTrimMessageList from './hooks/useTrimMessageList'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; type OpenChannelQueries = { // https://sendbird.github.io/core-sdk-javascript/module-model_params_messageListParams-MessageListParams.html @@ -101,12 +101,12 @@ const OpenChannelProvider: React.FC = (props: OpenChan // We didn't decide to support fetching participant list const fetchingParticipants = false; - const globalStore = useSendbirdStateContext(); + const { state } = useSendbird(); - const sdk = globalStore?.stores?.sdkStore?.sdk; - const sdkInit = globalStore?.stores?.sdkStore?.initialized; - const user = globalStore?.stores?.userStore?.user; - const config = globalStore?.config; + const sdk = state.stores.sdkStore.sdk; + const sdkInit = state.stores.sdkStore.initialized; + const user = state.stores.userStore.user; + const config = state.config; const { userId, diff --git a/src/modules/OpenChannel/context/hooks/useDeleteMessageCallback.ts b/src/modules/OpenChannel/context/hooks/useDeleteMessageCallback.ts index a462471686..bb7fd4401e 100644 --- a/src/modules/OpenChannel/context/hooks/useDeleteMessageCallback.ts +++ b/src/modules/OpenChannel/context/hooks/useDeleteMessageCallback.ts @@ -1,9 +1,9 @@ import type { UserMessage } from '@sendbird/chat/message'; import type { OpenChannel } from '@sendbird/chat/openChannel'; +import type { Logger } from '../../../../lib/Sendbird/types'; import { useCallback } from 'react'; import * as messageActionTypes from '../dux/actionTypes'; import { SendableMessageType } from '../../../../utils'; -import { Logger } from '../../../../lib/SendbirdState'; interface DynamicParams { currentOpenChannel: OpenChannel | null; diff --git a/src/modules/OpenChannel/context/hooks/useFileUploadCallback.tsx b/src/modules/OpenChannel/context/hooks/useFileUploadCallback.tsx index e29024ac2a..93a8de7ede 100644 --- a/src/modules/OpenChannel/context/hooks/useFileUploadCallback.tsx +++ b/src/modules/OpenChannel/context/hooks/useFileUploadCallback.tsx @@ -2,18 +2,16 @@ import React, { useCallback } from 'react'; import type { OpenChannel } from '@sendbird/chat/openChannel'; import type { FileMessageCreateParams } from '@sendbird/chat/message'; -import type { Logger } from '../../../../lib/SendbirdState'; -import type { ImageCompressionOptions } from '../../../../lib/Sendbird'; +import type { Logger, SdkStore, ImageCompressionOptions } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; import * as utils from '../utils'; -import { SdkStore } from '../../../../lib/types'; import { compressImages } from '../../../../utils/compressImages'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useGlobalModalContext } from '../../../../hooks/useModal'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { ONE_MiB } from '../../../../utils/consts'; import { ModalFooter } from '../../../../ui/Modal'; import { ButtonTypes } from '../../../../ui/Button'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface DynamicParams { currentOpenChannel: OpenChannel | null; @@ -39,8 +37,7 @@ function useFileUploadCallback({ ): CallbackReturn { const { stringSet } = useLocalization(); const { openModal } = useGlobalModalContext(); - const { config } = useSendbirdStateContext(); - const { uikitUploadSizeLimit } = config; + const { state: { config: { uikitUploadSizeLimit } } } = useSendbird(); return useCallback(async (files) => { if (sdk) { diff --git a/src/modules/OpenChannel/context/hooks/useHandleChannelEvents.ts b/src/modules/OpenChannel/context/hooks/useHandleChannelEvents.ts index 0391aa1814..b5e67dc2bf 100644 --- a/src/modules/OpenChannel/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/OpenChannel/context/hooks/useHandleChannelEvents.ts @@ -4,8 +4,7 @@ import { OpenChannel, OpenChannelHandler } from '@sendbird/chat/openChannel'; import * as messageActionTypes from '../dux/actionTypes'; import uuidv4 from '../../../../utils/uuid'; import { scrollIntoLast } from '../utils'; -import { Logger } from '../../../../lib/SendbirdState'; -import { SdkStore } from '../../../../lib/types'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; type MessagesDispatcherType = { type: string, payload: any, diff --git a/src/modules/OpenChannel/context/hooks/useInitialMessagesFetch.ts b/src/modules/OpenChannel/context/hooks/useInitialMessagesFetch.ts index a7b1b3e243..ff4ef58d0a 100644 --- a/src/modules/OpenChannel/context/hooks/useInitialMessagesFetch.ts +++ b/src/modules/OpenChannel/context/hooks/useInitialMessagesFetch.ts @@ -2,7 +2,7 @@ import { MessageListParams } from '@sendbird/chat/message'; import type { OpenChannel } from '@sendbird/chat/openChannel'; import React, { useEffect } from 'react'; -import type { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import type { CustomUseReducerDispatcher, Logger } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; import { scrollIntoLast } from '../utils'; diff --git a/src/modules/OpenChannel/context/hooks/useResendMessageCallback.ts b/src/modules/OpenChannel/context/hooks/useResendMessageCallback.ts index 47615d1826..357208e452 100644 --- a/src/modules/OpenChannel/context/hooks/useResendMessageCallback.ts +++ b/src/modules/OpenChannel/context/hooks/useResendMessageCallback.ts @@ -1,8 +1,8 @@ import type { OpenChannel } from '@sendbird/chat/openChannel'; +import type { Logger } from '../../../../lib/Sendbird/types'; import { useCallback } from 'react'; import * as messageActionTypes from '../dux/actionTypes'; import { SendableMessageType } from '../../../../utils'; -import { Logger } from '../../../../lib/SendbirdState'; interface DynamicParams { currentOpenChannel: OpenChannel | null; diff --git a/src/modules/OpenChannel/context/hooks/useScrollCallback.ts b/src/modules/OpenChannel/context/hooks/useScrollCallback.ts index 9bd5a95ef0..c4e89743a2 100644 --- a/src/modules/OpenChannel/context/hooks/useScrollCallback.ts +++ b/src/modules/OpenChannel/context/hooks/useScrollCallback.ts @@ -2,9 +2,8 @@ import { useCallback } from 'react'; import type { MessageListParams } from '@sendbird/chat/message'; import type { OpenChannel } from '@sendbird/chat/openChannel'; -import type { CustomUseReducerDispatcher, Logger } from '../../../../lib/SendbirdState'; +import type { CustomUseReducerDispatcher, Logger, SdkStore } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; -import { SdkStore } from '../../../../lib/types'; interface DynamicParams { currentOpenChannel: OpenChannel | null; diff --git a/src/modules/OpenChannel/context/hooks/useSendMessageCallback.ts b/src/modules/OpenChannel/context/hooks/useSendMessageCallback.ts index fa7286aca4..55ea407dc7 100644 --- a/src/modules/OpenChannel/context/hooks/useSendMessageCallback.ts +++ b/src/modules/OpenChannel/context/hooks/useSendMessageCallback.ts @@ -2,10 +2,9 @@ import type { UserMessageCreateParams } from '@sendbird/chat/message'; import type { OpenChannel } from '@sendbird/chat/openChannel'; import React, { useCallback } from 'react'; -import type { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; import * as utils from '../utils'; -import { SdkStore } from '../../../../lib/types'; import { SendableMessage } from '@sendbird/chat/lib/__definition'; interface DynamicParams { diff --git a/src/modules/OpenChannel/context/hooks/useSetChannel.ts b/src/modules/OpenChannel/context/hooks/useSetChannel.ts index baef53e629..deb50f3325 100644 --- a/src/modules/OpenChannel/context/hooks/useSetChannel.ts +++ b/src/modules/OpenChannel/context/hooks/useSetChannel.ts @@ -1,9 +1,8 @@ import type { OpenChannel } from '@sendbird/chat/openChannel'; import { useEffect } from 'react'; -import type { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import * as messageActionTypes from '../dux/actionTypes'; import * as utils from '../utils'; -import { SdkStore } from '../../../../lib/types'; interface DynamicParams { channelUrl: string; diff --git a/src/modules/OpenChannel/context/hooks/useUpdateMessageCallback.ts b/src/modules/OpenChannel/context/hooks/useUpdateMessageCallback.ts index f40980e4f3..4c83622313 100644 --- a/src/modules/OpenChannel/context/hooks/useUpdateMessageCallback.ts +++ b/src/modules/OpenChannel/context/hooks/useUpdateMessageCallback.ts @@ -1,6 +1,6 @@ import type { UserMessageUpdateParams } from '@sendbird/chat/message'; -import type { Logger } from '../../../../lib/SendbirdState'; +import type { Logger } from '../../../../lib/Sendbird/types'; import type { OpenChannel } from '@sendbird/chat/openChannel'; import { useCallback } from 'react'; import * as messageActionTypes from '../dux/actionTypes'; diff --git a/src/modules/OpenChannel/context/utils.ts b/src/modules/OpenChannel/context/utils.ts index f27548b6fe..79c1bdee83 100644 --- a/src/modules/OpenChannel/context/utils.ts +++ b/src/modules/OpenChannel/context/utils.ts @@ -3,7 +3,7 @@ import type { User } from '@sendbird/chat'; import type { OpenChannel, ParticipantListQuery } from '@sendbird/chat/openChannel'; import format from 'date-fns/format'; -import { Logger } from '../../../lib/SendbirdState'; +import { Logger } from '../../../lib/Sendbird/types'; import { SendableMessageType } from '../../../utils'; import { useLocalization } from '../../../lib/LocalizationContext'; diff --git a/src/modules/OpenChannelApp/components/CommunityChannelList.tsx b/src/modules/OpenChannelApp/components/CommunityChannelList.tsx index dcd5e30d82..cb7e1b46a4 100644 --- a/src/modules/OpenChannelApp/components/CommunityChannelList.tsx +++ b/src/modules/OpenChannelApp/components/CommunityChannelList.tsx @@ -12,7 +12,7 @@ import type { OpenChannelListQuery, SendbirdOpenChat, } from '@sendbird/chat/openChannel'; -import withSendBird from '../../../lib/SendbirdSdkContext'; +import { withSendBird } from '../../../lib/Sendbird/index'; import * as sendbirdSelectors from '../../../lib/selectors'; import './community-channel-list.scss'; diff --git a/src/modules/OpenChannelApp/components/StreamingChannelList.tsx b/src/modules/OpenChannelApp/components/StreamingChannelList.tsx index e16dc78c3c..ec35890481 100644 --- a/src/modules/OpenChannelApp/components/StreamingChannelList.tsx +++ b/src/modules/OpenChannelApp/components/StreamingChannelList.tsx @@ -1,6 +1,6 @@ import React, { ReactElement, useEffect, useState } from 'react'; -import withSendBird from '../../../lib/SendbirdSdkContext'; +import { withSendBird } from '../../../lib/Sendbird/index'; import * as sendbirdSelectors from '../../../lib/selectors'; import './streaming-channel-list.scss'; diff --git a/src/modules/OpenChannelList/context/OpenChannelListInterfaces.ts b/src/modules/OpenChannelList/context/OpenChannelListInterfaces.ts index 72f5fdda20..de2f9f1721 100644 --- a/src/modules/OpenChannelList/context/OpenChannelListInterfaces.ts +++ b/src/modules/OpenChannelList/context/OpenChannelListInterfaces.ts @@ -1,6 +1,6 @@ import { OpenChannel } from '@sendbird/chat/openChannel'; import { Dispatch } from 'react'; -import { Logger } from '../../../lib/SendbirdState'; +import { Logger } from '../../../lib/Sendbird/types'; import OpenChannelListActionTypes from './dux/actionTypes'; import { FetchNextCallbackType } from './hooks/useFetchNextCallback'; diff --git a/src/modules/OpenChannelList/context/OpenChannelListProvider.tsx b/src/modules/OpenChannelList/context/OpenChannelListProvider.tsx index b92624c9d7..e30b93888a 100644 --- a/src/modules/OpenChannelList/context/OpenChannelListProvider.tsx +++ b/src/modules/OpenChannelList/context/OpenChannelListProvider.tsx @@ -1,7 +1,6 @@ import React, { useContext, useReducer, useEffect } from 'react'; import pubSubTopics from '../../../lib/pubSub/topics'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import openChannelListReducer from './dux/reducer'; import openChannelListInitialState, { OpenChannelListInitialInterface } from './dux/initialState'; @@ -14,6 +13,7 @@ import useFetchNextCallback from './hooks/useFetchNextCallback'; import useSetupOpenChannelList from './hooks/useSetupOpenChannelList'; import useRefreshOpenChannelList from './hooks/useRefreshOpenChannelList'; import OpenChannelListActionTypes from './dux/actionTypes'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; const OpenChannelListContext = React.createContext(null); @@ -30,7 +30,7 @@ export const OpenChannelListProvider: React.FC = ( onChannelSelected, }: OpenChannelListProviderProps): React.ReactElement => { // props - const { stores, config } = useSendbirdStateContext(); + const { state: { stores, config } } = useSendbird(); const { logger, pubSub } = config; const sdk = stores?.sdkStore?.sdk || null; const sdkInitialized = stores?.sdkStore?.initialized || false; diff --git a/src/modules/OpenChannelList/context/hooks/createChannelListQuery.ts b/src/modules/OpenChannelList/context/hooks/createChannelListQuery.ts index 15e50af5bd..a6f2eb0e55 100644 --- a/src/modules/OpenChannelList/context/hooks/createChannelListQuery.ts +++ b/src/modules/OpenChannelList/context/hooks/createChannelListQuery.ts @@ -1,8 +1,7 @@ +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import { OpenChannelListQuery, OpenChannelListQueryParams } from '@sendbird/chat/openChannel'; -import { Logger } from '../../../../lib/SendbirdState'; import OpenChannelListActionTypes from '../dux/actionTypes'; import { OpenChannelListDispatcherType, UserFilledOpenChannelListQuery } from '../OpenChannelListInterfaces'; -import { SdkStore } from '../../../../lib/types'; interface createChannelListQueryProps { sdk: SdkStore['sdk']; diff --git a/src/modules/OpenChannelList/context/hooks/useFetchNextCallback.ts b/src/modules/OpenChannelList/context/hooks/useFetchNextCallback.ts index be807ef802..cdd89f229e 100644 --- a/src/modules/OpenChannelList/context/hooks/useFetchNextCallback.ts +++ b/src/modules/OpenChannelList/context/hooks/useFetchNextCallback.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { SendbirdError } from '@sendbird/chat'; import { OpenChannel, OpenChannelListQuery } from '@sendbird/chat/openChannel'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger } from '../../../../lib/Sendbird/types'; import OpenChannelListActionTypes from '../dux/actionTypes'; import { OpenChannelListDispatcherType } from '../OpenChannelListInterfaces'; diff --git a/src/modules/OpenChannelList/context/hooks/useRefreshOpenChannelList.ts b/src/modules/OpenChannelList/context/hooks/useRefreshOpenChannelList.ts index 882bbcdc44..279193cac1 100644 --- a/src/modules/OpenChannelList/context/hooks/useRefreshOpenChannelList.ts +++ b/src/modules/OpenChannelList/context/hooks/useRefreshOpenChannelList.ts @@ -1,10 +1,9 @@ import { useCallback } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import createChannelListQuery from './createChannelListQuery'; import { OpenChannelListDispatcherType, UserFilledOpenChannelListQuery } from '../OpenChannelListInterfaces'; import OpenChannelListActionTypes from '../dux/actionTypes'; -import { SdkStore } from '../../../../lib/types'; interface DynamicParams { sdk: SdkStore['sdk']; diff --git a/src/modules/OpenChannelList/context/hooks/useSetupOpenChannelList.ts b/src/modules/OpenChannelList/context/hooks/useSetupOpenChannelList.ts index e0509b3e18..132289499a 100644 --- a/src/modules/OpenChannelList/context/hooks/useSetupOpenChannelList.ts +++ b/src/modules/OpenChannelList/context/hooks/useSetupOpenChannelList.ts @@ -1,10 +1,9 @@ import { useEffect } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import OpenChannelListActionTypes from '../dux/actionTypes'; import { OpenChannelListDispatcherType, UserFilledOpenChannelListQuery } from '../OpenChannelListInterfaces'; import createChannelListQuery from './createChannelListQuery'; -import { SdkStore } from '../../../../lib/types'; interface DynamicParams { sdk: SdkStore['sdk']; diff --git a/src/modules/OpenChannelSettings/components/EditDetailsModal.tsx b/src/modules/OpenChannelSettings/components/EditDetailsModal.tsx index 514e2a8bc9..2f5dca6ac8 100644 --- a/src/modules/OpenChannelSettings/components/EditDetailsModal.tsx +++ b/src/modules/OpenChannelSettings/components/EditDetailsModal.tsx @@ -16,7 +16,7 @@ import Label, { LabelColors, LabelTypography } from '../../../ui/Label'; import TextButton from '../../../ui/TextButton'; import OpenChannelAvatar from '../../../ui/ChannelAvatar/OpenChannelAvatar'; import { useOpenChannelSettingsContext } from '../context/OpenChannelSettingsProvider'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; interface Props { onCancel(): void; @@ -26,8 +26,8 @@ const EditDetails = (props: Props): ReactElement => { const { onCancel, } = props; - const globalState = useSendbirdStateContext(); - const { logger, theme, pubSub } = globalState.config; + const { state } = useSendbird(); + const { logger, theme, pubSub } = state.config; const { channel, onBeforeUpdateChannel, diff --git a/src/modules/OpenChannelSettings/components/OpenChannelProfile/index.tsx b/src/modules/OpenChannelSettings/components/OpenChannelProfile/index.tsx index a32b7c35ce..5b3c53a18c 100644 --- a/src/modules/OpenChannelSettings/components/OpenChannelProfile/index.tsx +++ b/src/modules/OpenChannelSettings/components/OpenChannelProfile/index.tsx @@ -11,13 +11,13 @@ import Label, { LabelColors, LabelTypography } from '../../../../ui/Label'; import TextButton from '../../../../ui/TextButton'; import OpenChannelAvatar from '../../../../ui/ChannelAvatar/OpenChannelAvatar'; import EditDetailsModal from '../EditDetailsModal'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export default function ChannelProfile(): ReactElement { - const globalState = useSendbirdStateContext(); - const disabled = !(globalState?.config?.isOnline); - const theme = globalState?.config?.theme; + const { state } = useSendbird(); + const disabled = !(state?.config?.isOnline); + const theme = state?.config?.theme; const { channel, } = useOpenChannelSettingsContext(); diff --git a/src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/index.tsx b/src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/index.tsx index ed55d24827..1d1806b760 100644 --- a/src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/index.tsx +++ b/src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/index.tsx @@ -1,7 +1,6 @@ import './open-channel-ui.scss'; import React, { useContext } from 'react'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; @@ -11,6 +10,7 @@ import ParticipantUI from '../ParticipantUI'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import Icon, { IconTypes } from '../../../../ui/Icon'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface OpenChannelUIProps { renderOperatorUI?: () => React.ReactElement; @@ -26,9 +26,9 @@ const OpenChannelUI: React.FC = ({ onCloseClick, isChannelInitialized, } = useOpenChannelSettingsContext(); - const globalStore = useSendbirdStateContext(); - const logger = globalStore?.config?.logger; - const user = globalStore?.stores?.userStore?.user; + const { state } = useSendbird(); + const logger = state?.config?.logger; + const user = state?.stores?.userStore?.user; const { stringSet } = useContext(LocalizationContext); if (isChannelInitialized && !channel) { diff --git a/src/modules/OpenChannelSettings/components/OperatorUI/BannedUserList.tsx b/src/modules/OpenChannelSettings/components/OperatorUI/BannedUserList.tsx index 0a710df2ae..3db541f982 100644 --- a/src/modules/OpenChannelSettings/components/OperatorUI/BannedUserList.tsx +++ b/src/modules/OpenChannelSettings/components/OperatorUI/BannedUserList.tsx @@ -16,8 +16,8 @@ import BannedUsersModal from './BannedUsersModal'; import { UserListItem } from '../ParticipantUI/ParticipantItem'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { RestrictedUser } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export const BannedUserList = (): ReactElement => { const [bannedUsers, setBannedUsers] = useState([]); @@ -25,7 +25,7 @@ export const BannedUserList = (): ReactElement => { const [showModal, setShowModal] = useState(false); const { channel } = useOpenChannelSettingsContext(); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const { stringSet } = useContext(LocalizationContext); const currentUserId = state?.config?.userId; diff --git a/src/modules/OpenChannelSettings/components/OperatorUI/BannedUsersModal.tsx b/src/modules/OpenChannelSettings/components/OperatorUI/BannedUsersModal.tsx index f4d27e41c4..e94e8e64e6 100644 --- a/src/modules/OpenChannelSettings/components/OperatorUI/BannedUsersModal.tsx +++ b/src/modules/OpenChannelSettings/components/OperatorUI/BannedUsersModal.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState, } from 'react'; +import { BannedUserListQuery, RestrictedUser } from '@sendbird/chat'; import Modal from '../../../../ui/Modal'; import UserListItem from '../../../../ui/UserListItem'; @@ -14,8 +15,7 @@ import ContextMenu, { MenuItem, MenuItems } from '../../../../ui/ContextMenu'; import { noop } from '../../../../utils/utils'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; -import { BannedUserListQuery, RestrictedUser } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface Props { onCancel(): void; @@ -27,7 +27,7 @@ export default function BannedUsersModal({ const [bannedUsers, setBannedUsers] = useState([]); const [userListQuery, setUserListQuery] = useState(null); const { channel } = useOpenChannelSettingsContext(); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const { stringSet } = useContext(LocalizationContext); const currentUserId = state?.config?.userId; diff --git a/src/modules/OpenChannelSettings/components/OperatorUI/DeleteOpenChannel.tsx b/src/modules/OpenChannelSettings/components/OperatorUI/DeleteOpenChannel.tsx index db5468213b..720f8ac6b3 100644 --- a/src/modules/OpenChannelSettings/components/OperatorUI/DeleteOpenChannel.tsx +++ b/src/modules/OpenChannelSettings/components/OperatorUI/DeleteOpenChannel.tsx @@ -8,15 +8,13 @@ import Modal from '../../../../ui/Modal'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export default function DeleteChannel(): ReactElement { const [showDeleteChannelModal, setShowDeleteChannelModal] = useState(false); const { stringSet } = useContext(LocalizationContext); - const globalState = useSendbirdStateContext(); - const isOnline = globalState?.config?.isOnline; - const logger = globalState?.config?.logger; + const { state: { config: { isOnline, logger } } } = useSendbird(); const { channel, onDeleteChannel } = useOpenChannelSettingsContext(); diff --git a/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantList.tsx b/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantList.tsx index 4e13d51332..d90ba105ba 100644 --- a/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantList.tsx +++ b/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantList.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useContext, } from 'react'; +import { RestrictedUser } from '@sendbird/chat'; import Button, { ButtonTypes, ButtonSizes } from '../../../../ui/Button'; import IconButton from '../../../../ui/IconButton'; @@ -13,10 +14,9 @@ import ContextMenu, { MenuItem, MenuItems } from '../../../../ui/ContextMenu'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import { UserListItem } from '../ParticipantUI/ParticipantItem'; import MutedParticipantsModal from './MutedParticipantsModal'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; -import { RestrictedUser } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export const MutedParticipantList = (): ReactElement => { const [mutedUsers, setMutedUsers] = useState([]); @@ -24,7 +24,7 @@ export const MutedParticipantList = (): ReactElement => { const [showModal, setShowModal] = useState(false); const { channel } = useOpenChannelSettingsContext(); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const currentUserId = state?.config?.userId; const { stringSet } = useContext(LocalizationContext); diff --git a/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantsModal.tsx b/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantsModal.tsx index 03206a0d22..28d49ae8b0 100644 --- a/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantsModal.tsx +++ b/src/modules/OpenChannelSettings/components/OperatorUI/MutedParticipantsModal.tsx @@ -12,9 +12,9 @@ import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import ContextMenu, { MenuItem, MenuItems } from '../../../../ui/ContextMenu'; import { noop } from '../../../../utils/utils'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import { MutedUserListQuery, User } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface Props { onCancel(): void; @@ -27,7 +27,7 @@ export default function MutedParticipantsModal({ const [userListQuery, setUserListQuery] = useState(null); const { channel } = useOpenChannelSettingsContext(); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const currentUserId = state?.config?.userId; const { stringSet } = useContext(LocalizationContext); diff --git a/src/modules/OpenChannelSettings/components/OperatorUI/OperatorList.tsx b/src/modules/OpenChannelSettings/components/OperatorUI/OperatorList.tsx index f6d9c8212f..e262b3b105 100644 --- a/src/modules/OpenChannelSettings/components/OperatorUI/OperatorList.tsx +++ b/src/modules/OpenChannelSettings/components/OperatorUI/OperatorList.tsx @@ -1,7 +1,6 @@ import React, { ReactElement, useContext, useState } from 'react'; import { UserListItem } from '../ParticipantUI/ParticipantItem'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import Button, { ButtonTypes, ButtonSizes } from '../../../../ui/Button'; import ContextMenu, { MenuItem, MenuItems, MuteMenuItem } from '../../../../ui/ContextMenu'; @@ -12,11 +11,12 @@ import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettings import OperatorListModal from './OperatorsModal'; import AddOperatorsModal from './AddOperatorsModal'; import { Participant } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; const OperatorList = (): ReactElement => { const [showAdd, setShowAdd] = useState(false); const [showMore, setShowMore] = useState(false); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const currentUserId = state?.config?.userId; const { stringSet } = useContext(LocalizationContext); const { channel } = useOpenChannelSettingsContext(); diff --git a/src/modules/OpenChannelSettings/components/OperatorUI/OperatorsModal.tsx b/src/modules/OpenChannelSettings/components/OperatorUI/OperatorsModal.tsx index d82d2237c9..42901f3c4c 100644 --- a/src/modules/OpenChannelSettings/components/OperatorUI/OperatorsModal.tsx +++ b/src/modules/OpenChannelSettings/components/OperatorUI/OperatorsModal.tsx @@ -11,10 +11,10 @@ import IconButton from '../../../../ui/IconButton'; import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import ContextMenu, { MenuItem, MenuItems } from '../../../../ui/ContextMenu'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import { OperatorListQuery, User } from '@sendbird/chat'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface Props { onCancel?(): void } @@ -23,7 +23,7 @@ export default function OperatorListModal({ onCancel }: Props): ReactElement { const [operatorQuery, setOperatorQuery] = useState(null); const { channel } = useOpenChannelSettingsContext(); - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const currentUserId = state?.config?.userId; const { stringSet } = useContext(LocalizationContext); diff --git a/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx b/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx index 261d89c13e..15f629d3f3 100644 --- a/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx +++ b/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantItem.tsx @@ -18,8 +18,8 @@ import ParticipantsModal from './ParticipantsModal'; import UserProfile from '../../../../ui/UserProfile'; import ContextMenu, { MenuItems } from '../../../../ui/ContextMenu'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import MutedAvatarOverlay from '../../../../ui/Avatar/MutedAvatarOverlay'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; const SHOWN_MEMBER_MAX = 10; @@ -153,8 +153,8 @@ export interface ParticipantsAccordionProps { export default function ParticipantsAccordion(props: ParticipantsAccordionProps): ReactElement { const maxMembers = props?.maxMembers || SHOWN_MEMBER_MAX; const { channel } = useOpenChannelSettingsContext(); - const globalState = useSendbirdStateContext(); - const currentUserId = globalState?.config?.userId; + const { state } = useSendbird(); + const currentUserId = state?.config?.userId; const [participants, setParticipants] = useState([]); const [showMoreModal, setShowMoreModal] = useState(false); const { stringSet } = useContext(LocalizationContext); diff --git a/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantsModal.tsx b/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantsModal.tsx index 4d77222a20..b757d75306 100644 --- a/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantsModal.tsx +++ b/src/modules/OpenChannelSettings/components/ParticipantUI/ParticipantsModal.tsx @@ -15,7 +15,7 @@ import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import { noop } from '../../../../utils/utils'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface Props { onCancel(): void; @@ -24,7 +24,7 @@ interface Props { export default function ParticipantsModal({ onCancel, }: Props): ReactElement { - const state = useSendbirdStateContext(); + const { state } = useSendbird(); const { channel } = useOpenChannelSettingsContext(); const { stringSet } = useContext(LocalizationContext); const [participants, setParticipants] = useState | null>([]); diff --git a/src/modules/OpenChannelSettings/components/ParticipantUI/index.tsx b/src/modules/OpenChannelSettings/components/ParticipantUI/index.tsx index 010dc959b5..9335336923 100644 --- a/src/modules/OpenChannelSettings/components/ParticipantUI/index.tsx +++ b/src/modules/OpenChannelSettings/components/ParticipantUI/index.tsx @@ -18,7 +18,7 @@ import { UserListItem } from './ParticipantItem'; import ParticipantsModal from './ParticipantsModal'; import { LocalizationContext } from '../../../../lib/LocalizationContext'; import { useOpenChannelSettingsContext } from '../../context/OpenChannelSettingsProvider'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; interface ParticipantListProps { isOperatorView?: boolean; @@ -27,8 +27,8 @@ interface ParticipantListProps { export default function ParticipantList({ isOperatorView = false, }: ParticipantListProps): ReactElement { - const globalState = useSendbirdStateContext(); - const currentUserId = globalState?.config?.userId; + const { state } = useSendbird(); + const currentUserId = state?.config?.userId; const { channel } = useOpenChannelSettingsContext(); const { stringSet } = useContext(LocalizationContext); const [participants, setParticipants] = useState | null>(null); diff --git a/src/modules/OpenChannelSettings/context/OpenChannelSettingsProvider.tsx b/src/modules/OpenChannelSettings/context/OpenChannelSettingsProvider.tsx index 253c62f0c9..0e9a7d1cec 100644 --- a/src/modules/OpenChannelSettings/context/OpenChannelSettingsProvider.tsx +++ b/src/modules/OpenChannelSettings/context/OpenChannelSettingsProvider.tsx @@ -4,10 +4,10 @@ import React, { } from 'react'; import { OpenChannel, OpenChannelHandler, OpenChannelUpdateParams } from '@sendbird/chat/openChannel'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import { UserProfileProvider } from '../../../lib/UserProfileContext'; import { RenderUserProfileProps } from '../../../types'; import uuidv4 from '../../../utils/uuid'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export interface OpenChannelSettingsContextProps { channelUrl: string; @@ -44,11 +44,11 @@ const OpenChannelSettingsProvider: React.FC = ( } = props; // fetch store from - const globalStore = useSendbirdStateContext(); - const sdk = globalStore?.stores?.sdkStore?.sdk; - const isSDKInitialized = globalStore?.stores?.sdkStore?.initialized; + const { state: { stores, config } } = useSendbird(); + const sdk = stores?.sdkStore?.sdk; + const isSDKInitialized = stores?.sdkStore?.initialized; - const logger = globalStore?.config?.logger; + const logger = config?.logger; const currentUserId = sdk?.currentUser?.userId; const [currentChannel, setChannel] = useState(null); @@ -155,7 +155,7 @@ const OpenChannelSettingsProvider: React.FC = ( {children} diff --git a/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx b/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx index c990decae9..407abd3f94 100644 --- a/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx +++ b/src/modules/Thread/components/ParentMessageInfo/ParentMessageInfoItem.tsx @@ -26,7 +26,6 @@ import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import ImageRenderer from '../../../../ui/ImageRenderer'; import Icon, { IconTypes, IconColors } from '../../../../ui/Icon'; import TextButton from '../../../../ui/TextButton'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import EmojiReactions from '../../../../ui/EmojiReactions'; import VoiceMessageItemBody from '../../../../ui/VoiceMessageItemBody'; import TextFragment from '../../../Message/components/TextFragment'; @@ -39,6 +38,7 @@ import { Colors } from '../../../../utils/color'; import type { OnBeforeDownloadFileMessageType } from '../../../GroupChannel/context/types'; import { openURL } from '../../../../utils/utils'; import useThread from '../../context/useThread'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ParentMessageInfoItemProps { className?: string; @@ -53,7 +53,7 @@ export default function ParentMessageInfoItem({ showFileViewer, onBeforeDownloadFileMessage = null, }: ParentMessageInfoItemProps): ReactElement { - const { stores, config, eventHandlers } = useSendbirdStateContext(); + const { state: { stores, config, eventHandlers } } = useSendbird(); const { logger } = config; const onPressUserProfileHandler = eventHandlers?.reaction?.onPressUserProfile; const currentUserId = stores?.userStore?.user?.userId; diff --git a/src/modules/Thread/components/ParentMessageInfo/index.tsx b/src/modules/Thread/components/ParentMessageInfo/index.tsx index f3238927c0..b300e6b2dd 100644 --- a/src/modules/Thread/components/ParentMessageInfo/index.tsx +++ b/src/modules/Thread/components/ParentMessageInfo/index.tsx @@ -9,7 +9,6 @@ import ParentMessageInfoItem from './ParentMessageInfoItem'; import { getSenderName, SendableMessageType } from '../../../../utils'; import { getIsReactionEnabled } from '../../../../utils/getIsReactionEnabled'; import { useLocalization } from '../../../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useUserProfileContext } from '../../../../lib/UserProfileContext'; import SuggestedMentionList from '../SuggestedMentionList'; @@ -21,7 +20,7 @@ import ContextMenu, { EMOJI_MENU_ROOT_ID, getObservingId, MENU_OBSERVING_CLASS_N import ConnectedUserProfile from '../../../../ui/UserProfile'; import MessageInput from '../../../../ui/MessageInput'; import { MessageInputKeys } from '../../../../ui/MessageInput/const'; -import { Role } from '../../../../lib/types'; +import { Role } from '../../../../lib/Sendbird/types'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; import useLongPress from '../../../../hooks/useLongPress'; import MobileMenu from '../../../../ui/MobileMenu'; @@ -32,6 +31,7 @@ import { classnames } from '../../../../utils/utils'; import { MessageMenu, MessageMenuProps } from '../../../../ui/MessageMenu'; import useElementObserver from '../../../../hooks/useElementObserver'; import useThread from '../../context/useThread'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ParentMessageInfoProps { className?: string; @@ -44,7 +44,7 @@ export default function ParentMessageInfo({ renderEmojiMenu = (props) => , renderMessageMenu = (props) => , }: ParentMessageInfoProps): React.ReactElement { - const { stores, config } = useSendbirdStateContext(); + const { state: { stores, config } } = useSendbird(); const { isOnline, userMention, logger, groupChannel } = config; const userId = stores.userStore.user?.userId ?? ''; const { dateLocale, stringSet } = useLocalization(); diff --git a/src/modules/Thread/components/ThreadList/ThreadListItem.tsx b/src/modules/Thread/components/ThreadList/ThreadListItem.tsx index 873e04ccee..7c5bbb7f93 100644 --- a/src/modules/Thread/components/ThreadList/ThreadListItem.tsx +++ b/src/modules/Thread/components/ThreadList/ThreadListItem.tsx @@ -7,13 +7,12 @@ import DateSeparator from '../../../../ui/DateSeparator'; import Label, { LabelTypography, LabelColors } from '../../../../ui/Label'; import RemoveMessage from '../RemoveMessageModal'; import FileViewer from '../../../../ui/FileViewer'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import SuggestedMentionList from '../SuggestedMentionList'; import MessageInput from '../../../../ui/MessageInput'; import { ThreadListStateTypes } from '../../types'; import { MessageInputKeys } from '../../../../ui/MessageInput/const'; import ThreadListItemContent from './ThreadListItemContent'; -import { Role } from '../../../../lib/types'; +import { Role } from '../../../../lib/Sendbird/types'; import { useDirtyGetMentions } from '../../../Message/hooks/useDirtyGetMentions'; import { getIsReactionEnabled } from '../../../../utils/getIsReactionEnabled'; import { SendableMessageType } from '../../../../utils'; @@ -22,6 +21,7 @@ import { getCaseResolvedReplyType } from '../../../../lib/utils/resolvedReplyTyp import { classnames } from '../../../../utils/utils'; import { MessageComponentRenderers } from '../../../../ui/MessageContent'; import useThread from '../../context/useThread'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ThreadListItemProps extends MessageComponentRenderers { className?: string; @@ -43,7 +43,7 @@ export default function ThreadListItem(props: ThreadListItemProps): React.ReactE renderCustomSeparator, handleScroll, } = props; - const { stores, config } = useSendbirdStateContext(); + const { state: { stores, config } } = useSendbird(); const { isOnline, userMention, logger, groupChannel } = config; const userId = stores?.userStore?.user?.userId; const { dateLocale, stringSet } = useLocalization(); diff --git a/src/modules/Thread/components/ThreadList/ThreadListItemContent.tsx b/src/modules/Thread/components/ThreadList/ThreadListItemContent.tsx index 7e05bb74cd..89e6ce61ed 100644 --- a/src/modules/Thread/components/ThreadList/ThreadListItemContent.tsx +++ b/src/modules/Thread/components/ThreadList/ThreadListItemContent.tsx @@ -23,7 +23,6 @@ import { useLocalization } from '../../../../lib/LocalizationContext'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; import useLongPress from '../../../../hooks/useLongPress'; import MobileMenu from '../../../../ui/MobileMenu'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { ThreadMessageKind } from '../../../../ui/MultipleFilesMessageItemBody'; import { useThreadMessageKindKeySelector } from '../../../Channel/context/hooks/useThreadMessageKindKeySelector'; import { useFileInfoListWithUploaded } from '../../../Channel/context/hooks/useFileInfoListWithUploaded'; @@ -36,6 +35,7 @@ import MessageBody, { CustomSubcomponentsProps, MessageBodyProps } from '../../. import { MessageHeaderProps, MessageHeader } from '../../../../ui/MessageContent/MessageHeader'; import { MobileBottomSheetProps } from '../../../../ui/MobileMenu/types'; import useThread from '../../context/useThread'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ThreadListItemContentProps extends MessageComponentRenderers { className?: string; @@ -99,7 +99,7 @@ export default function ThreadListItemContent(props: ThreadListItemContentProps) const { isMobile } = useMediaQueryContext(); const { dateLocale, stringSet } = useLocalization(); - const { config, eventHandlers } = useSendbirdStateContext?.() || {}; + const { state: { config, eventHandlers } } = useSendbird(); const { logger } = config; const onPressUserProfileHandler = eventHandlers?.reaction?.onPressUserProfile; const isMenuMounted = useElementObserver( diff --git a/src/modules/Thread/components/ThreadList/index.tsx b/src/modules/Thread/components/ThreadList/index.tsx index 40c0bce1d9..a5d387c6aa 100644 --- a/src/modules/Thread/components/ThreadList/index.tsx +++ b/src/modules/Thread/components/ThreadList/index.tsx @@ -6,11 +6,11 @@ import './index.scss'; import type { SendableMessageType } from '../../../../utils'; import ThreadListItem, { ThreadListItemProps } from './ThreadListItem'; import { compareMessagesForGrouping } from '../../../../utils/messages'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { isSameDay } from 'date-fns'; import { MessageProvider } from '../../../Message/context/MessageProvider'; import { getCaseResolvedReplyType } from '../../../../lib/utils/resolvedReplyType'; import useThread from '../../context/useThread'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ThreadListProps { className?: string; @@ -27,7 +27,7 @@ export default function ThreadList({ scrollRef, scrollBottom, }: ThreadListProps): React.ReactElement { - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { userId } = config; const { state: { diff --git a/src/modules/Thread/components/ThreadMessageInput/index.tsx b/src/modules/Thread/components/ThreadMessageInput/index.tsx index e586350b25..f1734e2449 100644 --- a/src/modules/Thread/components/ThreadMessageInput/index.tsx +++ b/src/modules/Thread/components/ThreadMessageInput/index.tsx @@ -3,7 +3,6 @@ import { MutedState } from '@sendbird/chat/groupChannel'; import './index.scss'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useMediaQueryContext } from '../../../../lib/MediaQueryContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; @@ -11,7 +10,7 @@ import MessageInput from '../../../../ui/MessageInput'; import { MessageInputKeys } from '../../../../ui/MessageInput/const'; import { SuggestedMentionList } from '../SuggestedMentionList'; import { VoiceMessageInputWrapper } from '../../../GroupChannel/components/MessageInputWrapper'; -import { Role } from '../../../../lib/types'; +import { Role } from '../../../../lib/Sendbird/types'; import { useDirtyGetMentions } from '../../../Message/hooks/useDirtyGetMentions'; import { useHandleUploadFiles } from '../../../Channel/context/hooks/useHandleUploadFiles'; @@ -19,6 +18,7 @@ import { isDisabledBecauseFrozen, isDisabledBecauseMuted } from '../../../Channe import { User } from '@sendbird/chat'; import { classnames } from '../../../../utils/utils'; import useThread from '../../context/useThread'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ThreadMessageInputProps { className?: string; @@ -41,7 +41,7 @@ const ThreadMessageInput = ( acceptableMimeTypes, } = props; - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { isMobile } = useMediaQueryContext(); const { stringSet } = useLocalization(); const { isOnline, userMention, logger, groupChannel } = config; diff --git a/src/modules/Thread/components/ThreadUI/index.tsx b/src/modules/Thread/components/ThreadUI/index.tsx index 05f1cbf348..9e5d6cefff 100644 --- a/src/modules/Thread/components/ThreadUI/index.tsx +++ b/src/modules/Thread/components/ThreadUI/index.tsx @@ -2,7 +2,6 @@ import React, { ReactNode, useRef, useState } from 'react'; import './index.scss'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../../lib/LocalizationContext'; import { getChannelTitle } from '../../../GroupChannel/components/GroupChannelHeader/utils'; import { ParentMessageStateTypes, ThreadListStateTypes } from '../../types'; @@ -19,6 +18,7 @@ import { MessageProvider } from '../../../Message/context/MessageProvider'; import { SendableMessageType, getHTMLTextDirection } from '../../../../utils'; import { classnames } from '../../../../utils/utils'; import useThread from '../../context/useThread'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; export interface ThreadUIProps { renderHeader?: () => React.ReactElement; @@ -50,10 +50,7 @@ const ThreadUI: React.FC = ({ renderVoiceMessageIcon, renderSendMessageIcon, }: ThreadUIProps): React.ReactElement => { - const { - stores, - config, - } = useSendbirdStateContext(); + const { state: { stores, config } } = useSendbird(); const currentUserId = stores?.sdkStore?.sdk?.currentUser?.userId; const { stringSet, diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx index bb4da9a93b..16f543fd0c 100644 --- a/src/modules/Thread/context/ThreadProvider.tsx +++ b/src/modules/Thread/context/ThreadProvider.tsx @@ -9,7 +9,6 @@ import type { import { getNicknamesMapFromMembers, getParentMessageFrom } from './utils'; import { UserProfileProvider, UserProfileProviderProps } from '../../../lib/UserProfileContext'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import type { OnBeforeDownloadFileMessageType } from '../../GroupChannel/context/types'; import useGetChannel from './hooks/useGetChannel'; @@ -23,6 +22,7 @@ import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from import { useStore } from '../../../hooks/useStore'; import useSetCurrentUserId from './hooks/useSetCurrentUserId'; import useThread from './useThread'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; export interface ThreadProviderProps extends Pick { @@ -138,8 +138,7 @@ export const ThreadManager: React.FC ({ - __esModule: true, - default: jest.fn(() => ({ - stores: { - sdkStore: { - sdk: { - groupChannel: { - getChannel: mockGetChannel, - }, +const mockState = { + stores: { + sdkStore: { + sdk: { + groupChannel: { + getChannel: mockGetChannel, }, - initialized: true, }, - userStore: { user: { userId: 'test-user-id' } }, + initialized: true, }, - config: { - logger: console, - pubSub: { - publish: jest.fn(), - }, - groupChannel: { - enableMention: true, - enableReactions: true, - }, + userStore: { user: { userId: 'test-user-id' } }, + }, + config: { + logger: console, + pubSub: { + publish: jest.fn(), + }, + groupChannel: { + enableMention: true, + enableReactions: true, }, - })), + }, +}; +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), })); describe('ThreadProvider', () => { @@ -50,6 +52,12 @@ describe('ThreadProvider', () => { messageId: 1, } as SendableMessageType; + beforeEach(() => { + const stateContextValue = { state: mockState }; + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); + }); + it('provides the correct initial state', async () => { const wrapper = ({ children }) => ( {children} diff --git a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts index 021ca7d271..fb81a7def8 100644 --- a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts @@ -1,6 +1,7 @@ -import { GroupChannel } from '@sendbird/chat/groupChannel'; import { useCallback } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; + +import type { Logger } from '../../../../lib/Sendbird/types'; import { SendableMessageType } from '../../../../utils'; interface DynamicProps { diff --git a/src/modules/Thread/context/hooks/useGetAllEmoji.ts b/src/modules/Thread/context/hooks/useGetAllEmoji.ts index e0a44a8464..d941aa09e4 100644 --- a/src/modules/Thread/context/hooks/useGetAllEmoji.ts +++ b/src/modules/Thread/context/hooks/useGetAllEmoji.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; -import { SdkStore } from '../../../../lib/types'; import useThread from '../useThread'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; interface DanamicPrpos { sdk: SdkStore['sdk']; diff --git a/src/modules/Thread/context/hooks/useGetChannel.ts b/src/modules/Thread/context/hooks/useGetChannel.ts index 7319ba6a81..608e12d23f 100644 --- a/src/modules/Thread/context/hooks/useGetChannel.ts +++ b/src/modules/Thread/context/hooks/useGetChannel.ts @@ -1,8 +1,7 @@ import { useEffect } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import { SendableMessageType } from '../../../../utils'; -import { SdkStore } from '../../../../lib/types'; import useThread from '../useThread'; interface DynamicProps { diff --git a/src/modules/Thread/context/hooks/useGetParentMessage.ts b/src/modules/Thread/context/hooks/useGetParentMessage.ts index adfcd491d6..23424cf178 100644 --- a/src/modules/Thread/context/hooks/useGetParentMessage.ts +++ b/src/modules/Thread/context/hooks/useGetParentMessage.ts @@ -1,9 +1,8 @@ import { useEffect } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import { BaseMessage, MessageRetrievalParams } from '@sendbird/chat/message'; import { ChannelType } from '@sendbird/chat'; -import { SdkStore } from '../../../../lib/types'; import useThread from '../useThread'; interface DynamicProps { diff --git a/src/modules/Thread/context/hooks/useHandleChannelEvents.ts b/src/modules/Thread/context/hooks/useHandleChannelEvents.ts index 2cd11c4cbc..a26bde2d81 100644 --- a/src/modules/Thread/context/hooks/useHandleChannelEvents.ts +++ b/src/modules/Thread/context/hooks/useHandleChannelEvents.ts @@ -1,8 +1,7 @@ import { GroupChannel, GroupChannelHandler } from '@sendbird/chat/groupChannel'; import { useEffect } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; import uuidv4 from '../../../../utils/uuid'; -import { SdkStore } from '../../../../lib/types'; +import type { Logger, SdkStore } from '../../../../lib/Sendbird/types'; import compareIds from '../../../../utils/compareIds'; import useThread from '../useThread'; import { SendableMessageType } from '../../../../utils'; diff --git a/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts b/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts index 022bac16bb..780acaaa2d 100644 --- a/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts +++ b/src/modules/Thread/context/hooks/useHandleThreadPubsubEvents.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; -import { Logger } from '../../../../lib/SendbirdState'; + +import type { Logger } from '../../../../lib/Sendbird/types'; import topics, { PUBSUB_TOPICS, SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { scrollIntoLast } from '../utils'; import { SendableMessageType } from '../../../../utils'; diff --git a/src/modules/Thread/context/hooks/useResendMessageCallback.ts b/src/modules/Thread/context/hooks/useResendMessageCallback.ts index 2e9d5e20cd..428add20ed 100644 --- a/src/modules/Thread/context/hooks/useResendMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useResendMessageCallback.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { FileMessage, @@ -7,8 +8,8 @@ import { UploadableFileInfo, UserMessage, } from '@sendbird/chat/message'; -import { useCallback } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; + +import type { Logger } from '../../../../lib/Sendbird/types'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { SendableMessageType } from '../../../../utils'; import { PublishingModuleType } from '../../../internalInterfaces'; diff --git a/src/modules/Thread/context/hooks/useSendFileMessage.ts b/src/modules/Thread/context/hooks/useSendFileMessage.ts index cf7babd54a..ef1b4bc14e 100644 --- a/src/modules/Thread/context/hooks/useSendFileMessage.ts +++ b/src/modules/Thread/context/hooks/useSendFileMessage.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { FileMessage, FileMessageCreateParams, SendingStatus } from '@sendbird/chat/message'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger } from '../../../../lib/Sendbird/types'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { scrollIntoLast } from '../utils'; import { SendableMessageType } from '../../../../utils'; diff --git a/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts b/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts index 5ef9ff2168..b7681d516b 100644 --- a/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useSendUserMessageCallback.ts @@ -3,7 +3,7 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; import { UserMessage, UserMessageCreateParams } from '@sendbird/chat/message'; import { User } from '@sendbird/chat'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger } from '../../../../lib/Sendbird/types'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { SendableMessageType } from '../../../../utils'; import { PublishingModuleType } from '../../../internalInterfaces'; diff --git a/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts b/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts index c4632826ff..fbe62974ec 100644 --- a/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useSendVoiceMessageCallback.ts @@ -1,7 +1,8 @@ import { useCallback } from 'react'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { FileMessage, FileMessageCreateParams, MessageMetaArray, SendingStatus } from '@sendbird/chat/message'; -import { Logger } from '../../../../lib/SendbirdState'; + +import type { Logger } from '../../../../lib/Sendbird/types'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { scrollIntoLast } from '../utils'; import { diff --git a/src/modules/Thread/context/hooks/useThreadFetchers.ts b/src/modules/Thread/context/hooks/useThreadFetchers.ts index fa9e4d642f..a3ea176e2b 100644 --- a/src/modules/Thread/context/hooks/useThreadFetchers.ts +++ b/src/modules/Thread/context/hooks/useThreadFetchers.ts @@ -3,8 +3,8 @@ import { BaseMessage, ThreadedMessageListParams } from '@sendbird/chat/message'; import { CoreMessageType, SendableMessageType } from '../../../../utils'; import { LoggerInterface } from '../../../../lib/Logger'; import { useCallback } from 'react'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; import { ThreadListStateTypes } from '../../types'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; type Params = { anchorMessage?: SendableMessageType; @@ -52,7 +52,7 @@ export const useThreadFetchers = ({ getNextMessagesSuccess, getNextMessagesFailure, }: Params) => { - const { stores } = useSendbirdStateContext(); + const { state: { stores } } = useSendbird(); const timestamp = anchorMessage?.createdAt || 0; const initialize = useCallback( diff --git a/src/modules/Thread/context/hooks/useToggleReactionsCallback.ts b/src/modules/Thread/context/hooks/useToggleReactionsCallback.ts index 237b510b2d..557767b391 100644 --- a/src/modules/Thread/context/hooks/useToggleReactionsCallback.ts +++ b/src/modules/Thread/context/hooks/useToggleReactionsCallback.ts @@ -1,8 +1,9 @@ -import { GroupChannel } from '@sendbird/chat/groupChannel'; import { useCallback } from 'react'; -import { Logger } from '../../../../lib/SendbirdState'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; import { BaseMessage } from '@sendbird/chat/message'; +import type { Logger } from '../../../../lib/Sendbird/types'; + interface DynamicProps { currentChannel: GroupChannel | null; } diff --git a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts index fb5a981e4b..3a23b66583 100644 --- a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts @@ -3,7 +3,7 @@ import { User } from '@sendbird/chat'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { UserMessage, UserMessageUpdateParams } from '@sendbird/chat/message'; -import { Logger } from '../../../../lib/SendbirdState'; +import type { Logger } from '../../../../lib/Sendbird/types'; import topics, { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; import { PublishingModuleType } from '../../../internalInterfaces'; diff --git a/src/modules/Thread/context/useThread.ts b/src/modules/Thread/context/useThread.ts index 58e3f352cb..7d68ebcaf5 100644 --- a/src/modules/Thread/context/useThread.ts +++ b/src/modules/Thread/context/useThread.ts @@ -14,7 +14,7 @@ import { } from '@sendbird/chat/message'; import { NEXT_THREADS_FETCH_SIZE, PREV_THREADS_FETCH_SIZE } from '../consts'; import useToggleReactionCallback from './hooks/useToggleReactionsCallback'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; import useSendUserMessageCallback from './hooks/useSendUserMessageCallback'; import { PublishingModuleType } from '../../../lib/pubSub/topics'; @@ -37,7 +37,7 @@ const useThread = () => { if (!store) throw new Error('useCreateChannel must be used within a CreateChannelProvider'); // SendbirdStateContext config - const { stores, config } = useSendbirdStateContext(); + const { state: { stores, config } } = useSendbird(); const { logger, pubSub } = config; const isMentionEnabled = config.groupChannel.enableMention; const isReactionEnabled = config.groupChannel.enableReactions; diff --git a/src/ui/BottomSheet/__tests__/BottomSheet.spec.js b/src/ui/BottomSheet/__tests__/BottomSheet.spec.js index 8aa6296d0a..bd8be5d76c 100644 --- a/src/ui/BottomSheet/__tests__/BottomSheet.spec.js +++ b/src/ui/BottomSheet/__tests__/BottomSheet.spec.js @@ -1,15 +1,35 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; import BottomSheet from "../index"; -import { SendbirdSdkContext } from '../../../lib/SendbirdSdkContext'; +import { SendbirdContext } from '../../../lib/Sendbird/context/SendbirdContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; + +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(), + useSendbird: jest.fn(), +})); + describe('ui/BottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + const stateContextValue = { + state: { + config: {}, + stores: {}, + }, + }; + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); + }); + it('should do a snapshot test of the default Button DOM', function () { render( - + - + ); expect(document.body.lastChild).toMatchSnapshot(); }); diff --git a/src/ui/BottomSheet/index.tsx b/src/ui/BottomSheet/index.tsx index 6467353ae7..b9f5ad35e6 100644 --- a/src/ui/BottomSheet/index.tsx +++ b/src/ui/BottomSheet/index.tsx @@ -3,7 +3,7 @@ import React, { useRef } from 'react'; import { createPortal } from 'react-dom'; import { MODAL_ROOT } from '../../hooks/useModal'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; interface BottomSheetProps { className?: string; @@ -17,7 +17,7 @@ const BottomSheet: React.FunctionComponent = (props: BottomShe children, onBackdropClick, } = props; - const logger = useSendbirdStateContext()?.config?.logger; + const { state: { config: { logger } } } = useSendbird(); // https://github.com/testing-library/react-testing-library/issues/62#issuecomment-438653348 const portalRoot = useRef(); diff --git a/src/ui/EmojiReactions/ReactionItem.tsx b/src/ui/EmojiReactions/ReactionItem.tsx index ebf52be26a..caa539bf5a 100644 --- a/src/ui/EmojiReactions/ReactionItem.tsx +++ b/src/ui/EmojiReactions/ReactionItem.tsx @@ -16,11 +16,11 @@ import { getEmojiTooltipString, isReactedBy, SendableMessageType } from '../../u import { useMediaQueryContext } from '../../lib/MediaQueryContext'; import useLongPress from '../../hooks/useLongPress'; import { LocalizationContext } from '../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { useMessageContext } from '../../modules/Message/context/MessageProvider'; import { ModalFooter } from '../Modal'; import { ButtonTypes } from '../Button'; import { useGlobalModalContext } from '../../hooks/useModal'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; type Props = { reaction: Reaction; @@ -44,12 +44,11 @@ export default function ReactionItem({ isFiltered, }: Props) { const { openModal } = useGlobalModalContext(); - const store = useSendbirdStateContext(); + const { state: { config: { userId } } } = useSendbird(); const { isMobile } = useMediaQueryContext(); const messageStore = useMessageContext(); const { stringSet } = useContext(LocalizationContext); - const userId = store.config.userId; const reactedByMe = isReactedBy(userId, reaction); const showHoverTooltip = (reaction.userIds.length > 0) && (channel?.isGroupChannel() && !channel.isSuper); diff --git a/src/ui/EmojiReactions/index.tsx b/src/ui/EmojiReactions/index.tsx index 50f0a46a2a..8c79262996 100644 --- a/src/ui/EmojiReactions/index.tsx +++ b/src/ui/EmojiReactions/index.tsx @@ -22,8 +22,8 @@ import ReactionItem from './ReactionItem'; import { useMediaQueryContext } from '../../lib/MediaQueryContext'; import { AddReactionBadgeItem } from './AddReactionBadgeItem'; import { MobileEmojisBottomSheet } from '../MobileMenu/MobileEmojisBottomSheet'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { getIsReactionEnabled } from '../../utils/getIsReactionEnabled'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export interface EmojiReactionsProps { className?: string | Array; @@ -54,7 +54,7 @@ const EmojiReactions = ({ }: EmojiReactionsProps): ReactElement => { let showTheReactedMembers = false; try { - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); showTheReactedMembers = channel ? getIsReactionEnabled({ channel, config, diff --git a/src/ui/FileMessageItemBody/index.tsx b/src/ui/FileMessageItemBody/index.tsx index 47e784cda2..c5f4760248 100644 --- a/src/ui/FileMessageItemBody/index.tsx +++ b/src/ui/FileMessageItemBody/index.tsx @@ -8,10 +8,10 @@ import TextButton from '../TextButton'; 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/types'; import { LoggerInterface } from '../../lib/Logger'; import { openURL } from '../../utils/utils'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; interface Props { className?: string | Array; @@ -34,7 +34,8 @@ export default function FileMessageItemBody({ }: Props): ReactElement { let logger: LoggerInterface | null = null; try { - logger = useSendbirdStateContext()?.config?.logger; + const { state: { config: { logger: globalLogger } } } = useSendbird(); + logger = globalLogger; } catch (err) { // TODO: Handle error } diff --git a/src/ui/FileViewer/__tests__/FileViewer.spec.js b/src/ui/FileViewer/__tests__/FileViewer.spec.js index 56694befa3..0a714f9d67 100644 --- a/src/ui/FileViewer/__tests__/FileViewer.spec.js +++ b/src/ui/FileViewer/__tests__/FileViewer.spec.js @@ -1,10 +1,17 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, renderHook } from '@testing-library/react'; import { FileViewerComponent as FileViewer } from "../index"; import { msg0, msg1 } from '../data.mock'; -import { SendbirdSdkContext } from '../../../lib/SendbirdSdkContext'; import { MODAL_ROOT } from '../../../hooks/useModal'; +import { SendbirdContext } from '../../../lib/Sendbird/context/SendbirdContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; + +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(), + useSendbird: jest.fn(), +})); describe('ui/FileViewer', () => { let modalRoot; @@ -21,6 +28,18 @@ describe('ui/FileViewer', () => { document.body.removeChild(modalRoot); }); + beforeEach(() => { + jest.clearAllMocks(); + const stateContextValue = { + state: { + config: {}, + stores: {}, + }, + }; + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); + }); + it('should display image', function () { const { sender, @@ -30,7 +49,7 @@ describe('ui/FileViewer', () => { } = msg0; const { profileUrl, nickname = '' } = sender; render( - + { onClose={() => { }} onDelete={() => { }} /> - + ); expect( screen.getByAltText(msg0.name).className @@ -62,7 +81,7 @@ describe('ui/FileViewer', () => { } = msg1; const { profileUrl, nickname = '' } = sender; const { container } = render( - + { onClose={() => { }} onDelete={() => { }} /> - + ); // Use document to search for the element inside the modal root @@ -98,7 +117,7 @@ describe('ui/FileViewer', () => { name = '', } = unsupportedMsg; const { container } = render( - + { onClose={() => { }} onDelete={() => { }} /> - + ); // Use document to search for the element inside the modal root @@ -137,7 +156,7 @@ describe('ui/FileViewer', () => { } = msg0; const { profileUrl, nickname = '' } = sender; const { asFragment } = render( - + { onDelete={() => { }} message={msg0} /> - + ); expect(asFragment()).toMatchSnapshot(); }); diff --git a/src/ui/LegacyEditUserProfile/index.tsx b/src/ui/LegacyEditUserProfile/index.tsx index 097d6cc105..930902424b 100644 --- a/src/ui/LegacyEditUserProfile/index.tsx +++ b/src/ui/LegacyEditUserProfile/index.tsx @@ -8,7 +8,7 @@ import React, { import type { User } from '@sendbird/chat'; import Modal from '../Modal'; -import withSendbirdContext from '../../lib/SendbirdSdkContext'; +import { withSendBird } from '../../lib/Sendbird/index'; import { LocalizationContext } from '../../lib/LocalizationContext'; import Input, { InputLabel } from '../Input'; @@ -179,6 +179,6 @@ interface ConnectedEditUserProfileProps { const ConnectedEditUserProfile: ( props: ConnectedEditUserProfileProps -) => React.ReactElement = withSendbirdContext(EditUserProfile, mapStoreToProps); +) => React.ReactElement = withSendBird(EditUserProfile, mapStoreToProps); export default ConnectedEditUserProfile; diff --git a/src/ui/MentionLabel/index.tsx b/src/ui/MentionLabel/index.tsx index 6b8c137453..4e948c0a2d 100644 --- a/src/ui/MentionLabel/index.tsx +++ b/src/ui/MentionLabel/index.tsx @@ -11,8 +11,8 @@ import type { User } from '@sendbird/chat'; import ContextMenu, { MenuItems } from '../ContextMenu'; import Label, { LabelTypography, LabelColors } from '../Label'; import UserProfile from '../UserProfile'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { classnames } from '../../utils/utils'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; interface MentionLabelProps { mentionTemplate: string; @@ -31,9 +31,9 @@ export default function MentionLabel(props: MentionLabelProps): JSX.Element { const mentionRef = useRef(); - const sendbirdState = useSendbirdStateContext(); - const userId = sendbirdState?.config?.userId; - const sdk = sendbirdState?.stores?.sdkStore?.sdk; + const { state } = useSendbird(); + const userId = state?.config?.userId; + const sdk = state?.stores?.sdkStore?.sdk; const amIBeingMentioned = userId === mentionedUserId; const [user, setUser] = useState(); const fetchUser = useCallback( diff --git a/src/ui/MessageContent/MessageBody/index.tsx b/src/ui/MessageContent/MessageBody/index.tsx index bbc6791442..b8f2617ce5 100644 --- a/src/ui/MessageContent/MessageBody/index.tsx +++ b/src/ui/MessageContent/MessageBody/index.tsx @@ -16,7 +16,7 @@ import ThumbnailMessageItemBody from '../../ThumbnailMessageItemBody'; import UnknownMessageItemBody from '../../UnknownMessageItemBody'; import { useThreadMessageKindKeySelector } from '../../../modules/Channel/context/hooks/useThreadMessageKindKeySelector'; import { useFileInfoListWithUploaded } from '../../../modules/Channel/context/hooks/useFileInfoListWithUploaded'; -import { SendBirdStateConfig } from '../../../lib/types'; +import type { SendbirdStateConfig } from '../../../lib/Sendbird/types'; import { Nullable, SendbirdTheme } from '../../../types'; import { GroupChannel } from '@sendbird/chat/groupChannel'; import { match } from 'ts-pattern'; @@ -42,7 +42,7 @@ export interface MessageBodyProps { mouseHover: boolean; isMobile: boolean; - config: SendBirdStateConfig; + config: SendbirdStateConfig; isReactionEnabledInChannel: boolean; isByMe: boolean; } diff --git a/src/ui/MessageContent/MessageContentForTemplateMessage.tsx b/src/ui/MessageContent/MessageContentForTemplateMessage.tsx index d6dbb654e4..cfc53441e1 100644 --- a/src/ui/MessageContent/MessageContentForTemplateMessage.tsx +++ b/src/ui/MessageContent/MessageContentForTemplateMessage.tsx @@ -4,10 +4,10 @@ import { classnames } from '../../utils/utils'; import format from 'date-fns/format'; import { MessageTemplateData, TemplateType } from '../TemplateMessageItemBody/types'; import { MessageComponentRenderers, MessageContentProps } from './index'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { uiContainerType } from '../../utils'; import { useLocalization } from '../../lib/LocalizationContext'; import { MESSAGE_TEMPLATE_KEY } from '../../utils/consts'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; type MessageContentForTemplateMessageProps = MessageContentProps & MessageComponentRenderers & { isByMe: boolean; @@ -42,7 +42,7 @@ export function MessageContentForTemplateMessage(props: MessageContentForTemplat useReplying, } = props; - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { dateLocale } = useLocalization(); const uiContainerTypeClassName = uiContainerType[templateType] ?? ''; diff --git a/src/ui/MessageContent/__tests__/MessageContent.spec.js b/src/ui/MessageContent/__tests__/MessageContent.spec.js index 8cd3cbf1e1..2330b29024 100644 --- a/src/ui/MessageContent/__tests__/MessageContent.spec.js +++ b/src/ui/MessageContent/__tests__/MessageContent.spec.js @@ -1,24 +1,33 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { render, renderHook } from '@testing-library/react'; import MessageContent from "../index"; import { useMessageContext } from '../../../modules/Message/context/MessageProvider'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../../lib/LocalizationContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; jest.mock('date-fns/format', () => () => ('mock-date')); -// to mock useSendbirdStateContext -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(), -})); -jest.mock('../../../lib/LocalizationContext', () => ({ - ...jest.requireActual('../../../lib/LocalizationContext'), - useLocalization: jest.fn(), +// to mock useSendbird +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(), + useSendbird: jest.fn(), })); +jest.mock('../../../lib/LocalizationContext', () => { + const React = require('react'); + return { + __esModule: true, + LocalizationContext: React.createContext({ + stringSet: { + DATE_FORMAT__MESSAGE_CREATED_AT: 'p', + }, + }), + useLocalization: jest.fn(), + }; +}); jest.mock('../../../modules/Message/context/MessageProvider', () => ({ - ...jest.requireActual('../../../modules/Message/context/MessageProvider'), + __esModule: true, useMessageContext: jest.fn(), })); @@ -63,10 +72,13 @@ describe('ui/MessageContent', () => { /** Mocking necessary hooks */ beforeEach(() => { const stateContextValue = { - config: { - groupChannel: { - enableOgtag: true, - } + state: { + config: { + groupChannel: { + enableOgtag: true, + } + }, + eventHandlers: {}, } }; const localeContextValue = { @@ -77,13 +89,15 @@ describe('ui/MessageContent', () => { }; const messageContextValue = { message: {}, - } + }; + + // Mocking the hooks - useContext.mockReturnValue(stateContextValue); + useSendbird.mockReturnValue(stateContextValue); useLocalization.mockReturnValue(localeContextValue); - useMessageContext.mockReturnValue(messageContextValue) + useMessageContext.mockReturnValue(messageContextValue); - renderHook(() => useSendbirdStateContext()); + renderHook(() => useSendbird()); renderHook(() => useLocalization()); renderHook(() => useMessageContext()); }) @@ -290,11 +304,13 @@ describe('ui/MessageContent', () => { it('should render OGMessageItemBody if config.groupChannel.enableOgtag == true', function () { const message = createMockMessage(); const contextValue = { - config: { - groupChannel: { enableOgtag: true }, + state: { + config: { + groupChannel: { enableOgtag: true }, + } } }; - useContext.mockReturnValue(contextValue); + useSendbird.mockReturnValue(contextValue); const { container } = render( { it('should not render OGMessageItemBody if config.groupChannel.enableOgtag == false', function () { const message = createMockMessage(); const contextValue = { - config: { - groupChannel: { enableOgtag: false }, + state: { + config: { + groupChannel: { enableOgtag: false }, + } } }; - useContext.mockReturnValue(contextValue); + useSendbird.mockReturnValue(contextValue); const { container } = render( (); diff --git a/src/ui/MessageInput/__tests__/MessageInput.spec.js b/src/ui/MessageInput/__tests__/MessageInput.spec.js index c86e46c417..3f99de3160 100644 --- a/src/ui/MessageInput/__tests__/MessageInput.spec.js +++ b/src/ui/MessageInput/__tests__/MessageInput.spec.js @@ -1,17 +1,18 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { render, renderHook, screen,fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext'; -import { useLocalization } from '../../../lib/LocalizationContext'; import MessageInput from "../index"; +import { useLocalization } from '../../../lib/LocalizationContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; const noop = () => {}; // to mock useSendbirdStateContext -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(), +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(), + useSendbird: jest.fn(), })); jest.mock('../../../lib/LocalizationContext', () => ({ ...jest.requireActual('../../../lib/LocalizationContext'), @@ -22,9 +23,11 @@ describe('ui/MessageInput', () => { /** Mocking necessary hooks */ beforeEach(() => { const stateContextValue = { - config: { - groupChannel: { - enableDocument: true, + state: { + config: { + groupChannel: { + enableDocument: true, + } } } }; @@ -32,25 +35,27 @@ describe('ui/MessageInput', () => { stringSet: {}, }; - useContext.mockReturnValue(stateContextValue); + useSendbird.mockReturnValue(stateContextValue); useLocalization.mockReturnValue(localeContextValue); - renderHook(() => useSendbirdStateContext()); + renderHook(() => useSendbird()); renderHook(() => useLocalization()); }) describe('Dashboard enableDocument config', () => { it('should not render file upload icon if groupChannel.enableDocument: false', () => { const stateContextValue = { - config: { - groupChannel: { - enableDocument: false, + state: { + config: { + groupChannel: { + enableDocument: false, + } } } }; - useContext.mockReturnValue(stateContextValue); - renderHook(() => useSendbirdStateContext()); + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); const { container } = render(); expect( @@ -60,15 +65,17 @@ describe('ui/MessageInput', () => { it('should not render file upload icon if openChannel.enableDocument: false', () => { const stateContextValue = { - config: { - openChannel: { - enableDocument: false, + state: { + config: { + openChannel: { + enableDocument: false, + } } } }; - useContext.mockReturnValue(stateContextValue); - renderHook(() => useSendbirdStateContext()); + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); const { container } = render(); expect( @@ -78,15 +85,17 @@ describe('ui/MessageInput', () => { it('should not render file upload icon if openChannel.enableDocument: true', () => { const stateContextValue = { - config: { - openChannel: { - enableDocument: true, + state: { + config: { + openChannel: { + enableDocument: true, + } } } }; - useContext.mockReturnValue(stateContextValue); - renderHook(() => useSendbirdStateContext()); + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); const { container } = render(); expect( @@ -242,27 +251,29 @@ describe('ui/MessageInput', () => { describe('MessageInput error handling', () => { beforeEach(() => { const stateContextValue = { - config: { - groupChannel: { - enableDocument: true, + state: { + config: { + groupChannel: { + enableDocument: true, + }, }, - }, - eventHandlers: { - message: { - onSendMessageFailed: jest.fn(), - onUpdateMessageFailed: jest.fn(), - onFileUploadFailed: jest.fn(), + eventHandlers: { + message: { + onSendMessageFailed: jest.fn(), + onUpdateMessageFailed: jest.fn(), + onFileUploadFailed: jest.fn(), + }, }, - }, + } }; const localeContextValue = { stringSet: {}, }; - useContext.mockReturnValue(stateContextValue); + useSendbird.mockReturnValue(stateContextValue); useLocalization.mockReturnValue(localeContextValue); - renderHook(() => useSendbirdStateContext()); + renderHook(() => useSendbird()); renderHook(() => useLocalization()); }); @@ -271,7 +282,7 @@ describe('MessageInput error handling', () => { const onSendMessage = jest.fn(() => { throw new Error(mockErrorMessage); }); - const { eventHandlers } = useSendbirdStateContext(); + const { state: { eventHandlers } } = useSendbird(); const textRef = { current: { innerText: null } }; const mockText = 'Test Value'; @@ -291,7 +302,7 @@ describe('MessageInput error handling', () => { const onSendMessage = jest.fn(() => { throw new Error(mockErrorMessage); }); - const { eventHandlers } = useSendbirdStateContext(); + const { state: { eventHandlers } } = useSendbird(); const textRef = { current: { innerText: null } }; const mockText = 'Test Value'; @@ -312,7 +323,7 @@ describe('MessageInput error handling', () => { const onUpdateMessage = jest.fn(() => { throw new Error(mockErrorMessage); }); - const { eventHandlers } = useSendbirdStateContext(); + const { state: { eventHandlers } } = useSendbird(); const messageId = 123; const textRef = { current: { innerText: null } }; const mockText = 'Updated Text'; @@ -342,7 +353,7 @@ describe('MessageInput error handling', () => { const onFileUpload = jest.fn(() => { throw new Error(mockErrorMessage); }); - const { eventHandlers } = useSendbirdStateContext(); + const { state: { eventHandlers } } = useSendbird(); const file = new File(['dummy content'], 'example.txt', { type: 'text/plain' }); render(); diff --git a/src/ui/MessageInput/index.tsx b/src/ui/MessageInput/index.tsx index fc05d5de2c..3d9441f02d 100644 --- a/src/ui/MessageInput/index.tsx +++ b/src/ui/MessageInput/index.tsx @@ -10,7 +10,6 @@ import renderMentionLabelToString from '../MentionUserLabel/renderToString'; import Icon, { IconColors, IconTypes } from '../Icon'; import Label, { LabelColors, LabelTypography } from '../Label'; import { useLocalization } from '../../lib/LocalizationContext'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { extractTextAndMentions, isChannelTypeSupportsMultipleFilesMessage, nodeListToArray, sanitizeString } from './utils'; import { arrayEqual, getMimeTypesUIKitAccepts } from '../../utils'; @@ -26,6 +25,7 @@ import { GroupChannel } from '@sendbird/chat/groupChannel'; import { User } from '@sendbird/chat'; import { OpenChannel } from '@sendbird/chat/openChannel'; import { UserMessage } from '@sendbird/chat/message'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; const TEXT_FIELD_ID = 'sendbird-message-input-text-field'; const noop = () => { @@ -126,7 +126,7 @@ const MessageInput = React.forwardRef((prop const textFieldId = messageFieldId || TEXT_FIELD_ID; const { stringSet } = useLocalization(); - const { config, eventHandlers } = useSendbirdStateContext(); + const { state: { config, eventHandlers } } = useSendbird(); const isFileUploadEnabled = checkIfFileUploadEnabled({ channel, diff --git a/src/ui/MessageInput/messageInputUtils.ts b/src/ui/MessageInput/messageInputUtils.ts index 5b2a129d78..02febf0d3d 100644 --- a/src/ui/MessageInput/messageInputUtils.ts +++ b/src/ui/MessageInput/messageInputUtils.ts @@ -7,7 +7,7 @@ import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { OpenChannel } from '@sendbird/chat/openChannel'; import { match } from 'ts-pattern'; -import type { SendBirdStateConfig } from '../../lib/types'; +import type { SendbirdStateConfig } from '../../lib/Sendbird/types'; /** * FIXME: @@ -26,12 +26,12 @@ enum ChannelType { * If customer is using MessageInput outside our modules(ie: custom UI), * we expect Channel to be undefined and customer gets control to show/hide file-upload. * @param {*} channel GroupChannel | OpenChannel - * @param {*} config SendBirdStateConfig + * @param {*} config SendbirdStateConfig * @returns boolean */ export const checkIfFileUploadEnabled = ({ channel, config }: { channel?: GroupChannel | OpenChannel, - config?: SendBirdStateConfig, + config?: SendbirdStateConfig, }) => { const isEnabled = match(channel?.channelType) .with(ChannelType.GROUP, () => config?.groupChannel?.enableDocument) diff --git a/src/ui/MessageItemMenu/index.tsx b/src/ui/MessageItemMenu/index.tsx index fdf0eb16ba..f481e3b521 100644 --- a/src/ui/MessageItemMenu/index.tsx +++ b/src/ui/MessageItemMenu/index.tsx @@ -17,7 +17,7 @@ import { SendableMessageType, } from '../../utils/index'; import { LocalizationContext } from '../../lib/LocalizationContext'; -import { Role } from '../../lib/types'; +import { Role } from '../../lib/Sendbird/types'; import { ReplyType } from '../../types'; import { deleteNullish } from '../../utils/utils'; diff --git a/src/ui/MessageMenu/MessageMenu.tsx b/src/ui/MessageMenu/MessageMenu.tsx index ede8070bcc..ead3091516 100644 --- a/src/ui/MessageMenu/MessageMenu.tsx +++ b/src/ui/MessageMenu/MessageMenu.tsx @@ -4,7 +4,6 @@ import { OpenChannel } from '@sendbird/chat/openChannel'; import { SendableMessageType } from '../../utils'; import { classnames, noop } from '../../utils/utils'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { MenuItems, getObservingId } from '../ContextMenu'; import { type PrebuildMenuItemPropsType, @@ -31,6 +30,7 @@ import { } from '../../utils/menuConditions'; import { MessageMenuProvider } from './MessageMenuProvider'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export type RenderMenuItemsParams = { items: { @@ -79,8 +79,7 @@ export const MessageMenu = ({ onReplyInThread, onMoveToParentMessage, }: MessageMenuProps) => { - const { config } = useSendbirdStateContext(); - const { isOnline } = config; + const { state: { config: { isOnline } } } = useSendbird(); const triggerRef = useRef(null); const containerRef = useRef(null); diff --git a/src/ui/MobileMenu/MobileBottomSheet.tsx b/src/ui/MobileMenu/MobileBottomSheet.tsx index 90f9f17553..c78959d2ab 100644 --- a/src/ui/MobileMenu/MobileBottomSheet.tsx +++ b/src/ui/MobileMenu/MobileBottomSheet.tsx @@ -19,7 +19,6 @@ import BottomSheet from '../BottomSheet'; import ImageRenderer from '../ImageRenderer'; import ReactionButton from '../ReactionButton'; import Icon, { IconTypes, IconColors } from '../Icon'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { classnames } from '../../utils/utils'; import { MessageMenuProvider, MobileMessageMenuContextProps } from '../MessageMenu/MessageMenuProvider'; import { @@ -31,6 +30,7 @@ import { DeleteMenuItem, DownloadMenuItem, } from '../MessageMenu/menuItems/BottomSheetMenuItems'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; const EMOJI_SIZE = 38; @@ -56,8 +56,7 @@ const MobileBottomSheet: React.FunctionComponent = (prop renderMenuItems, } = props; const isByMe = message?.sender?.userId === userId; - const { config } = useSendbirdStateContext(); - const { isOnline } = config; + const { state: { config: { isOnline } } } = useSendbird(); const showMenuItemCopy: boolean = isUserMessage(message as UserMessage); const showMenuItemEdit: boolean = (isUserMessage(message as UserMessage) && isSentMessage(message) && isByMe); const showMenuItemResend: boolean = (isOnline && isFailedMessage(message) && message?.isResendable && isByMe); diff --git a/src/ui/MobileMenu/MobileContextMenu.tsx b/src/ui/MobileMenu/MobileContextMenu.tsx index 16e3bb757e..97a116664d 100644 --- a/src/ui/MobileMenu/MobileContextMenu.tsx +++ b/src/ui/MobileMenu/MobileContextMenu.tsx @@ -24,9 +24,9 @@ import { DeleteMenuItem, DownloadMenuItem, } from '../MessageMenu/menuItems/MobileMenuItems'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { MenuItems } from '../ContextMenu'; import { noop } from '../../utils/utils'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; const MobileContextMenu: React.FunctionComponent = (props: BaseMenuProps): React.ReactElement => { const { @@ -48,8 +48,7 @@ const MobileContextMenu: React.FunctionComponent = (props: BaseMe hideMenu: hideMobileMenu, } = props; const isByMe = message?.sender?.userId === userId; - const { config } = useSendbirdStateContext(); - const { isOnline } = config; + const { state: { config: { isOnline } } } = useSendbird(); // Menu Items condition const showMenuItemCopy = isUserMessage(message as UserMessage); diff --git a/src/ui/Modal/index.tsx b/src/ui/Modal/index.tsx index 03aeb8ba68..27a581671e 100644 --- a/src/ui/Modal/index.tsx +++ b/src/ui/Modal/index.tsx @@ -13,7 +13,7 @@ import Button, { ButtonTypes } from '../Button'; import Icon, { IconTypes, IconColors } from '../Icon'; import Label, { LabelTypography, LabelColors } from '../Label'; import uuidv4 from '../../utils/uuid'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export interface ModalHeaderProps { titleText: string; @@ -113,7 +113,7 @@ export function Modal(props: ModalProps): ReactElement { customFooter, } = props; const handleClose = onClose ?? onCancel ?? noop; - const { eventHandlers } = useSendbirdStateContext(); + const { state: { eventHandlers } } = useSendbird(); const [id] = useState(() => `sbu-modal-${uuidv4()}`); diff --git a/src/ui/MultipleFilesMessageItemBody/index.tsx b/src/ui/MultipleFilesMessageItemBody/index.tsx index 2ce03436ee..cfb5aa0080 100644 --- a/src/ui/MultipleFilesMessageItemBody/index.tsx +++ b/src/ui/MultipleFilesMessageItemBody/index.tsx @@ -2,7 +2,6 @@ import React, { ReactElement, useState } from 'react'; import { MultipleFilesMessage, SendingStatus } from '@sendbird/chat/message'; import type { OnBeforeDownloadFileMessageType } from '../../modules/GroupChannel/context/types'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import Icon, { IconColors, IconTypes } from '../Icon'; import ImageRenderer, { getBorderRadiusForMultipleImageRenderer } from '../ImageRenderer'; import ImageGrid from '../ImageGrid'; @@ -11,6 +10,7 @@ import './index.scss'; import { MULTIPLE_FILES_IMAGE_BORDER_RADIUS, MULTIPLE_FILES_IMAGE_SIDE_LENGTH, MULTIPLE_FILES_IMAGE_THUMBNAIL_SIDE_LENGTH } from './const'; import { isGif } from '../../utils'; import { UploadedFileInfoWithUpload } from '../../types'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export const ThreadMessageKind = { PARENT: 'parent', @@ -39,7 +39,7 @@ export default function MultipleFilesMessageItemBody({ statefulFileInfoList = [], onBeforeDownloadFileMessage = null, }: Props): ReactElement { - const logger = useSendbirdStateContext?.()?.config?.logger; + const { state: { config: { logger } } } = useSendbird(); const [currentFileViewerIndex, setCurrentFileViewerIndex] = useState(-1); function onClose() { diff --git a/src/ui/OpenChannelMobileMenu/index.tsx b/src/ui/OpenChannelMobileMenu/index.tsx index 23c70d617f..67d382d0bd 100644 --- a/src/ui/OpenChannelMobileMenu/index.tsx +++ b/src/ui/OpenChannelMobileMenu/index.tsx @@ -10,9 +10,9 @@ import { isFineEdit, isFineDownload, } from '../../utils/openChannelUtils'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { useLocalization } from '../../lib/LocalizationContext'; import { SendableMessageType } from '../../utils'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; type Props = { message: SendableMessageType; @@ -39,7 +39,7 @@ const OpenChannelMobileMenu: React.FC = (props: Props) => { const userMessage = message as UserMessage; const status = message?.sendingStatus; const { stringSet } = useLocalization(); - const userId = useSendbirdStateContext()?.config?.userId; + const { state: { config: { userId } } } = useSendbird(); const fileMessage = message as FileMessage; return ( { diff --git a/src/ui/Toggle/utils.ts b/src/ui/Toggle/utils.ts index b4832fc91f..827bdd26c9 100644 --- a/src/ui/Toggle/utils.ts +++ b/src/ui/Toggle/utils.ts @@ -1,10 +1,9 @@ -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export function filterNumber(input: string | number): Array { if (typeof input !== 'string' && typeof input !== 'number') { try { - const { config } = useSendbirdStateContext(); - const { logger } = config; + const { state: { config: { logger } } } = useSendbird(); logger.warning('@sendbird/uikit-react/ui/Toggle: TypeError - expected string or number.', input); } catch (_) { /* noop */ } return []; diff --git a/src/ui/UserListItem/__tests__/UserListItem.spec.js b/src/ui/UserListItem/__tests__/UserListItem.spec.js index df926da277..59eb4538e7 100644 --- a/src/ui/UserListItem/__tests__/UserListItem.spec.js +++ b/src/ui/UserListItem/__tests__/UserListItem.spec.js @@ -1,8 +1,15 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, renderHook } from '@testing-library/react'; import UserListItem from "../index"; -import { SendbirdSdkContext } from '../../../lib/SendbirdSdkContext'; +import { SendbirdContext } from '../../../lib/Sendbird/context/SendbirdContext'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; + +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(), + useSendbird: jest.fn(), +})); const getUserList = () => [ { @@ -23,6 +30,18 @@ const getUserList = () => [ ]; describe('ui/UserListItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + const stateContextValue = { + state: { + config: {}, + stores: {}, + }, + }; + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); + }); + it.skip('should render text prop', function () { const [user1] = getUserList(); render(); @@ -35,14 +54,14 @@ describe('ui/UserListItem', () => { it('should do a snapshot test of the UserListItem DOM', function () { const [user1] = getUserList(); const { asFragment } = render( - + { }} /> - + ); expect(asFragment()).toMatchSnapshot(); }); diff --git a/src/ui/UserListItem/index.tsx b/src/ui/UserListItem/index.tsx index 7eb2157f6b..f08294ea8b 100644 --- a/src/ui/UserListItem/index.tsx +++ b/src/ui/UserListItem/index.tsx @@ -3,7 +3,6 @@ import type { User } from '@sendbird/chat'; import type { GroupChannel, Member } from '@sendbird/chat/groupChannel'; import './index.scss'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; import { useUserProfileContext } from '../../lib/UserProfileContext'; import { useLocalization } from '../../lib/LocalizationContext'; @@ -16,6 +15,7 @@ import Label, { LabelTypography, LabelColors } from '../Label'; import { UserListItemMenuProps } from '../UserListItemMenu/UserListItemMenu'; import { classnames } from '../../utils/utils'; import pxToNumber from '../../utils/pxToNumber'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export interface UserListItemProps { user: User | Member; @@ -63,7 +63,7 @@ export function UserListItem({ const avatarRef = useRef(null); const { disableUserProfile, renderUserProfile } = useUserProfileContext(); const { stringSet } = useLocalization(); - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const currentUser = config.userId; const itemClassName = size === 'small' ? 'sendbird-user-list-item--small' : 'sendbird-user-list-item'; diff --git a/src/ui/UserListItemMenu/UserListItemMenu.tsx b/src/ui/UserListItemMenu/UserListItemMenu.tsx index 90312543a1..97762efdcd 100644 --- a/src/ui/UserListItemMenu/UserListItemMenu.tsx +++ b/src/ui/UserListItemMenu/UserListItemMenu.tsx @@ -6,7 +6,7 @@ import { OperatorToggleMenuItem, MuteToggleMenuItem, BanToggleMenuItem } from '. import { classnames } from '../../utils/utils'; import { MenuItems } from '../ContextMenu'; import { DefaultMenuItems, TriggerIcon } from './DefaultElements'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; export type RenderUserListItemMenuItemsParams = { items: { @@ -32,7 +32,7 @@ export const UserListItemMenu = (props: UserListItemMenuProps) => { const containerRef = useRef(null); const triggerRef = useRef(null); - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { userId: currentUserId } = config; const toggleMenu = () => { diff --git a/src/ui/UserListItemMenu/context.tsx b/src/ui/UserListItemMenu/context.tsx index bac2fa698c..3aac6239d9 100644 --- a/src/ui/UserListItemMenu/context.tsx +++ b/src/ui/UserListItemMenu/context.tsx @@ -4,7 +4,7 @@ import type { GroupChannel } from '@sendbird/chat/groupChannel'; import { OpenChannel } from '@sendbird/chat/openChannel'; import { useToggleBan, useToggleMute, useToggleOperator } from './hooks'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; interface UserListItemMenuContextInterface extends UserListItemMenuContextValues, @@ -38,7 +38,7 @@ export interface UserListItemMenuProviderProps extends UserListItemMenuContextVa isBanned?: boolean; } export const UserListItemMenuProvider = ({ children, ...values }: UserListItemMenuProviderProps) => { - const { config } = useSendbirdStateContext(); + const { state: { config } } = useSendbird(); const { userId: currentUserId } = config; const { channel, user } = values; const isCurrentUser = user.userId === currentUserId; diff --git a/src/ui/UserProfile/index.tsx b/src/ui/UserProfile/index.tsx index 627d33e282..50106d15e5 100644 --- a/src/ui/UserProfile/index.tsx +++ b/src/ui/UserProfile/index.tsx @@ -9,7 +9,7 @@ import { getCreateGroupChannel } from '../../lib/selectors'; import Avatar from '../Avatar/index'; import Label, { LabelColors, LabelTypography } from '../Label'; import Button, { ButtonTypes } from '../Button'; -import useSendbirdStateContext from '../../hooks/useSendbirdStateContext'; +import useSendbird from '../../lib/Sendbird/context/hooks/useSendbird'; interface Logger { info?(message: string, channel: GroupChannel): void; @@ -30,11 +30,11 @@ function UserProfile({ disableMessaging = false, onSuccess, }: Props): ReactElement { - const store = useSendbirdStateContext(); - const createChannel = getCreateGroupChannel(store); - const logger = store?.config?.logger; + const { state } = useSendbird(); + const createChannel = getCreateGroupChannel(state); + const logger = state?.config?.logger; const { stringSet } = useContext(LocalizationContext); - const currentUserId_ = currentUserId || store?.config?.userId; + const currentUserId_ = currentUserId || state?.config?.userId; const { onStartDirectMessage } = useUserProfileContext(); return (
diff --git a/src/ui/Word/__tests__/Word.spec.js b/src/ui/Word/__tests__/Word.spec.js index b11270f430..a1a29b13c9 100644 --- a/src/ui/Word/__tests__/Word.spec.js +++ b/src/ui/Word/__tests__/Word.spec.js @@ -1,20 +1,44 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { render, renderHook } from "@testing-library/react"; import Word from "../index"; -import { SendbirdSdkContext } from "../../../lib/SendbirdSdkContext"; +import useSendbird from "../../../lib/Sendbird/context/hooks/useSendbird"; +import { SendbirdContext } from "../../../lib/Sendbird/context/SendbirdContext"; + +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(), + useSendbird: jest.fn(), +})); describe("ui/Word", () => { + beforeEach(() => { + const stateContextValue = { + state: { + config: { + userId: 'hoon', + }, + stores: { + sdkStore: { + sdk: {} + } + } + } + } + useSendbird.mockReturnValue(stateContextValue); + renderHook(() => useSendbird()); + }) + it("should do a snapshot test of the Word DOM", function () { const { asFragment } = render( - + - + ); expect(asFragment()).toMatchSnapshot(); }); diff --git a/src/ui/Word/__tests__/__snapshots__/Word.spec.js.snap b/src/ui/Word/__tests__/__snapshots__/Word.spec.js.snap index 0a7afdc97f..8e5e051476 100644 --- a/src/ui/Word/__tests__/__snapshots__/Word.spec.js.snap +++ b/src/ui/Word/__tests__/__snapshots__/Word.spec.js.snap @@ -11,7 +11,7 @@ exports[`ui/Word should do a snapshot test of the Word DOM 1`] = ` class="sendbird-context-menu" > ({ @@ -16,7 +16,7 @@ const normalConfigs = (props?: any, groupChannelProps?: any) => ({ ...groupChannelProps, }, ...props, -} as SendBirdStateConfig); +} as SendbirdStateConfig); describe('Global-utils/getIsReactionEnabled', () => { it('should prioritize the moduleLevel than global config', () => { diff --git a/src/utils/__tests__/isReplyTypeMessageEnabled.spec.ts b/src/utils/__tests__/isReplyTypeMessageEnabled.spec.ts index fe6badaf00..0fa3558343 100644 --- a/src/utils/__tests__/isReplyTypeMessageEnabled.spec.ts +++ b/src/utils/__tests__/isReplyTypeMessageEnabled.spec.ts @@ -1,4 +1,4 @@ -import { Role } from '../../lib/types'; +import { Role } from '../../lib/Sendbird/types'; import { isFailedMessage, isPendingMessage } from '..'; import { isReplyTypeMessageEnabled } from '../menuConditions'; diff --git a/src/utils/compressImages.ts b/src/utils/compressImages.ts index 06ea9efb15..c4a755e044 100644 --- a/src/utils/compressImages.ts +++ b/src/utils/compressImages.ts @@ -1,6 +1,5 @@ -import type { ImageCompressionOptions, ImageCompressionOutputFormatType } from '../lib/Sendbird'; +import type { ImageCompressionOptions, ImageCompressionOutputFormatType, Logger } from '../lib/Sendbird/types'; import pxToNumber from './pxToNumber'; -import { Logger } from '../lib/SendbirdState'; interface CompressImageParams { imageFile: File; diff --git a/src/utils/getIsReactionEnabled.ts b/src/utils/getIsReactionEnabled.ts index 4272ecbb7b..7ba534cf35 100644 --- a/src/utils/getIsReactionEnabled.ts +++ b/src/utils/getIsReactionEnabled.ts @@ -4,11 +4,11 @@ */ import type { GroupChannel } from '@sendbird/chat/groupChannel'; -import type { SendBirdStateConfig } from '../lib/types'; +import type { SendbirdStateConfig } from '../lib/Sendbird/types'; export interface IsReactionEnabledProps { channel: GroupChannel | null; - config: SendBirdStateConfig; + config: SendbirdStateConfig; moduleLevel?: boolean; } From 3a5c8332d873c0b8d94047ffdf2ee1b6a4627c8c Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Fri, 29 Nov 2024 15:51:26 +0900 Subject: [PATCH 11/29] [CLNP-5907] fix: update group channel list state properly (#1261) Fixes https://sendbird.atlassian.net/browse/CLNP-5907 https://sendbird.slack.com/archives/C05AN1T4Z5Z/p1732853520502099 --- .../components/GroupChannelListItem/index.tsx | 4 +-- .../context/GroupChannelListProvider.tsx | 3 ++- .../GroupChannelListProvider.spec.tsx | 26 ++++++++++--------- .../context/useGroupChannelList.ts | 8 +++--- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx b/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx index f312891635..bcf6c17479 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListItem/index.tsx @@ -23,8 +23,8 @@ export const GroupChannelListItem = ({ const { stringSet } = useLocalization(); const { state: { - isTypingIndicatorEnabled = false, - isMessageReceiptStatusEnabled = false, + isTypingIndicatorEnabled, + isMessageReceiptStatusEnabled, }, } = useGroupChannelList(); diff --git a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx index 2f5e7d1b33..d07fe617fe 100644 --- a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx +++ b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx @@ -108,7 +108,8 @@ export const GroupChannelListManager: React.FC = }: GroupChannelListProviderProps) => { const { state: sendbirdState } = useSendbird(); const { config, stores } = sendbirdState; - const { state, updateState } = useGroupChannelListStore(); + const { state } = useGroupChannelList(); + const { updateState } = useGroupChannelListStore(); const { sdkStore } = stores; const sdk = sdkStore.sdk; diff --git a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx index 5f4b9d6436..f8464df52d 100644 --- a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { - GroupChannelListProvider, - useGroupChannelListContext, -} from '../GroupChannelListProvider'; +import { GroupChannelListProvider, useGroupChannelListStore } from '../GroupChannelListProvider'; +import { useGroupChannelList } from '../useGroupChannelList'; import { act, renderHook, waitFor } from '@testing-library/react'; const mockState = { @@ -16,7 +14,11 @@ const mockState = { initialized: true, }, }, - config: { logger: console }, + config: { + logger: console, + groupChannelList: { + }, + }, }; jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, @@ -64,9 +66,9 @@ describe('GroupChannelListProvider', () => { ); - const { result } = renderHook(() => useGroupChannelListContext(), { wrapper }); + const { result } = renderHook(() => useGroupChannelList(), { wrapper }); - expect(result.current.getState()).toMatchObject(initialState); + expect(result.current.state).toMatchObject(initialState); }); it('update state correctly', async () => { @@ -79,14 +81,14 @@ describe('GroupChannelListProvider', () => { channelListQueryParams.prev = 42; - const { result } = renderHook(() => useGroupChannelListContext(), { wrapper }); - expect(result.current.getState().className).toEqual('old-classname'); + const { result } = renderHook(() => useGroupChannelListStore(), { wrapper }); + expect(result.current.state.className).toEqual('old-classname'); await act(async () => { - result.current.setState({ className: 'new-classname' }); - result.current.setState({ disableAutoSelect: true }); + result.current.updateState({ className: 'new-classname' }); + result.current.updateState({ disableAutoSelect: true }); await waitFor(() => { - const newState = result.current.getState(); + const newState = result.current.state; expect(newState.className).toEqual('new-classname'); expect(newState.disableAutoSelect).toEqual(true); }); diff --git a/src/modules/GroupChannelList/context/useGroupChannelList.ts b/src/modules/GroupChannelList/context/useGroupChannelList.ts index 6219913a56..bbf636aee6 100644 --- a/src/modules/GroupChannelList/context/useGroupChannelList.ts +++ b/src/modules/GroupChannelList/context/useGroupChannelList.ts @@ -1,9 +1,9 @@ -import { GroupChannelListState, useGroupChannelListContext } from './GroupChannelListProvider'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { useMemo } from 'react'; +import { useMemo, useContext } from 'react'; +import { GroupChannelListState, GroupChannelListContext } from './GroupChannelListProvider'; export const useGroupChannelList = () => { - const store = useGroupChannelListContext(); + const store = useContext(GroupChannelListContext); if (!store) throw new Error('useGroupChannelList must be used within a GroupChannelListProvider'); const state: GroupChannelListState = useSyncExternalStore(store.subscribe, store.getState); @@ -12,3 +12,5 @@ export const useGroupChannelList = () => { return { state, actions }; }; + +export default useGroupChannelList; From bfd8141c6bf088fff8d5c04920df04b9f3c51b20 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Mon, 2 Dec 2024 10:07:00 +0900 Subject: [PATCH 12/29] [CLNP-5817][CLNP-5918] fix: scroll & search message issues in GroupChannelProvider (#1263) Fixes - https://sendbird.atlassian.net/browse/CLNP-5917 - https://sendbird.atlassian.net/browse/CLNP-5918 ### Changes To fix [CLNP-5917](https://sendbird.atlassian.net/browse/CLNP-5917) introduced optimizations to prevent the "Maximum update depth exceeded" error that occurs during message searches: 1. Added useDeepCompareEffect hook: - Performs deep comparison of dependencies instead of reference equality - Particularly useful for handling message array updates efficiently - Inspired by [kentcdodds/use-deep-compare-effect](https://github.com/kentcdodds/use-deep-compare-effect) 2. Enhanced useStore with state change detection: - Added hasStateChanged helper to compare previous and next states - Prevents unnecessary updates when state values haven't actually changed - Optimizes performance by reducing redundant renders 3. Improved storeManager with nested update prevention: - Added protection against nested state updates - Prevents infinite update cycles Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If unsure, ask the members. This is a reminder of what we look for before merging your code. - [x] **All tests pass locally with my changes** - [ ] **I have added tests that prove my fix is effective or that my feature works** - [ ] **Public components / utils / props are appropriately exported** - [ ] I have added necessary documentation (if appropriate) [CLNP-5917]: https://sendbird.atlassian.net/browse/CLNP-5917?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- src/hooks/useDeepCompareEffect.ts | 31 +++++++++++++++++++ src/hooks/useStore.ts | 21 ++++++++----- .../context/hooks/useMessageListScroll.tsx | 6 +++- src/utils/storeManager.ts | 25 +++++++++++++-- 4 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useDeepCompareEffect.ts diff --git a/src/hooks/useDeepCompareEffect.ts b/src/hooks/useDeepCompareEffect.ts new file mode 100644 index 0000000000..db937bba7d --- /dev/null +++ b/src/hooks/useDeepCompareEffect.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react'; +import isEqual from 'lodash/isEqual'; + +function useDeepCompareMemoize(value: T): T { + const ref = useRef(value); + + if (!isEqual(value, ref.current)) { + ref.current = value; + } + + return ref.current; +} + +/** + * Custom hook that works like useEffect but performs a deep comparison of dependencies + * instead of reference equality. This is useful when dealing with complex objects or arrays + * in dependencies that could trigger unnecessary re-renders. + * + * Inspired by https://github.com/kentcdodds/use-deep-compare-effect + * + * @param callback Effect callback that can either return nothing (void) or return a cleanup function (() => void). + * @param dependencies Array of dependencies to be deeply compared + */ +function useDeepCompareEffect( + callback: () => void | (() => void), + dependencies: any[], +) { + useEffect(callback, dependencies.map(useDeepCompareMemoize)); +} + +export default useDeepCompareEffect; diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index ccd906318b..bd63240683 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -1,6 +1,6 @@ -import { useContext, useRef, useCallback } from 'react'; +import { useContext, useRef, useCallback, useMemo } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { type Store } from '../utils/storeManager'; +import { type Store, hasStateChanged } from '../utils/storeManager'; type StoreSelector = (state: T) => U; @@ -19,6 +19,7 @@ export function useStore( if (!store) { throw new Error('useStore must be used within a StoreProvider'); } + // Ensure the stability of the selector function using useRef const selectorRef = useRef(selector); selectorRef.current = selector; @@ -36,14 +37,18 @@ export function useStore( ); const updateState = useCallback((updates: Partial) => { - store.setState((prevState) => ({ - ...prevState, - ...updates, - })); + const currentState = store.getState(); + + if (hasStateChanged(currentState, updates)) { + store.setState((prevState) => ({ + ...prevState, + ...updates, + })); + } }, [store]); - return { + return useMemo(() => ({ state, updateState, - }; + }), [state, updateState]); } diff --git a/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx b/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx index bce7be5528..503a74653a 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx +++ b/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx @@ -1,5 +1,6 @@ import { DependencyList, useLayoutEffect, useRef, useState } from 'react'; import pubSubFactory from '../../../../lib/pubSub'; +import { useGroupChannel } from './useGroupChannel'; /** * You can pass the resolve function to scrollPubSub, if you want to catch when the scroll is finished. @@ -30,7 +31,10 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen const scrollDistanceFromBottomRef = useRef(0); const [scrollPubSub] = useState(() => pubSubFactory({ publishSynchronous: true })); - const [isScrollBottomReached, setIsScrollBottomReached] = useState(true); + const { + state: { isScrollBottomReached }, + actions: { setIsScrollBottomReached }, + } = useGroupChannel(); // SideEffect: Reset scroll state useLayoutEffect(() => { diff --git a/src/utils/storeManager.ts b/src/utils/storeManager.ts index 166a3c853b..1274cee053 100644 --- a/src/utils/storeManager.ts +++ b/src/utils/storeManager.ts @@ -5,17 +5,36 @@ export type Store = { subscribe: (listener: () => void) => () => void; }; +export function hasStateChanged(prevState: T, updates: Partial): boolean { + return Object.entries(updates).some(([key, value]) => { + return prevState[key as keyof T] !== value; + }); +} + /** * A custom store creation utility */ export function createStore(initialState: T): Store { let state = { ...initialState }; const listeners = new Set<() => void>(); + let isUpdating = false; const setState = (partial: Partial | ((state: T) => Partial)) => { - const nextState = typeof partial === 'function' ? partial(state) : partial; - state = { ...state, ...nextState }; - listeners.forEach((listener) => listener()); + // Prevent nested updates + if (isUpdating) return; + + try { + isUpdating = true; + const nextState = typeof partial === 'function' ? partial(state) : partial; + const hasChanged = hasStateChanged(state, nextState); + + if (hasChanged) { + state = { ...state, ...nextState }; + listeners.forEach((listener) => listener()); + } + } finally { + isUpdating = false; + } }; return { From a689dd362d9eb1aefa8f6d1c2ec3b81132b4e06e Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Mon, 2 Dec 2024 11:58:51 +0900 Subject: [PATCH 13/29] [CLNP-5914] Fix unit tests and UI error (#1267) * Changelog * Fixed the broken unit tests of `GroupChannelListProvider` * Fixed the UI error when click `open in channel` in the other channel * Use `useDeepCompareEffect` in `ThreadProvider` and `GroupChannelListProvider` --- .../GroupChannelListProvider.spec.tsx | 17 +++++----- .../__tests__/useGroupChannelList.spec.tsx | 31 +++++++------------ src/modules/Thread/context/ThreadProvider.tsx | 3 +- 3 files changed, 22 insertions(+), 29 deletions(-) diff --git a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx index f8464df52d..f4d2ebd17b 100644 --- a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx @@ -42,9 +42,9 @@ describe('GroupChannelListProvider', () => { className: '', selectedChannelUrl: '', disableAutoSelect: false, - allowProfileEdit: undefined, - isTypingIndicatorEnabled: undefined, - isMessageReceiptStatusEnabled: undefined, + allowProfileEdit: true, + isTypingIndicatorEnabled: false, + isMessageReceiptStatusEnabled: false, onChannelSelect: expect.any(Function), onChannelCreated: expect.any(Function), onThemeChange: undefined, @@ -87,11 +87,12 @@ describe('GroupChannelListProvider', () => { await act(async () => { result.current.updateState({ className: 'new-classname' }); result.current.updateState({ disableAutoSelect: true }); - await waitFor(() => { - const newState = result.current.state; - expect(newState.className).toEqual('new-classname'); - expect(newState.disableAutoSelect).toEqual(true); - }); + }); + + await waitFor(() => { + const newState = result.current.state; + expect(newState.className).toEqual('new-classname'); + expect(newState.disableAutoSelect).toEqual(true); }); }); diff --git a/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx b/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx index fa9416435c..7710c84300 100644 --- a/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/useGroupChannelList.spec.tsx @@ -1,4 +1,4 @@ -import { GroupChannelListProvider, useGroupChannelListContext } from '../GroupChannelListProvider'; +import { GroupChannelListProvider } from '../GroupChannelListProvider'; import { renderHook } from '@testing-library/react'; import React from 'react'; import { useGroupChannelList } from '../useGroupChannelList'; @@ -14,7 +14,12 @@ const mockState = { initialized: true, }, }, - config: { logger: console }, + config: { + logger: console, + groupChannelList: { + enableTypingIndicator: true, + }, + }, }; jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, @@ -33,17 +38,12 @@ jest.mock('@sendbird/uikit-tools', () => ({ useGroupChannelHandler: jest.fn(() => {}), })); -jest.mock('../GroupChannelListProvider', () => ({ - ...jest.requireActual('../GroupChannelListProvider'), - useGroupChannelListContext: jest.fn(), -})); - const initialState = { className: '', selectedChannelUrl: '', disableAutoSelect: false, - allowProfileEdit: false, - isTypingIndicatorEnabled: false, + allowProfileEdit: true, + isTypingIndicatorEnabled: true, isMessageReceiptStatusEnabled: false, onChannelSelect: undefined, onChannelCreated: undefined, @@ -59,14 +59,8 @@ const initialState = { loadMore: null, }; -const mockStore = { - getState: jest.fn(() => initialState), - setState: jest.fn(), - subscribe: jest.fn(() => jest.fn()), -}; - const wrapper = ({ children }) => ( - + {children} ); @@ -78,15 +72,12 @@ describe('GroupChannelListProvider', () => { }); it('throws an error if used outside of GroupChannelListProvider', () => { - (useGroupChannelListContext as jest.Mock).mockReturnValue(null); - expect(() => { - renderHook(() => useGroupChannelList(), { wrapper }); + renderHook(() => useGroupChannelList()); }).toThrow(new Error('useGroupChannelList must be used within a GroupChannelListProvider')); }); it('provide the correct initial state', () => { - (useGroupChannelListContext as jest.Mock).mockReturnValue(mockStore); const { result } = renderHook(() => useGroupChannelList(), { wrapper }); expect(result.current.state).toEqual(expect.objectContaining(initialState)); diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx index 16f543fd0c..bef0ad80bc 100644 --- a/src/modules/Thread/context/ThreadProvider.tsx +++ b/src/modules/Thread/context/ThreadProvider.tsx @@ -23,6 +23,7 @@ import { useStore } from '../../../hooks/useStore'; import useSetCurrentUserId from './hooks/useSetCurrentUserId'; import useThread from './useThread'; import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; +import useDeepCompareEffect from '../../../hooks/useDeepCompareEffect'; export interface ThreadProviderProps extends Pick { @@ -184,7 +185,7 @@ export const ThreadManager: React.FC { + useDeepCompareEffect(() => { updateState({ channelUrl, message, From 99a765f9de2f10de6b18fc44e4a55684250b7b2b Mon Sep 17 00:00:00 2001 From: Baek EunSeo Date: Mon, 2 Dec 2024 12:09:32 +0900 Subject: [PATCH 14/29] Used useDeepCompareEffect for the updateState of SendbirdProvider (#1266) Used `useDeepCompareEffect` for the `updateState` of `SendbirdProvider` Co-authored-by: Irene Ryu --- src/lib/Sendbird/context/SendbirdProvider.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/Sendbird/context/SendbirdProvider.tsx b/src/lib/Sendbird/context/SendbirdProvider.tsx index b992665e6a..71c03d9573 100644 --- a/src/lib/Sendbird/context/SendbirdProvider.tsx +++ b/src/lib/Sendbird/context/SendbirdProvider.tsx @@ -36,6 +36,7 @@ import useSendbird from './hooks/useSendbird'; import { SendbirdContext, useSendbirdStore } from './SendbirdContext'; import { createStore } from '../../../utils/storeManager'; import { initialState } from './initialState'; +import useDeepCompareEffect from '../../../hooks/useDeepCompareEffect'; /** * SendbirdContext - Manager @@ -345,7 +346,7 @@ const SendbirdContextManager = ({ getCachedTemplate, ]); - useEffect(() => { + useDeepCompareEffect(() => { updateState({ ...storeState, ...utilsState, From 9d6558f2b00f967d314f12265afed540110e9952 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Tue, 3 Dec 2024 15:14:55 +0900 Subject: [PATCH 15/29] Resolve conflict after rebase from main --- .../GroupChannelProvider.spec.tsx | 0 .../useGroupChannel.spec.tsx | 0 .../__tests__/useMessageActions.spec.ts | 154 -------------- .../useMessageActions.spec.tsx | 188 ++++++++++++++++++ .../context/hooks/useMessageActions.ts | 4 +- 5 files changed, 190 insertions(+), 156 deletions(-) rename src/modules/GroupChannel/context/{__test__ => __tests__}/GroupChannelProvider.spec.tsx (100%) rename src/modules/GroupChannel/context/{__test__ => __tests__}/useGroupChannel.spec.tsx (100%) delete mode 100644 src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts rename src/modules/GroupChannel/context/{__test__ => __tests__}/useMessageActions.spec.tsx (50%) diff --git a/src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx similarity index 100% rename from src/modules/GroupChannel/context/__test__/GroupChannelProvider.spec.tsx rename to src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx diff --git a/src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx b/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx similarity index 100% rename from src/modules/GroupChannel/context/__test__/useGroupChannel.spec.tsx rename to src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx diff --git a/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts deleted file mode 100644 index b20163832c..0000000000 --- a/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { useMessageActions } from '../hooks/useMessageActions'; -import { UserMessageCreateParams, FileMessageCreateParams } from '@sendbird/chat/message'; - -const mockEventHandlers = { - message: { - onSendMessageFailed: jest.fn(), - onUpdateMessageFailed: jest.fn(), - onFileUploadFailed: jest.fn(), - }, -}; - -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: () => ({ - eventHandlers: mockEventHandlers, - }), -})); - -describe('useMessageActions', () => { - const mockParams = { - sendUserMessage: jest.fn(), - sendFileMessage: jest.fn(), - sendMultipleFilesMessage: jest.fn(), - updateUserMessage: jest.fn(), - scrollToBottom: jest.fn(), - replyType: 'NONE', - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('processParams', () => { - it('should handle successful user message', async () => { - const { result } = renderHook(() => useMessageActions(mockParams)); - const params: UserMessageCreateParams = { message: 'test' }; - - await result.current.sendUserMessage(params); - - expect(mockParams.sendUserMessage).toHaveBeenCalledWith( - expect.objectContaining({ message: 'test' }), - expect.any(Function), - ); - }); - - it('should handle void return from onBeforeSendFileMessage', async () => { - const onBeforeSendFileMessage = jest.fn(); - const { result } = renderHook(() => useMessageActions({ - ...mockParams, - onBeforeSendFileMessage, - }), - ); - - const fileParams: FileMessageCreateParams = { - file: new File([], 'test.txt'), - }; - - await result.current.sendFileMessage(fileParams); - - expect(onBeforeSendFileMessage).toHaveBeenCalled(); - expect(mockParams.sendFileMessage).toHaveBeenCalledWith( - expect.objectContaining(fileParams), - expect.any(Function), - ); - }); - - it('should handle file upload error', async () => { - // Arrange - const error = new Error('Upload failed'); - const onBeforeSendFileMessage = jest.fn().mockRejectedValue(error); - const fileParams: FileMessageCreateParams = { - file: new File([], 'test.txt'), - fileName: 'test.txt', - }; - - const { result } = renderHook(() => useMessageActions({ - ...mockParams, - onBeforeSendFileMessage, - }), - ); - - await expect(async () => { - await result.current.sendFileMessage(fileParams); - }).rejects.toThrow('Upload failed'); - - // Wait for next tick to ensure all promises are resolved - await new Promise(process.nextTick); - - expect(onBeforeSendFileMessage).toHaveBeenCalled(); - expect(mockEventHandlers.message.onFileUploadFailed).toHaveBeenCalledWith(error); - expect(mockEventHandlers.message.onSendMessageFailed).toHaveBeenCalledWith( - expect.objectContaining({ - file: fileParams.file, - fileName: fileParams.fileName, - }), - error, - ); - }); - - it('should handle message update error', async () => { - // Arrange - const error = new Error('Update failed'); - const onBeforeUpdateUserMessage = jest.fn().mockRejectedValue(error); - const messageParams = { - messageId: 1, - message: 'update message', - }; - - const { result } = renderHook(() => useMessageActions({ - ...mockParams, - onBeforeUpdateUserMessage, - }), - ); - - await expect(async () => { - await result.current.updateUserMessage(messageParams.messageId, { - message: messageParams.message, - }); - }).rejects.toThrow('Update failed'); - - // Wait for next tick to ensure all promises are resolved - await new Promise(process.nextTick); - - expect(onBeforeUpdateUserMessage).toHaveBeenCalled(); - expect(mockEventHandlers.message.onUpdateMessageFailed).toHaveBeenCalledWith( - expect.objectContaining({ - message: messageParams.message, - }), - error, - ); - }); - - it('should preserve modified params from onBefore handlers', async () => { - const onBeforeSendUserMessage = jest.fn().mockImplementation((params) => ({ - ...params, - message: 'modified', - })); - - const { result } = renderHook(() => useMessageActions({ - ...mockParams, - onBeforeSendUserMessage, - }), - ); - - await result.current.sendUserMessage({ message: 'original' }); - - expect(mockParams.sendUserMessage).toHaveBeenCalledWith( - expect.objectContaining({ message: 'modified' }), - expect.any(Function), - ); - }); - }); -}); diff --git a/src/modules/GroupChannel/context/__test__/useMessageActions.spec.tsx b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx similarity index 50% rename from src/modules/GroupChannel/context/__test__/useMessageActions.spec.tsx rename to src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx index 821fc009ec..9f3b1ffd6e 100644 --- a/src/modules/GroupChannel/context/__test__/useMessageActions.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx @@ -1,6 +1,65 @@ import { renderHook } from '@testing-library/react-hooks'; +import { UserMessageCreateParams, FileMessageCreateParams } from '@sendbird/chat/message'; + import { useMessageActions } from '../hooks/useMessageActions'; +const mockEventHandlers = { + message: { + onSendMessageFailed: jest.fn(), + onUpdateMessageFailed: jest.fn(), + onFileUploadFailed: 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('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ + state: { + eventHandlers: mockEventHandlers, + stores: { + sdkStore: { + sdk: { + groupChannel: { + getChannel: mockGetChannel, + addGroupChannelHandler: jest.fn(), + removeGroupChannelHandler: jest.fn(), + }, + createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection), + }, + initialized: true, + }, + }, + config: { + markAsReadScheduler: { + push: jest.fn(), + }, + groupChannel: { + replyType: 'NONE', + threadReplySelectType: 'PARENT', + }, + groupChannelSettings: { + enableMessageSearch: true, + }, + isOnline: true, + pubSub: { + subscribe: () => ({ remove: jest.fn() }), + }, + }, + }, + })), +})); + describe('useMessageActions', () => { // Setup common mocks const mockSendUserMessage = jest.fn(); @@ -183,4 +242,133 @@ describe('useMessageActions', () => { ); }); }); + + describe('processParams', () => { + const mockParams = { + sendUserMessage: jest.fn(), + sendFileMessage: jest.fn(), + sendMultipleFilesMessage: jest.fn(), + updateUserMessage: jest.fn(), + scrollToBottom: jest.fn(), + replyType: 'NONE', + }; + it('should handle successful user message', async () => { + const { result } = renderHook(() => useMessageActions(mockParams)); + const params: UserMessageCreateParams = { message: 'test' }; + + await result.current.sendUserMessage(params); + + expect(mockParams.sendUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'test' }), + expect.any(Function), + ); + }); + + it('should handle void return from onBeforeSendFileMessage', async () => { + const onBeforeSendFileMessage = jest.fn(); + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeSendFileMessage, + }), + ); + + const fileParams: FileMessageCreateParams = { + file: new File([], 'test.txt'), + }; + + await result.current.sendFileMessage(fileParams); + + expect(onBeforeSendFileMessage).toHaveBeenCalled(); + expect(mockParams.sendFileMessage).toHaveBeenCalledWith( + expect.objectContaining(fileParams), + expect.any(Function), + ); + }); + + it('should handle file upload error', async () => { + // Arrange + const error = new Error('Upload failed'); + const onBeforeSendFileMessage = jest.fn().mockRejectedValue(error); + const fileParams: FileMessageCreateParams = { + file: new File([], 'test.txt'), + fileName: 'test.txt', + }; + + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeSendFileMessage, + }), + ); + + await expect(async () => { + await result.current.sendFileMessage(fileParams); + }).rejects.toThrow('Upload failed'); + + // Wait for next tick to ensure all promises are resolved + await new Promise(process.nextTick); + + expect(onBeforeSendFileMessage).toHaveBeenCalled(); + expect(mockEventHandlers.message.onFileUploadFailed).toHaveBeenCalledWith(error); + expect(mockEventHandlers.message.onSendMessageFailed).toHaveBeenCalledWith( + expect.objectContaining({ + file: fileParams.file, + fileName: fileParams.fileName, + }), + error, + ); + }); + + it('should handle message update error', async () => { + // Arrange + const error = new Error('Update failed'); + const onBeforeUpdateUserMessage = jest.fn().mockRejectedValue(error); + const messageParams = { + messageId: 1, + message: 'update message', + }; + + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeUpdateUserMessage, + }), + ); + + await expect(async () => { + await result.current.updateUserMessage(messageParams.messageId, { + message: messageParams.message, + }); + }).rejects.toThrow('Update failed'); + + // Wait for next tick to ensure all promises are resolved + await new Promise(process.nextTick); + + expect(onBeforeUpdateUserMessage).toHaveBeenCalled(); + expect(mockEventHandlers.message.onUpdateMessageFailed).toHaveBeenCalledWith( + expect.objectContaining({ + message: messageParams.message, + }), + error, + ); + }); + + it('should preserve modified params from onBefore handlers', async () => { + const onBeforeSendUserMessage = jest.fn().mockImplementation((params) => ({ + ...params, + message: 'modified', + })); + + const { result } = renderHook(() => useMessageActions({ + ...mockParams, + onBeforeSendUserMessage, + }), + ); + + await result.current.sendUserMessage({ message: 'original' }); + + expect(mockParams.sendUserMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: 'modified' }), + expect.any(Function), + ); + }); + }); }); diff --git a/src/modules/GroupChannel/context/hooks/useMessageActions.ts b/src/modules/GroupChannel/context/hooks/useMessageActions.ts index 3c023b7a9b..c936fdd0db 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts +++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts @@ -21,7 +21,7 @@ import { VOICE_MESSAGE_MIME_TYPE, } from '../../../../utils/consts'; import type { CoreMessageType } from '../../../../utils'; -import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; import type { GroupChannelState, OnBeforeHandler } from '../types'; type MessageListDataSource = ReturnType; @@ -70,7 +70,7 @@ export function useMessageActions(params: Params): MessageActions { quoteMessage, replyType, } = params; - const { eventHandlers } = useSendbirdStateContext(); + const { state: { eventHandlers } } = useSendbird(); const buildInternalMessageParams = useCallback( (basicParams: T): T => { const messageParams = { ...basicParams } as T; From c92fd2533bf58b1d6e8aa5f0bf371c3b2e5cbf44 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Thu, 5 Dec 2024 10:34:04 +0900 Subject: [PATCH 16/29] [CLNP-5974] Add more GroupChannel module custom hook unit tests (#1271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - before Screenshot 2024-12-03 at 11 14 47 PM - after Screenshot 2024-12-03 at 11 13 43 PM GroupChannelProvider has some not covered lines but they're mostly from uikit-tools lib related logic. There could be a way to mock these lines too, but let me handle it separately. --- .../__tests__/useGroupChannel.spec.tsx | 266 ++++++++++++++-- .../__tests__/useMessageListScroll.spec.tsx | 300 ++++++++++++++++++ .../context/__tests__/utils.spec.ts | 160 ++++++++++ 3 files changed, 691 insertions(+), 35 deletions(-) create mode 100644 src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx create mode 100644 src/modules/GroupChannel/context/__tests__/utils.spec.ts diff --git a/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx b/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx index 99158ce7cb..58a3db2236 100644 --- a/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx @@ -2,7 +2,7 @@ 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 { GroupChannelProvider, GroupChannelContext } from '../GroupChannelProvider'; import { useGroupChannel } from '../hooks/useGroupChannel'; import { SendableMessageType } from '../../../../utils'; @@ -20,6 +20,7 @@ const mockMessageCollection = { loadPrevious: jest.fn(), loadNext: jest.fn(), }; +const mockMarkAsReadScheduler = { push: jest.fn() }; jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: jest.fn(() => ({ @@ -39,9 +40,7 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ }, config: { logger: mockLogger, - markAsReadScheduler: { - push: jest.fn(), - }, + markAsReadScheduler: mockMarkAsReadScheduler, groupChannel: { replyType: 'NONE', threadReplySelectType: 'PARENT', @@ -57,6 +56,50 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ }, })), })); +jest.mock('../utils', () => ({ + getMessageTopOffset: jest.fn().mockReturnValue(100), +})); + +const createMockStore = (initialState = {}) => { + let state = { + currentChannel: null, + fetchChannelError: null, + quoteMessage: null, + animatedMessageId: null, + isScrollBottomReached: true, + messages: [], + scrollRef: { current: null }, + hasNext: () => false, + resetWithStartingPoint: jest.fn(), + scrollPubSub: { + publish: jest.fn(), + }, + resetNewMessages: jest.fn(), + ...initialState, + }; + + const subscribers = new Set<() => void>(); + + return { + getState: () => state, + setState: (updater: (prev: typeof state) => typeof state) => { + state = updater(state); + subscribers.forEach(subscriber => subscriber()); + }, + subscribe: (callback: () => void) => { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + }; +}; + +const createWrapper = (mockStore) => { + return ({ children }) => ( + + {children} + + ); +}; describe('useGroupChannel', () => { const wrapper = ({ children }) => ( @@ -64,7 +107,6 @@ describe('useGroupChannel', () => { {children} ); - describe('State management', () => { it('provides initial state', () => { const { result } = renderHook(() => useGroupChannel(), { wrapper }); @@ -98,7 +140,7 @@ describe('useGroupChannel', () => { const error = new Error('Failed to fetch channel'); act(() => { - result.current.actions.handleChannelError(error); + result.current.actions.handleChannelError(error as any); }); expect(result.current.state.currentChannel).toBeNull(); @@ -160,6 +202,132 @@ describe('useGroupChannel', () => { }); describe('Channel actions', () => { + describe('scrollToBottom', () => { + it('should not scroll if scrollRef is not set', async () => { + const mockStore = createMockStore({ + scrollRef: { current: null }, + scrollPubSub: { publish: jest.fn() }, + }); + const { result } = renderHook(() => useGroupChannel(), { + wrapper: createWrapper(mockStore), + }); + await act(async () => { + await result.current.actions.scrollToBottom(true); + await waitFor(() => { + expect(result.current.state.scrollPubSub.publish).not.toHaveBeenCalled(); + }); + }); + }); + it('should reset new messages and mark as read if no next messages', async () => { + const mockStore = createMockStore({ + scrollRef: { current: {} }, + hasNext: () => false, + currentChannel: mockChannel, + resetNewMessages: jest.fn(), + scrollPubSub: { publish: jest.fn() }, + }); + const { result } = renderHook(() => useGroupChannel(), { + wrapper: createWrapper(mockStore), + }); + await act(async () => { + await result.current.actions.scrollToBottom(true); + await waitFor(() => { + expect(result.current.state.resetNewMessages).toHaveBeenCalled(); + expect(mockMarkAsReadScheduler.push).toHaveBeenCalledWith(mockChannel); + }); + }); + }); + it('should scroll to bottom when online and has next message', async () => { + const mockScrollRef = { current: {} }; + const mockScrollPubSub = { publish: jest.fn() }; + const mockStore = createMockStore({ + scrollRef: mockScrollRef, + hasNext: () => true, + resetWithStartingPoint: jest.fn().mockResolvedValue(undefined), + scrollPubSub: mockScrollPubSub, + }); + const { result } = renderHook(() => useGroupChannel(), { + wrapper: createWrapper(mockStore), + }); + await act(async () => { + await result.current.actions.scrollToBottom(true); + await waitFor(() => { + expect(result.current.state.resetWithStartingPoint).toHaveBeenCalledWith(Number.MAX_SAFE_INTEGER); + expect(result.current.state.scrollPubSub.publish).toHaveBeenCalledWith('scrollToBottom', { animated: true }); + }); + }); + }); + }); + describe('scrollToMessage', () => { + it('should not scroll if element is not found', async () => { + const mockStore = createMockStore({ + messages: [], + scrollRef: { current: document.createElement('div') }, + scrollPubSub: { publish: jest.fn() }, + }); + const { result } = renderHook(() => useGroupChannel(), { + wrapper: createWrapper(mockStore), + }); + await act(async () => { + await result.current.actions.scrollToMessage(9999, 9999, true, true); + await waitFor(() => { + expect(result.current.state.scrollPubSub.publish).not.toHaveBeenCalled(); + }); + }); + }); + it('scroll to message when message exists', async () => { + const mockMessage = { messageId: 123, createdAt: 1000 }; + const mockStore = createMockStore({ + messages: [mockMessage], + scrollRef: { current: document.createElement('div') }, + scrollPubSub: { publish: jest.fn() }, + }); + const { result } = renderHook(() => useGroupChannel(), { + wrapper: createWrapper(mockStore), + }); + await act(async () => { + await result.current.actions.scrollToMessage(mockMessage.createdAt, mockMessage.messageId, true, true); + await waitFor(() => { + expect(mockStore.getState().scrollPubSub.publish) + .toHaveBeenCalledWith('scroll', { + top: 100, + animated: true, + }); + expect(result.current.state.animatedMessageId).toBe(mockMessage.messageId); + }); + }); + }); + it('loads message and scrolls when message does not exist', async () => { + const mockScrollPubSub = { publish: jest.fn() }; + const mockResetWithStartingPoint = jest.fn().mockResolvedValue(undefined); + const mockStore = createMockStore({ + messages: [], + scrollRef: { + current: document.createElement('div'), + }, + scrollPubSub: mockScrollPubSub, + resetWithStartingPoint: mockResetWithStartingPoint, + }); + const { result } = renderHook(() => useGroupChannel(), { + wrapper: createWrapper(mockStore), + }); + await act(async () => { + await result.current.actions.scrollToMessage(1000, 123, true, true); + await waitFor(() => { + expect(mockResetWithStartingPoint).toHaveBeenCalledWith(1000); + // mocking setTimeout + jest.runAllTimers(); + expect(mockStore.getState().scrollPubSub.publish) + .toHaveBeenCalledWith('scroll', { + top: 100, + lazy: false, + animated: true, + }); + expect(mockStore.getState().animatedMessageId).toBe(123); + }); + }); + }); + }); it('processes reaction toggle', async () => { const mockChannelWithReactions = { ...mockChannel, @@ -229,40 +397,68 @@ describe('useGroupChannel', () => { }); - 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 }); + describe('toggleReaction', () => { + it('should be able to add and delete reactions', async () => { + const mockChannel = { + addReaction: jest.fn().mockResolvedValue(undefined), + deleteReaction: jest.fn().mockResolvedValue(undefined), + }; + const mockStore = createMockStore({ + currentChannel: mockChannel, + }); + const { result } = renderHook(() => useGroupChannel(), { + wrapper: createWrapper(mockStore), + }); + const mockMessage = { messageId: 123 } as SendableMessageType; - act(async () => { - result.current.actions.setCurrentChannel(mockChannelWithReactions as any); + await act(async () => { + result.current.actions.toggleReaction(mockMessage, '👍', false); + await waitFor(() => { + expect(mockChannel.addReaction).toHaveBeenCalledWith(mockMessage, '👍'); + }); + }); + await act(async () => { + result.current.actions.toggleReaction(mockMessage, '👍', true); + await waitFor(() => { + expect(mockChannel.deleteReaction).toHaveBeenCalledWith(mockMessage, '👍'); + }); + }); }); + it('processes successful reaction toggles without logging errors', async () => { + const mockChannelWithReactions = { + ...mockChannel, + addReaction: jest.fn().mockResolvedValue({}), + deleteReaction: jest.fn().mockResolvedValue({}), + }; - act(async () => { - result.current.actions.toggleReaction( - { messageId: 1 } as SendableMessageType, - 'thumbs_up', - false, - ); - await waitFor(() => { - expect(mockChannelWithReactions.addReaction).toHaveBeenCalled(); - expect(mockLogger.warning).not.toHaveBeenCalled(); + 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', - true, - ); - await waitFor(() => { - expect(mockChannelWithReactions.deleteReaction).toHaveBeenCalled(); - expect(mockLogger.warning).not.toHaveBeenCalled(); + 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/__tests__/useMessageListScroll.spec.tsx b/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx new file mode 100644 index 0000000000..9e7b74ac56 --- /dev/null +++ b/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx @@ -0,0 +1,300 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useMessageListScroll } from '../hooks/useMessageListScroll'; +import { useGroupChannel } from '../hooks/useGroupChannel'; + +jest.mock('../hooks/useGroupChannel', () => ({ + useGroupChannel: jest.fn(), +})); + +describe('useMessageListScroll', () => { + const mockSetIsScrollBottomReached = jest.fn(); + + beforeEach(() => { + (useGroupChannel as jest.Mock).mockImplementation(() => ({ + state: { isScrollBottomReached: true }, + actions: { setIsScrollBottomReached: mockSetIsScrollBottomReached }, + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Initialization and Basic Behavior', () => { + it('should set the initial state correctly', () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + + expect(result.current.scrollRef.current).toBe(null); + expect(result.current.isScrollBottomReached).toBe(true); + expect(result.current.scrollDistanceFromBottomRef.current).toBe(0); + expect(result.current.scrollPositionRef.current).toBe(0); + expect(typeof result.current.scrollPubSub.publish).toBe('function'); + expect(typeof result.current.scrollPubSub.subscribe).toBe('function'); + }); + }); + + describe('scrollToBottom', () => { + it('should call resolve only if scrollRef is null', async () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + const resolveMock = jest.fn(); + + await act(async () => { + result.current.scrollPubSub.publish('scrollToBottom', { resolve: resolveMock }); + await waitFor(() => { + expect(resolveMock).toHaveBeenCalled(); + }); + }); + }); + + it('should update scroll position refs when scrolling to bottom', async () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + const mockScrollRef = { + current: { + scrollHeight: 1000, + clientHeight: 500, + scrollTop: 0, + scroll: jest.fn(), + }, + }; + // @ts-ignore + result.current.scrollRef = mockScrollRef; + + await act(async () => { + result.current.scrollPubSub.publish('scrollToBottom', {}); + await waitFor(() => { + expect(result.current.scrollDistanceFromBottomRef.current).toBe(0); + expect(result.current.isScrollBottomReached).toBe(true); + }); + }); + }); + + it('should update scroll position refs when scrolling to specific position', async () => { + const mockElement = document.createElement('div'); + Object.defineProperties(mockElement, { + scrollHeight: { value: 1000, configurable: true }, + clientHeight: { value: 500, configurable: true }, + scrollTop: { + value: 300, + writable: true, + configurable: true, + }, + scroll: { + value: jest.fn(), + configurable: true, + }, + }); + + const { result } = renderHook(() => useMessageListScroll('auto')); + + await act(async () => { + // @ts-ignore + result.current.scrollRef.current = mockElement; + result.current.scrollPubSub.publish('scroll', { + top: 300, + lazy: false, + }); + + await waitFor(() => { + expect(result.current.scrollDistanceFromBottomRef.current).toBe(200); + expect(mockSetIsScrollBottomReached).toHaveBeenCalledWith(true); + }); + }); + }); + + it('should use scrollTop if scroll method is not defined', async () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + + const mockScrollElement = { + scrollHeight: 1000, + scrollTop: 0, + }; + + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + await act(async () => { + const promise = new Promise((resolve) => { + result.current.scrollPubSub.publish('scrollToBottom', { resolve }); + }); + await promise; + }); + + expect(mockScrollElement.scrollTop).toBe(1000); + }); + + it('should use smooth behavior if behavior parameter is smooth', async () => { + const { result } = renderHook(() => useMessageListScroll('smooth')); + + const mockScroll = jest.fn(); + const mockScrollElement = { + scroll: mockScroll, + scrollHeight: 1000, + }; + + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + await act(async () => { + result.current.scrollPubSub.publish('scrollToBottom', {}); + await waitFor(() => { + expect(mockScroll).toHaveBeenCalledWith({ + top: 1000, + behavior: 'smooth', + }); + }); + }); + }); + }); + + describe('scroll', () => { + it('should do nothing if scrollRef is null', async () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + const resolveMock = jest.fn(); + + await act(async () => { + result.current.scrollPubSub.publish('scroll', { resolve: resolveMock }); + }); + + expect(resolveMock).not.toHaveBeenCalled(); + }); + + it('should use scrollTop if scroll method is not defined', async () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + + const mockScrollElement = { + scrollHeight: 1000, + scrollTop: 0, + clientHeight: 500, + }; + + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + await act(async () => { + result.current.scrollPubSub.publish('scroll', { top: 300 }); + await waitFor(() => { + expect(mockScrollElement.scrollTop).toBe(300); + }); + }); + }); + + it('should not change the scroll position if top is not defined', async () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + + const mockScroll = jest.fn(); + const mockScrollElement = { + scroll: mockScroll, + scrollHeight: 1000, + scrollTop: 100, + clientHeight: 500, + }; + + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + await act(async () => { + result.current.scrollPubSub.publish('scroll', {}); + }); + + expect(mockScroll).not.toHaveBeenCalled(); + }); + + it('should execute immediately if lazy option is false', async () => { + jest.useFakeTimers(); + const { result } = renderHook(() => useMessageListScroll('auto')); + + const mockScroll = jest.fn(); + const mockScrollElement = { + scroll: mockScroll, + scrollHeight: 1000, + scrollTop: 0, + clientHeight: 500, + }; + + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + await act(async () => { + result.current.scrollPubSub.publish('scroll', { top: 300, lazy: false }); + }); + + expect(mockScroll).toHaveBeenCalledWith({ + top: 300, + behavior: 'auto', + }); + jest.useRealTimers(); + }); + }); + + describe('deps change', () => { + it('should reset all states if deps change', () => { + const mockScrollElement = { + scrollHeight: 1000, + scrollTop: 0, + }; + + const { rerender } = renderHook( + ({ deps }) => useMessageListScroll('auto', deps), + { initialProps: { deps: ['initial'] } }, + ); + + const { result } = renderHook(() => useMessageListScroll('auto')); + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + rerender({ deps: ['updated'] }); + + expect(mockSetIsScrollBottomReached).toHaveBeenCalledWith(true); + expect(result.current.scrollDistanceFromBottomRef.current).toBe(0); + expect(result.current.scrollPositionRef.current).toBe(0); + }); + }); + + describe('getScrollBehavior utility', () => { + it('should return smooth if animated is true', async () => { + const { result } = renderHook(() => useMessageListScroll('auto')); + + const mockScroll = jest.fn(); + const mockScrollElement = { + scroll: mockScroll, + scrollHeight: 1000, + }; + + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + await act(async () => { + result.current.scrollPubSub.publish('scroll', { top: 300, animated: true }); + await waitFor(() => { + expect(mockScroll).toHaveBeenCalledWith({ + top: 300, + behavior: 'smooth', + }); + }); + }); + }); + + it('should return auto if animated is false', async () => { + const { result } = renderHook(() => useMessageListScroll('smooth')); + + const mockScroll = jest.fn(); + const mockScrollElement = { + scroll: mockScroll, + scrollHeight: 1000, + }; + + // @ts-ignore + result.current.scrollRef.current = mockScrollElement; + + await act(async () => { + result.current.scrollPubSub.publish('scroll', { top: 300, animated: false }); + await waitFor(() => { + expect(mockScroll).toHaveBeenCalledWith({ + top: 300, + behavior: 'auto', + }); + }); + }); + }); + }); +}); diff --git a/src/modules/GroupChannel/context/__tests__/utils.spec.ts b/src/modules/GroupChannel/context/__tests__/utils.spec.ts new file mode 100644 index 0000000000..5eec81164e --- /dev/null +++ b/src/modules/GroupChannel/context/__tests__/utils.spec.ts @@ -0,0 +1,160 @@ +import type { GroupChannel } from '@sendbird/chat/groupChannel'; +import { Role } from '@sendbird/chat'; +import { + getComponentKeyFromMessage, + isContextMenuClosed, + getMessageTopOffset, + isDisabledBecauseFrozen, + isDisabledBecauseMuted, + isDisabledBecauseSuggestedReplies, + isFormVersionCompatible, + isDisabledBecauseMessageForm, +} from '../utils'; +import { UIKIT_COMPATIBLE_FORM_VERSION } from '../const'; + +describe('GroupChannel utils', () => { + describe('getComponentKeyFromMessage', () => { + it('should return messageId if sendingStatus is succeeded', () => { + const message = { + messageId: 12345, + sendingStatus: 'succeeded', + }; + expect(getComponentKeyFromMessage(message)).toBe('12345'); + }); + + it('should return reqId if sendingStatus is pending', () => { + const message = { + messageId: 12345, + reqId: 'temp-id-123', + sendingStatus: 'pending', + }; + expect(getComponentKeyFromMessage(message)).toBe('temp-id-123'); + }); + }); + + describe('isContextMenuClosed', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('should return true if dropdown and emoji portal are empty', () => { + document.body.innerHTML = ` +
+
+ `; + expect(isContextMenuClosed()).toBe(true); + }); + + it('should return false if dropdown or emoji portal has content', () => { + document.body.innerHTML = ` +
content
+
+ `; + expect(isContextMenuClosed()).toBe(false); + }); + }); + + describe('getMessageTopOffset', () => { + const mockCreatedAt = 1234567890; + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('should return offsetTop if message element exists', () => { + document.body.innerHTML = ` +
+ `; + const element = document.querySelector('[data-sb-created-at="1234567890"]'); + Object.defineProperty(element, 'offsetTop', { + configurable: true, + value: 100, + }); + expect(getMessageTopOffset(mockCreatedAt)).toBe(100); + }); + + it('should return null if message element does not exist', () => { + expect(getMessageTopOffset(mockCreatedAt)).toBe(null); + }); + }); + + describe('isDisabledBecauseFrozen', () => { + it('should return true if channel is frozen and user is not operator', () => { + const channel = { + isFrozen: true, + myRole: Role.NONE, + } as GroupChannel; + expect(isDisabledBecauseFrozen(channel)).toBe(true); + }); + + it('should return false if channel is not frozen or user is operator', () => { + expect(isDisabledBecauseFrozen({ isFrozen: false, myRole: Role.NONE } as GroupChannel)).toBe(false); + expect(isDisabledBecauseFrozen({ isFrozen: true, myRole: Role.OPERATOR } as GroupChannel)).toBe(false); + }); + }); + + describe('isDisabledBecauseMuted', () => { + it('should return true if user is muted', () => { + const channel = { myMutedState: 'muted' } as GroupChannel; + expect(isDisabledBecauseMuted(channel)).toBe(true); + }); + + it('should return false if user is not muted', () => { + const channel = { myMutedState: 'unmuted' } as GroupChannel; + expect(isDisabledBecauseMuted(channel)).toBe(false); + }); + }); + + describe('isDisabledBecauseSuggestedReplies', () => { + it('should return true if suggested replies are enabled and chat input is disabled', () => { + const channel = { + lastMessage: { + extendedMessagePayload: { + suggested_replies: ['reply1', 'reply2'], + disable_chat_input: true, + }, + }, + }; + expect(isDisabledBecauseSuggestedReplies(channel as any, true)).toBe(true); + }); + }); + + describe('isFormVersionCompatible', () => { + it('should return true if version is compatible', () => { + expect(isFormVersionCompatible(UIKIT_COMPATIBLE_FORM_VERSION)).toBe(true); + expect(isFormVersionCompatible(UIKIT_COMPATIBLE_FORM_VERSION - 1)).toBe(true); + }); + + it('should return false if version is not compatible', () => { + expect(isFormVersionCompatible(UIKIT_COMPATIBLE_FORM_VERSION + 1)).toBe(false); + }); + }); + + describe('isDisabledBecauseMessageForm', () => { + it('should return true if there is an unsent form and chat input is disabled', () => { + const messages = [{ + messageForm: { + isSubmitted: false, + version: UIKIT_COMPATIBLE_FORM_VERSION, + }, + extendedMessagePayload: { + disable_chat_input: true, + }, + }]; + expect(isDisabledBecauseMessageForm(messages as any, true)).toBe(true); + }); + + it('should return false if there is no form or it is already submitted', () => { + const messages = [{ + messageForm: { + isSubmitted: true, + version: UIKIT_COMPATIBLE_FORM_VERSION, + }, + extendedMessagePayload: { + disable_chat_input: true, + }, + }]; + expect(isDisabledBecauseMessageForm(messages as any, true)).toBe(false); + }); + }); +}); From 6e4a14a2ed0676d5de44994035354dc0f6844e93 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Fri, 6 Dec 2024 09:14:12 +0900 Subject: [PATCH 17/29] Use useEffect instead of useDeepCompareEffect in GroupChannelListProvider (#1272) Fixes - https://sendbird.atlassian.net/browse/CLNP-5966 - https://sendbird.atlassian.net/browse/CLNP-5967 - https://sendbird.atlassian.net/browse/CLNP-5969 - https://sendbird.atlassian.net/browse/CLNP-5971 - https://sendbird.atlassian.net/browse/CLNP-5973 ## When to use useDeepCompareEffect vs useEffect ### useDeepCompareEffect is useful when: 1. **Handling objects without guaranteed immutability** ```typescript const complexObject = { settings: { theme: { ... }, preferences: { ... } }, data: [ ... ] }; useDeepCompareEffect(() => { // When you want to detect actual value changes }, [complexObject]); ``` 2. **Working with data from external libraries or APIs** - When objects have new references but identical content 3. **Dealing with deeply nested objects where memoization is impractical** - When object structures are too complex for individual memoization ### useEffect is better when: 1. **Detecting changes in array items** ```typescript const items = [{id: 1, value: 'a'}, {id: 2, value: 'b'}]; // Better for detecting changes within array items useEffect(() => { // Detect changes in items array }, [items]); ``` 2. **Performance is critical** - Deep comparison is computationally expensive - Especially important for large arrays or frequently updating data ### Example of proper useDeepCompareEffect usage: ```typescript useDeepCompareEffect(() => { updateState({ ...configurations, ...scrollState, ...eventHandlers, }); }, [ configurations, scrollState, eventHandlers, ]); ``` This works well here because: - Dependencies are mostly objects - Updates are needed only when internal structure changes - Objects are already memoized, reducing deep comparison cost ### Key Takeaway: - Use useDeepCompareEffect when structural equality matters - Use useEffect for reference equality or primitive value changes - Consider the trade-off between performance and accuracy --------- Co-authored-by: junyoung.lim --- src/hooks/useDeepCompareEffect.ts | 20 +++++++++++++++---- .../GroupChannelListProvider.spec.tsx | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/hooks/useDeepCompareEffect.ts b/src/hooks/useDeepCompareEffect.ts index db937bba7d..cdcaf7a0b6 100644 --- a/src/hooks/useDeepCompareEffect.ts +++ b/src/hooks/useDeepCompareEffect.ts @@ -13,12 +13,24 @@ function useDeepCompareMemoize(value: T): T { /** * Custom hook that works like useEffect but performs a deep comparison of dependencies - * instead of reference equality. This is useful when dealing with complex objects or arrays - * in dependencies that could trigger unnecessary re-renders. + * instead of reference equality. * - * Inspired by https://github.com/kentcdodds/use-deep-compare-effect + * Best used when: + * - Working with complex objects without guaranteed immutability + * - Handling data from external sources where reference equality isn't maintained + * - Dealing with deeply nested objects where individual memoization is impractical * - * @param callback Effect callback that can either return nothing (void) or return a cleanup function (() => void). + * Avoid using when: + * - Detecting changes within array items is crucial + * - Performance is critical (deep comparison is expensive) + * - Working primarily with primitive values or simple objects + * + * @example + * useDeepCompareEffect(() => { + * // Effect logic + * }, [complexObject, anotherObject]); + * + * @param callback Effect callback that can return a cleanup function * @param dependencies Array of dependencies to be deeply compared */ function useDeepCompareEffect( diff --git a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx index f4d2ebd17b..1421d52845 100644 --- a/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/GroupChannelListProvider.spec.tsx @@ -31,7 +31,7 @@ jest.mock('@sendbird/uikit-tools', () => ({ useGroupChannelList: jest.fn(() => ({ refreshing: false, initialized: true, - groupChannels: [{ url: 'test-groupchannel-url-1' }], + groupChannels: [{ url: 'test-groupchannel-url-1', serialize: () => JSON.stringify(this) }], refresh: null, loadMore: null, })), From 37f0f25789b7da1ad7b6984ba4c4f1c6d710dc9d Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Fri, 6 Dec 2024 10:56:43 +0900 Subject: [PATCH 18/29] [CLNP-5981] fix blinking GroupChannel on switching to another (#1277) Fixes https://sendbird.atlassian.net/browse/CLNP-5981 and applied the same approach in https://github.com/sendbird/sendbird-uikit-react/pull/1272 ### Checklist Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If unsure, ask the members. This is a reminder of what we look for before merging your code. - [x] **All tests pass locally with my changes** - [ ] **I have added tests that prove my fix is effective or that my feature works** - [ ] **Public components / utils / props are appropriately exported** - [ ] I have added necessary documentation (if appropriate) --- src/modules/GroupChannel/context/GroupChannelProvider.tsx | 8 ++++---- .../context/__tests__/GroupChannelProvider.spec.tsx | 2 ++ .../context/__tests__/useGroupChannel.spec.tsx | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 934077cbe2..f852955fa1 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -298,14 +298,14 @@ const GroupChannelManager :React.FC it.serialize()), ]); return children; diff --git a/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx index 2ed036a332..39f441c0ff 100644 --- a/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx @@ -8,6 +8,7 @@ const mockLogger = { warning: jest.fn() }; const mockChannel = { url: 'test-channel', members: [{ userId: '1', nickname: 'user1' }], + serialize: () => JSON.stringify(this), }; const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); @@ -17,6 +18,7 @@ const mockMessageCollection = { initialize: jest.fn().mockResolvedValue(null), loadPrevious: jest.fn(), loadNext: jest.fn(), + messages: [], }; jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, diff --git a/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx b/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx index 58a3db2236..bf861005ea 100644 --- a/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx @@ -10,6 +10,7 @@ const mockLogger = { warning: jest.fn() }; const mockChannel = { url: 'test-channel', members: [{ userId: '1', nickname: 'user1' }], + serialize: () => JSON.stringify(this), }; const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); @@ -19,6 +20,7 @@ const mockMessageCollection = { initialize: jest.fn().mockResolvedValue(null), loadPrevious: jest.fn(), loadNext: jest.fn(), + messages: [], }; const mockMarkAsReadScheduler = { push: jest.fn() }; jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ @@ -276,7 +278,7 @@ describe('useGroupChannel', () => { }); }); it('scroll to message when message exists', async () => { - const mockMessage = { messageId: 123, createdAt: 1000 }; + const mockMessage = { messageId: 123, createdAt: 1000, serialize: () => JSON.stringify(this) }; const mockStore = createMockStore({ messages: [mockMessage], scrollRef: { current: document.createElement('div') }, From db30b628f15abcdcead59ac5cc4d621161694ac6 Mon Sep 17 00:00:00 2001 From: Baek EunSeo Date: Fri, 6 Dec 2024 11:13:00 +0900 Subject: [PATCH 19/29] chore: Add tests for SendbirdProvider migration (#1275) [CLNP-5737](https://sendbird.atlassian.net/browse/CLNP-5737) * Added tests for `Sendbird/index.tsx`, `Sendbird/utils.ts`, `SendbirdProvider.tsx`, `initialState.ts`, and `useSendbird.tsx` ### Before ![image](https://github.com/user-attachments/assets/0bdae22e-f5f5-4880-949c-33ea65d61a29) ### After ![image](https://github.com/user-attachments/assets/b0f6d021-bb80-49cd-b476-cc5a6c155c3e) --- .../__tests__/SendbirdContext.spec.tsx | 31 ++ .../__tests__/SendbirdProvider.spec.tsx | 73 ++++ src/lib/Sendbird/__tests__/index.spec.tsx | 90 ++++ .../Sendbird/__tests__/initialState.spec.ts | 47 ++ .../Sendbird/__tests__/useSendbird.spec.tsx | 410 ++++++++++++++++++ src/lib/Sendbird/__tests__/utils.spec.ts | 154 +++++++ .../Sendbird/context/hooks/useSendbird.tsx | 2 +- src/lib/Sendbird/index.tsx | 2 +- 8 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 src/lib/Sendbird/__tests__/SendbirdContext.spec.tsx create mode 100644 src/lib/Sendbird/__tests__/SendbirdProvider.spec.tsx create mode 100644 src/lib/Sendbird/__tests__/index.spec.tsx create mode 100644 src/lib/Sendbird/__tests__/initialState.spec.ts create mode 100644 src/lib/Sendbird/__tests__/useSendbird.spec.tsx create mode 100644 src/lib/Sendbird/__tests__/utils.spec.ts diff --git a/src/lib/Sendbird/__tests__/SendbirdContext.spec.tsx b/src/lib/Sendbird/__tests__/SendbirdContext.spec.tsx new file mode 100644 index 0000000000..58b8ad2c59 --- /dev/null +++ b/src/lib/Sendbird/__tests__/SendbirdContext.spec.tsx @@ -0,0 +1,31 @@ +import React, { useContext } from 'react'; +import { render } from '@testing-library/react'; +import { SendbirdContext, createSendbirdContextStore } from '../context/SendbirdContext'; + +describe('SendbirdContext', () => { + it('should initialize with null by default', () => { + const TestComponent = () => { + const context = useContext(SendbirdContext); + return
{context ? 'Not Null' : 'Null'}
; + }; + + const { getByText } = render(); + expect(getByText('Null')).toBeInTheDocument(); + }); + + it('should provide a valid context to child components', () => { + const mockStore = createSendbirdContextStore(); + const TestComponent = () => { + const context = useContext(SendbirdContext); + return
{context ? 'Not Null' : 'Null'}
; + }; + + const { getByText } = render( + + + , + ); + + expect(getByText('Not Null')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/Sendbird/__tests__/SendbirdProvider.spec.tsx b/src/lib/Sendbird/__tests__/SendbirdProvider.spec.tsx new file mode 100644 index 0000000000..fbe631e483 --- /dev/null +++ b/src/lib/Sendbird/__tests__/SendbirdProvider.spec.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SendbirdContextProvider } from '../context/SendbirdProvider'; +import useSendbird from '../context/hooks/useSendbird'; + +const mockState = { + stores: { sdkStore: { initialized: false } }, + config: { logger: console, groupChannel: { enableVoiceMessage: false } }, +}; +const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; + +jest.mock('../context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState, actions: mockActions })), + useSendbird: jest.fn(() => ({ state: mockState, actions: mockActions })), +})); + +describe('SendbirdProvider', () => { + beforeEach(() => { + // Reset mock functions before each test + jest.clearAllMocks(); + + // Mock MediaRecorder.isTypeSupported + global.MediaRecorder = { + isTypeSupported: jest.fn((type) => { + const supportedMimeTypes = ['audio/webm', 'audio/wav']; + return supportedMimeTypes.includes(type); + }), + }; + + // Mock useSendbird return value + useSendbird.mockReturnValue({ + state: mockState, + actions: mockActions, + }); + }); + + it('should render child components', () => { + const { getByTestId } = render( + +
Child Component
+
, + ); + + expect(getByTestId('child')).toBeInTheDocument(); + }); + + it('should call connect when mounted', () => { + render( + +
Child Component
+
, + ); + + expect(mockActions.connect).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 'mockAppId', + userId: 'mockUserId', + }), + ); + }); + + it('should call disconnect on unmount', () => { + const { unmount } = render( + +
Child Component
+
, + ); + + unmount(); + expect(mockActions.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/Sendbird/__tests__/index.spec.tsx b/src/lib/Sendbird/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9c76d3bdc9 --- /dev/null +++ b/src/lib/Sendbird/__tests__/index.spec.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { SendbirdProvider, withSendBird } from '../index'; + +jest.mock('@sendbird/uikit-tools', () => ({ + UIKitConfigProvider: jest.fn(({ children }) =>
{children}
), +})); +jest.mock('../context/SendbirdProvider', () => ({ + SendbirdContextProvider: jest.fn(({ children }) =>
{children}
), +})); +jest.mock('../context/hooks/useSendbird', () => jest.fn(() => ({ + state: { someState: 'testState' }, + actions: { someAction: jest.fn() }, +}))); +jest.mock('../../utils/uikitConfigMapper', () => ({ + uikitConfigMapper: jest.fn(() => ({ + common: {}, + groupChannel: {}, + openChannel: {}, + })), +})); +jest.mock('../../utils/uikitConfigStorage', () => ({})); + +describe('SendbirdProvider/index', () => { + it('renders UIKitConfigProvider with correct localConfigs', () => { + const props = { + replyType: 'threaded', + isMentionEnabled: true, + isReactionEnabled: true, + disableUserProfile: false, + isVoiceMessageEnabled: true, + isTypingIndicatorEnabledOnChannelList: false, + isMessageReceiptStatusEnabledOnChannelList: false, + showSearchIcon: true, + uikitOptions: {}, + }; + + render(); + + expect(screen.getByTestId('UIKitConfigProvider')).toBeInTheDocument(); + expect(screen.getByTestId('SendbirdContextProvider')).toBeInTheDocument(); + }); +}); + +describe('withSendbirdContext', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('logs a warning if mapStoreToProps is not a function', () => { + const MockComponent = jest.fn(() =>
); + const invalidMapStoreToProps = 'invalidValue'; + + const WrappedComponent = withSendBird(MockComponent, invalidMapStoreToProps); + + render(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Second parameter to withSendbirdContext must be a pure function', + ); + }); + + it('renders OriginalComponent with merged props', () => { + const MockComponent = jest.fn((props) =>
{props.testProp}
); + const mapStoreToProps = (context: any) => ({ + mappedProp: context.someState, + }); + + const WrappedComponent = withSendBird(MockComponent, mapStoreToProps); + + render(); + + expect(screen.getByTestId('MockComponent')).toHaveTextContent('additionalValue'); + + expect(MockComponent).toHaveBeenCalledWith( + expect.objectContaining({ + mappedProp: 'testState', + testProp: 'additionalValue', + }), + {}, + ); + }); +}); diff --git a/src/lib/Sendbird/__tests__/initialState.spec.ts b/src/lib/Sendbird/__tests__/initialState.spec.ts new file mode 100644 index 0000000000..a99e491957 --- /dev/null +++ b/src/lib/Sendbird/__tests__/initialState.spec.ts @@ -0,0 +1,47 @@ +import { initialState } from '../context/initialState'; + +describe('initialState', () => { + it('should match the expected structure', () => { + expect(initialState).toMatchObject({ + config: expect.any(Object), + stores: expect.any(Object), + utils: expect.any(Object), + eventHandlers: expect.any(Object), + }); + }); + + it('should have default values', () => { + expect(initialState.stores.sdkStore).toEqual({ + sdk: {}, + initialized: false, + loading: false, + error: undefined, + }); + expect(initialState.stores.userStore).toEqual({ + user: {}, + initialized: false, + loading: false, + }); + expect(initialState.stores.appInfoStore).toEqual({ + messageTemplatesInfo: undefined, + waitingTemplateKeysMap: {}, + }); + }); + + it('should have correct config values', () => { + expect(initialState.config.theme).toBe('light'); + expect(initialState.config.replyType).toBe('NONE'); + expect(initialState.config.uikitUploadSizeLimit).toBeDefined(); + expect(initialState.config.uikitMultipleFilesMessageLimit).toBeDefined(); + }); + + it('should have all eventHandlers initialized', () => { + expect(initialState.eventHandlers.reaction.onPressUserProfile).toBeInstanceOf(Function); + expect(initialState.eventHandlers.connection.onConnected).toBeInstanceOf(Function); + expect(initialState.eventHandlers.connection.onFailed).toBeInstanceOf(Function); + expect(initialState.eventHandlers.modal.onMounted).toBeInstanceOf(Function); + expect(initialState.eventHandlers.message.onSendMessageFailed).toBeInstanceOf(Function); + expect(initialState.eventHandlers.message.onUpdateMessageFailed).toBeInstanceOf(Function); + expect(initialState.eventHandlers.message.onFileUploadFailed).toBeInstanceOf(Function); + }); +}); diff --git a/src/lib/Sendbird/__tests__/useSendbird.spec.tsx b/src/lib/Sendbird/__tests__/useSendbird.spec.tsx new file mode 100644 index 0000000000..ddcb777fd5 --- /dev/null +++ b/src/lib/Sendbird/__tests__/useSendbird.spec.tsx @@ -0,0 +1,410 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import useSendbird from '../context/hooks/useSendbird'; +import { SendbirdContext, createSendbirdContextStore } from '../context/SendbirdContext'; + +jest.mock('../utils', () => { + const actualUtils = jest.requireActual('../utils'); + return { + ...actualUtils, + initSDK: jest.fn(() => ({ + connect: jest.fn().mockResolvedValue({ userId: 'mockUserId' }), + updateCurrentUserInfo: jest.fn().mockResolvedValue({}), + })), + setupSDK: jest.fn(), + }; +}); + +describe('useSendbird', () => { + let mockStore; + const mockLogger = { error: jest.fn(), info: jest.fn() }; + + beforeEach(() => { + mockStore = createSendbirdContextStore(); + }); + + const wrapper = ({ children }) => ( + {children} + ); + + describe('General behavior', () => { + it('should throw an error if used outside SendbirdProvider', () => { + const { result } = renderHook(() => useSendbird()); + expect(result.error).toBeDefined(); + expect(result.error.message).toBe('No sendbird state value available. Make sure you are rendering `` at the top of your app.'); + }); + + it('should return state and actions when used within SendbirdProvider', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + expect(result.current.state).toBeDefined(); + expect(result.current.actions).toBeDefined(); + }); + }); + + describe('SDK actions', () => { + it('should update state when initSdk is called', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + act(() => { + result.current.actions.initSdk('mockSdk'); + }); + + expect(mockStore.getState().stores.sdkStore.sdk).toBe('mockSdk'); + expect(mockStore.getState().stores.sdkStore.initialized).toBe(true); + }); + + it('should reset SDK state when resetSdk is called', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + act(() => { + result.current.actions.initSdk('mockSdk'); + }); + + act(() => { + result.current.actions.resetSdk(); + }); + + const sdkStore = mockStore.getState().stores.sdkStore; + expect(sdkStore.sdk).toBeNull(); + expect(sdkStore.initialized).toBe(false); + expect(sdkStore.loading).toBe(false); + }); + + it('should set SDK loading state correctly', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + act(() => { + result.current.actions.setSdkLoading(true); + }); + + expect(mockStore.getState().stores.sdkStore.loading).toBe(true); + + act(() => { + result.current.actions.setSdkLoading(false); + }); + + expect(mockStore.getState().stores.sdkStore.loading).toBe(false); + }); + + it('should handle SDK errors correctly', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + act(() => { + result.current.actions.sdkError(); + }); + + const sdkStore = mockStore.getState().stores.sdkStore; + expect(sdkStore.error).toBe(true); + expect(sdkStore.loading).toBe(false); + expect(sdkStore.initialized).toBe(false); + }); + }); + + describe('User actions', () => { + it('should update user state when initUser is called', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + const mockUser = { id: 'mockUserId', name: 'mockUserName' }; + act(() => { + result.current.actions.initUser(mockUser); + }); + + const userStore = mockStore.getState().stores.userStore; + expect(userStore.user).toEqual(mockUser); + expect(userStore.initialized).toBe(true); + }); + + it('should reset user state when resetUser is called', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + const mockUser = { id: 'mockUserId', name: 'mockUserName' }; + act(() => { + result.current.actions.initUser(mockUser); + }); + + act(() => { + result.current.actions.resetUser(); + }); + + const userStore = mockStore.getState().stores.userStore; + expect(userStore.user).toBeNull(); + expect(userStore.initialized).toBe(false); + }); + + it('should update user info when updateUserInfo is called', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + const initialUser = { id: 'mockUserId', name: 'oldName' }; + const updatedUser = { id: 'mockUserId', name: 'newName' }; + + act(() => { + result.current.actions.initUser(initialUser); + }); + + act(() => { + result.current.actions.updateUserInfo(updatedUser); + }); + + const userStore = mockStore.getState().stores.userStore; + expect(userStore.user).toEqual(updatedUser); + }); + }); + + describe('AppInfo actions', () => { + it('should initialize message templates info with initMessageTemplateInfo', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + const mockPayload = { templatesMap: { key1: 'template1', key2: 'template2' } }; + + act(() => { + result.current.actions.initMessageTemplateInfo({ payload: mockPayload }); + }); + + const appInfoStore = mockStore.getState().stores.appInfoStore; + expect(appInfoStore.messageTemplatesInfo).toEqual(mockPayload); + expect(appInfoStore.waitingTemplateKeysMap).toEqual({}); + }); + + it('should upsert message templates with upsertMessageTemplates', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + act(() => { + mockStore.setState((state) => ({ + ...state, + stores: { + ...state.stores, + appInfoStore: { + ...state.stores.appInfoStore, + messageTemplatesInfo: { templatesMap: {} }, + waitingTemplateKeysMap: { key1: {}, key2: {} }, + }, + }, + })); + }); + + act(() => { + result.current.actions.upsertMessageTemplates({ + payload: [ + { key: 'key1', template: 'templateContent1' }, + { key: 'key2', template: 'templateContent2' }, + ], + }); + }); + + const appInfoStore = mockStore.getState().stores.appInfoStore; + expect(appInfoStore.messageTemplatesInfo.templatesMap).toEqual({ + key1: 'templateContent1', + key2: 'templateContent2', + }); + expect(appInfoStore.waitingTemplateKeysMap).toEqual({}); + }); + + it('should upsert waiting template keys with upsertWaitingTemplateKeys', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + const mockPayload = { + keys: ['key1', 'key2'], + requestedAt: Date.now(), + }; + + act(() => { + result.current.actions.upsertWaitingTemplateKeys({ payload: mockPayload }); + }); + + const appInfoStore = mockStore.getState().stores.appInfoStore; + expect(appInfoStore.waitingTemplateKeysMap.key1).toEqual({ + erroredMessageIds: [], + requestedAt: mockPayload.requestedAt, + }); + expect(appInfoStore.waitingTemplateKeysMap.key2).toEqual({ + erroredMessageIds: [], + requestedAt: mockPayload.requestedAt, + }); + }); + + it('should mark error waiting template keys with markErrorWaitingTemplateKeys', () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + act(() => { + mockStore.setState((state) => ({ + ...state, + stores: { + ...state.stores, + appInfoStore: { + ...state.stores.appInfoStore, + waitingTemplateKeysMap: { + key1: { erroredMessageIds: [] }, + key2: { erroredMessageIds: ['existingErrorId'] }, + }, + }, + }, + })); + }); + + act(() => { + result.current.actions.markErrorWaitingTemplateKeys({ + payload: { + keys: ['key1', 'key2'], + messageId: 'newErrorId', + }, + }); + }); + + const appInfoStore = mockStore.getState().stores.appInfoStore; + expect(appInfoStore.waitingTemplateKeysMap.key1.erroredMessageIds).toContain('newErrorId'); + expect(appInfoStore.waitingTemplateKeysMap.key2.erroredMessageIds).toContain('newErrorId'); + expect(appInfoStore.waitingTemplateKeysMap.key2.erroredMessageIds).toContain('existingErrorId'); + }); + + }); + + describe('Connection actions', () => { + it('should connect and initialize SDK correctly', async () => { + const mockStore = createSendbirdContextStore(); + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useSendbird(), { wrapper }); + + const mockActions = result.current.actions; + + await act(async () => { + await mockActions.connect({ + logger: mockLogger, + userId: 'mockUserId', + appId: 'mockAppId', + accessToken: 'mockAccessToken', + nickname: 'mockNickname', + profileUrl: 'mockProfileUrl', + isMobile: false, + sdkInitParams: {}, + customApiHost: '', + customWebSocketHost: '', + customExtensionParams: {}, + eventHandlers: { + connection: { + onConnected: jest.fn(), + onFailed: jest.fn(), + }, + }, + initializeMessageTemplatesInfo: jest.fn(), + initDashboardConfigs: jest.fn(), + configureSession: jest.fn(), + }); + }); + + const sdkStore = mockStore.getState().stores.sdkStore; + const userStore = mockStore.getState().stores.userStore; + + expect(sdkStore.initialized).toBe(true); + expect(sdkStore.sdk).toBeDefined(); + expect(userStore.user).toEqual({ userId: 'mockUserId' }); + }); + + it('should disconnect and reset SDK correctly', async () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + act(() => { + result.current.actions.initSdk('mockSdk'); + }); + + await act(async () => { + await result.current.actions.disconnect({ logger: mockLogger }); + }); + + const sdkStore = mockStore.getState().stores.sdkStore; + const userStore = mockStore.getState().stores.userStore; + + expect(sdkStore.sdk).toBeNull(); + expect(userStore.user).toBeNull(); + }); + + it('should trigger onConnected event handler after successful connection', async () => { + const mockOnConnected = jest.fn(); + const { result } = renderHook(() => useSendbird(), { wrapper }); + + await act(async () => { + await result.current.actions.connect({ + logger: mockLogger, + userId: 'mockUserId', + appId: 'mockAppId', + accessToken: 'mockAccessToken', + eventHandlers: { + connection: { + onConnected: mockOnConnected, + }, + }, + }); + }); + + expect(mockOnConnected).toHaveBeenCalledWith({ userId: 'mockUserId' }); + }); + + it('should call initSDK and setupSDK with correct parameters during connect', async () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + const mockInitSDK = jest.requireMock('../utils').initSDK; + const mockSetupSDK = jest.requireMock('../utils').setupSDK; + + await act(async () => { + await result.current.actions.connect({ + logger: mockLogger, + userId: 'mockUserId', + appId: 'mockAppId', + accessToken: 'mockAccessToken', + sdkInitParams: {}, + }); + }); + + expect(mockInitSDK).toHaveBeenCalledWith({ + appId: 'mockAppId', + customApiHost: undefined, + customWebSocketHost: undefined, + sdkInitParams: {}, + }); + + expect(mockSetupSDK).toHaveBeenCalled(); + }); + + it('should handle connection failure and trigger onFailed event handler', async () => { + const { result } = renderHook(() => useSendbird(), { wrapper }); + + const mockOnFailed = jest.fn(); + const mockLogger = { error: jest.fn(), info: jest.fn() }; + + const mockSdk = { + connect: jest.fn(() => { + throw new Error('Mock connection error'); + }), + }; + jest.requireMock('../utils').initSDK.mockReturnValue(mockSdk); + + await act(async () => { + await result.current.actions.connect({ + logger: mockLogger, + userId: 'mockUserId', + appId: 'mockAppId', + accessToken: 'mockAccessToken', + eventHandlers: { + connection: { + onFailed: mockOnFailed, + }, + }, + }); + }); + + const sdkStore = mockStore.getState().stores.sdkStore; + const userStore = mockStore.getState().stores.userStore; + + expect(sdkStore.sdk).toBeNull(); + expect(userStore.user).toBeNull(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'SendbirdProvider | useSendbird/connect failed', + expect.any(Error), + ); + + expect(mockOnFailed).toHaveBeenCalledWith(expect.any(Error)); + }); + }); +}); diff --git a/src/lib/Sendbird/__tests__/utils.spec.ts b/src/lib/Sendbird/__tests__/utils.spec.ts new file mode 100644 index 0000000000..1b0ee5ec8d --- /dev/null +++ b/src/lib/Sendbird/__tests__/utils.spec.ts @@ -0,0 +1,154 @@ +import SendbirdChat from '@sendbird/chat'; + +import type { SendbirdState, SdkStore, UserStore, AppInfoStore, SendbirdStateConfig } from '../types'; +import { updateAppInfoStore, updateSdkStore, updateUserStore, initSDK, setupSDK } from '../utils'; + +jest.mock('@sendbird/chat', () => ({ + init: jest.fn(), + GroupChannelModule: jest.fn(), + OpenChannelModule: jest.fn(), + DeviceOsPlatform: { + MOBILE_WEB: 'mobile_web', + WEB: 'web', + }, + SendbirdPlatform: { + JS: 'js', + }, + SendbirdProduct: { + UIKIT_CHAT: 'uikit_chat', + }, +})); + +describe('State Update Functions', () => { + const initialState: SendbirdState = { + config: { + appId: 'testAppId', + } as SendbirdStateConfig, + stores: { + appInfoStore: { + waitingTemplateKeysMap: {}, + messageTemplatesInfo: undefined, + }, + sdkStore: { + error: false, + initialized: false, + loading: false, + sdk: {} as any, + }, + userStore: { + initialized: false, + loading: false, + user: {} as any, + }, + }, + eventHandlers: undefined, + emojiManager: {} as any, + utils: {} as any, + }; + + test('updateAppInfoStore merges payload with existing appInfoStore', () => { + const payload: Partial = { messageTemplatesInfo: { templateKey: 'templateValue' } }; + const updatedState = updateAppInfoStore(initialState, payload); + + expect(updatedState.stores.appInfoStore).toEqual({ + waitingTemplateKeysMap: {}, + messageTemplatesInfo: { templateKey: 'templateValue' }, + }); + }); + + test('updateSdkStore merges payload with existing sdkStore', () => { + const payload: Partial = { initialized: true, error: true }; + const updatedState = updateSdkStore(initialState, payload); + + expect(updatedState.stores.sdkStore).toEqual({ + error: true, + initialized: true, + loading: false, + sdk: {} as any, + }); + }); + + test('updateUserStore merges payload with existing userStore', () => { + const payload: Partial = { initialized: true, loading: true }; + const updatedState = updateUserStore(initialState, payload); + + expect(updatedState.stores.userStore).toEqual({ + initialized: true, + loading: true, + user: {} as any, + }); + }); +}); + +describe('initSDK', () => { + it('initializes SendbirdChat with required parameters', () => { + const params = { appId: 'testAppId' }; + initSDK(params); + + expect(SendbirdChat.init).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 'testAppId', + modules: expect.any(Array), + localCacheEnabled: true, + }), + ); + }); + + it('includes customApiHost and customWebSocketHost if provided', () => { + const params = { + appId: 'testAppId', + customApiHost: 'https://custom.api', + customWebSocketHost: 'wss://custom.websocket', + }; + initSDK(params); + + expect(SendbirdChat.init).toHaveBeenCalledWith( + expect.objectContaining({ + customApiHost: 'https://custom.api', + customWebSocketHost: 'wss://custom.websocket', + }), + ); + }); +}); + +const mockSdk = { + addExtension: jest.fn(), + addSendbirdExtensions: jest.fn(), + setSessionHandler: jest.fn(), +}; +const mockLogger = { + info: jest.fn(), +}; + +describe('setupSDK', () => { + it('sets up SDK with extensions and session handler', () => { + const params = { + logger: mockLogger, + sessionHandler: { onSessionExpired: jest.fn() }, + isMobile: false, + customExtensionParams: { customKey: 'customValue' }, + }; + + setupSDK(mockSdk, params); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'SendbirdProvider | useConnect/setupConnection/setVersion', + expect.any(Object), + ); + expect(mockSdk.addExtension).toHaveBeenCalledWith('sb_uikit', expect.any(String)); + expect(mockSdk.addSendbirdExtensions).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Object), + { customKey: 'customValue' }, + ); + expect(mockSdk.setSessionHandler).toHaveBeenCalledWith(params.sessionHandler); + }); + + it('does not set session handler if not provided', () => { + const params = { logger: mockLogger }; + + setupSDK(mockSdk, params); + + expect(mockSdk.setSessionHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/Sendbird/context/hooks/useSendbird.tsx b/src/lib/Sendbird/context/hooks/useSendbird.tsx index 908242fe32..34844183a9 100644 --- a/src/lib/Sendbird/context/hooks/useSendbird.tsx +++ b/src/lib/Sendbird/context/hooks/useSendbird.tsx @@ -224,7 +224,7 @@ export const useSendbird = () => { actions.resetUser(); logger.info?.('SendbirdProvider | useSendbird/disconnect completed'); }, - }), [store]); + }), [store, state.stores.appInfoStore]); return { state, actions }; }; diff --git a/src/lib/Sendbird/index.tsx b/src/lib/Sendbird/index.tsx index ef8d1fe57a..f071aa83e4 100644 --- a/src/lib/Sendbird/index.tsx +++ b/src/lib/Sendbird/index.tsx @@ -57,7 +57,7 @@ const withSendbirdContext = (OriginalComponent: any, mapStoreToProps: (props: an const ContextAwareComponent = (props) => { const { state, actions } = useSendbird(); const context = { ...state, ...actions }; - if (mapStoreToProps && typeof mapStoreToProps !== 'function') { + if (!mapStoreToProps || typeof mapStoreToProps !== 'function') { // eslint-disable-next-line no-console console.warn('Second parameter to withSendbirdContext must be a pure function'); } From 6f0e194bf68992d2e09e92736816ec8484b40e1a Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Mon, 9 Dec 2024 13:04:50 +0900 Subject: [PATCH 20/29] Add basic test cases for the new `ThreadProvider` (#1274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changelog This PR will add the test cases for the new `ThreadProvider` and its related hooks. #### Before 스크린샷 2024-12-09 오전 11 29 53 #### After 스크린샷 2024-12-09 오전 11 28 39 --------- Co-authored-by: Irene Ryu --- .../__test__/ThreadUI.integration.test.tsx | 392 +++++++++ src/modules/Thread/context/ThreadProvider.tsx | 2 +- .../context/__test__/ThreadProvider.spec.tsx | 182 +++-- .../__test__/useDeleteMessageCallback.spec.ts | 140 ++++ .../context/__test__/useGetAllEmoji.spec.ts | 68 ++ .../context/__test__/useGetChannel.spec.ts | 196 +++++ .../__test__/useGetParentMessage.spec.ts | 220 +++++ .../__test__/useHandleChannelEvents.spec.ts | 169 ++++ .../useHandleThreadPubsubEvents.spec.ts | 167 ++++ .../__test__/useResendMessageCallback.spec.ts | 277 +++++++ .../__test__/useSendFileMessage.spec.ts | 214 +++++ .../useSendUserMessageCallback.spec.ts | 250 ++++++ .../useSendVoiceMessageCallback.spec.ts | 236 ++++++ .../context/__test__/useThread.spec.tsx | 761 ++++++++++++++++++ .../__test__/useThreadFetchers.spec.ts | 161 ++++ .../useToggleReactionsCallback.spec.ts | 148 ++++ .../__test__/useUpdateMessageCallback.spec.ts | 169 ++++ .../context/hooks/useDeleteMessageCallback.ts | 7 +- .../context/hooks/useResendMessageCallback.ts | 189 ++--- .../context/hooks/useSendFileMessage.ts | 63 +- .../context/hooks/useUpdateMessageCallback.ts | 7 +- src/modules/Thread/context/useThread.ts | 3 +- 22 files changed, 3817 insertions(+), 204 deletions(-) create mode 100644 src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx create mode 100644 src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts create mode 100644 src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts create mode 100644 src/modules/Thread/context/__test__/useGetChannel.spec.ts create mode 100644 src/modules/Thread/context/__test__/useGetParentMessage.spec.ts create mode 100644 src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts create mode 100644 src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts create mode 100644 src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts create mode 100644 src/modules/Thread/context/__test__/useSendFileMessage.spec.ts create mode 100644 src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts create mode 100644 src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts create mode 100644 src/modules/Thread/context/__test__/useThread.spec.tsx create mode 100644 src/modules/Thread/context/__test__/useThreadFetchers.spec.ts create mode 100644 src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts create mode 100644 src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts diff --git a/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx new file mode 100644 index 0000000000..0a1142b871 --- /dev/null +++ b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx @@ -0,0 +1,392 @@ +import * as useThreadModule from '../../../context/useThread'; +import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../../types'; +import { EmojiContainer } from '@sendbird/chat'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; +import ThreadUI from '../index'; +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; + +const mockSendUserMessage = jest.fn(); + +const mockChannel = { + url: 'test-channel', + members: [{ userId: 'test-user-id', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async (message) => mockNewMessage(message)), + sendUserMessage: mockSendUserMessage, + isGroupChannel: jest.fn().mockImplementation(() => true), +}; + +const mockNewMessage = (message) => ({ + messageId: 42, + message: message ?? 'new message', +}); + +const mockMessage = { + messageId: 1, + message: 'first message', +}; + +const mockGetMessage = jest.fn().mockResolvedValue(mockMessage); +const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); + +const mockState = { + stores: { + sdkStore: { + sdk: { + getMessage: mockGetMessage, + groupChannel: { + getChannel: mockGetChannel, + }, + }, + initialized: true, + }, + userStore: { user: { userId: 'test-user-id' } }, + }, + config: { + logger: console, + isOnline: true, + pubSub: { + publish: jest.fn(), + }, + groupChannel: { + enableMention: true, + enableReactions: true, + replyType: 'THREAD', + }, + }, +}; + +jest.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ state: mockState })), + useSendbird: jest.fn(() => ({ state: mockState })), +})); + +jest.mock('../../../context/useThread'); + +const mockStringSet = { + DATE_FORMAT__MESSAGE_CREATED_AT: 'p', +}; + +const mockLocalizationContext = { + stringSet: mockStringSet, +}; + +const defaultMockState = { + channelUrl: '', + message: null, + onHeaderActionClick: undefined, + onMoveToParentMessage: undefined, + onBeforeSendUserMessage: undefined, + onBeforeSendFileMessage: undefined, + onBeforeSendVoiceMessage: undefined, + onBeforeSendMultipleFilesMessage: undefined, + onBeforeDownloadFileMessage: undefined, + isMultipleFilesMessageEnabled: undefined, + filterEmojiCategoryIds: undefined, + currentChannel: undefined, + allThreadMessages: [ + { + messageId: 2, + message: 'threaded message 1', + isUserMessage: () => true, + }, + ], + localThreadMessages: [], + parentMessage: null, + channelState: ChannelStateTypes.INITIALIZED, + parentMessageState: ParentMessageStateTypes.INITIALIZED, + threadListState: ThreadListStateTypes.INITIALIZED, + hasMorePrev: false, + hasMoreNext: false, + emojiContainer: {} as EmojiContainer, + isMuted: false, + isChannelFrozen: false, + currentUserId: '', + typingMembers: [], + nicknamesMap: null, +}; + +const defaultMockActions = { + fetchPrevThreads: jest.fn((callback) => { + callback(); + }), + fetchNextThreads: jest.fn((callback) => { + callback(); + }), +}; + +describe('CreateChannelUI Integration Tests', () => { + const mockUseThread = useThreadModule.default as jest.Mock; + + const renderComponent = (mockState = {}, mockActions = {}) => { + mockUseThread.mockReturnValue({ + state: { ...defaultMockState, ...mockState }, + actions: { ...defaultMockActions, ...mockActions }, + }); + + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('display initial state correctly', async () => { + await act(async () => { + renderComponent( + { + parentMessage: { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }, + }, + ); + }); + + expect(screen.getByText('parent message')).toBeInTheDocument(); + expect(screen.getByText('threaded message 1')).toBeInTheDocument(); + }); + + it('fetchPrevThread is correctly called when scroll is top', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + getThreadedMessagesByTimestamp: () => ({ + parentMessage, + threadedMessages: [ + { messageId: 3, message: 'threaded message -1', isUserMessage: () => true }, + { messageId: 4, message: 'threaded message 0', isUserMessage: () => true }, + ], + }), + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + hasMorePrev: true, + }, + ); + + container = result.container; + }); + + const scrollContainer = container.getElementsByClassName('sendbird-thread-ui--scroll')[0]; + fireEvent.scroll(scrollContainer, { target: { scrollY: -1 } }); + + await waitFor(() => { + expect(defaultMockActions.fetchPrevThreads).toBeCalledTimes(1); + }); + }); + + it('fetchNextThreads is correctly called when scroll is bottom', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + getThreadedMessagesByTimestamp: () => ({ + parentMessage, + threadedMessages: [ + { messageId: 3, message: 'threaded message -1', isUserMessage: () => true }, + { messageId: 4, message: 'threaded message 0', isUserMessage: () => true }, + ], + }), + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + hasMoreNext: true, + }, + ); + + container = result.container; + }); + + const scrollContainer = container.getElementsByClassName('sendbird-thread-ui--scroll')[0]; + fireEvent.scroll(scrollContainer, { target: { scrollY: scrollContainer.scrollHeight + 1 } }); + + await waitFor(() => { + expect(defaultMockActions.fetchNextThreads).toBeCalledTimes(1); + }); + }); + + it('show proper placeholder when ParentMessageStateTypes is NIL', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + parentMessageState: ParentMessageStateTypes.NIL, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-nil')[0]; + expect(placeholder).not.toBe(undefined); + }); + + }); + + it('show proper placeholder when ParentMessageStateTypes is LOADING', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + parentMessageState: ParentMessageStateTypes.LOADING, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-loading')[0]; + expect(placeholder).not.toBe(undefined); + }); + + }); + + it('show proper placeholder when ParentMessageStateTypes is INVALID', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + parentMessageState: ParentMessageStateTypes.INVALID, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-invalid')[0]; + expect(placeholder).not.toBe(undefined); + }); + }); + + it('show proper placeholder when ThreadListState is LOADING', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + threadListState: ThreadListStateTypes.LOADING, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-loading')[0]; + expect(placeholder).not.toBe(undefined); + }); + }); + + it('show proper placeholder when ThreadListState is INVALID', async () => { + let container; + const parentMessage = { + messageId: 1, + message: 'parent message', + isUserMessage: () => true, + isTextMessage: true, + createdAt: 0, + sender: { + userId: 'test-user-id', + }, + }; + + await act(async () => { + const result = renderComponent( + { + parentMessage, + threadListState: ThreadListStateTypes.INVALID, + }, + ); + + container = result.container; + }); + + await waitFor(() => { + const placeholder = container.getElementsByClassName('placeholder-invalid')[0]; + expect(placeholder).not.toBe(undefined); + }); + }); + +}); diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx index bef0ad80bc..8d3b0c9b11 100644 --- a/src/modules/Thread/context/ThreadProvider.tsx +++ b/src/modules/Thread/context/ThreadProvider.tsx @@ -70,7 +70,7 @@ export interface ThreadState { nicknamesMap: Map; } -const initialState = { +const initialState: ThreadState = { channelUrl: '', message: null, onHeaderActionClick: undefined, diff --git a/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx index 43b0d0d66f..e197dcdbf8 100644 --- a/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx +++ b/src/modules/Thread/context/__test__/ThreadProvider.spec.tsx @@ -1,21 +1,68 @@ import React from 'react'; import { act, waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; -import { ThreadProvider } from '../ThreadProvider'; +import { ThreadProvider, ThreadState } from '../ThreadProvider'; import useThread from '../useThread'; import { SendableMessageType } from '../../../../utils'; import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; +import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../types'; +import { EmojiContainer } from '@sendbird/chat'; + +class MockMessageMethod { + _onPending: (message: SendableMessageType) => void; + + _onFailed: (message: SendableMessageType) => void; + + _onSucceeded: (message: SendableMessageType) => void; + + constructor(message, willSucceed = true) { + this._onPending = undefined; + this._onFailed = undefined; + this._onSucceeded = undefined; + + this.init(message, willSucceed); + } + + init(message, willSucceed) { + setTimeout(() => this._onPending?.(message), 0); + setTimeout(() => { + if (willSucceed) { + this._onSucceeded?.(message); + } else { + this._onFailed?.(message); + } + }, 300); + } + + onPending(func) { + this._onPending = func; + return this; + } + + onFailed(func) { + this._onFailed = func; + return this; + } + + onSucceeded(func) { + this._onSucceeded = func; + return this; + } +} + +const mockSendUserMessage = jest.fn(); const mockChannel = { url: 'test-channel', members: [{ userId: '1', nickname: 'user1' }], - updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + updateUserMessage: jest.fn().mockImplementation(async (message) => mockNewMessage(message)), + sendUserMessage: mockSendUserMessage, }; -const mockNewMessage = { +const mockNewMessage = (message) => ({ messageId: 42, - message: 'new message', -}; + message: message ?? 'new message', +}); const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); @@ -48,26 +95,54 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ })); describe('ThreadProvider', () => { + const initialState: ThreadState = { + channelUrl: '', + message: null, + onHeaderActionClick: undefined, + onMoveToParentMessage: undefined, + onBeforeSendUserMessage: undefined, + onBeforeSendFileMessage: undefined, + onBeforeSendVoiceMessage: undefined, + onBeforeSendMultipleFilesMessage: undefined, + onBeforeDownloadFileMessage: undefined, + isMultipleFilesMessageEnabled: undefined, + filterEmojiCategoryIds: undefined, + currentChannel: null, + allThreadMessages: [], + localThreadMessages: [], + parentMessage: null, + channelState: ChannelStateTypes.NIL, + parentMessageState: ParentMessageStateTypes.NIL, + threadListState: ThreadListStateTypes.NIL, + hasMorePrev: false, + hasMoreNext: false, + emojiContainer: {} as EmojiContainer, + isMuted: false, + isChannelFrozen: false, + currentUserId: '', + typingMembers: [], + nicknamesMap: null, + }; + const initialMockMessage = { messageId: 1, } as SendableMessageType; beforeEach(() => { + jest.clearAllMocks(); const stateContextValue = { state: mockState }; - useSendbird.mockReturnValue(stateContextValue); + (useSendbird as jest.Mock).mockReturnValue(stateContextValue); renderHook(() => useSendbird()); }); it('provides the correct initial state', async () => { const wrapper = ({ children }) => ( - {children} + {children} ); await act(async () => { const { result } = renderHook(() => useThread(), { wrapper }); - await waitFor(() => { - expect(result.current.state.message).toBe(initialMockMessage); - }); + expect(result.current.state).toEqual(initialState); }); }); @@ -154,70 +229,25 @@ describe('ThreadProvider', () => { }); }); - // it('calls sendMessage correctly', async () => { - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // const { result } = renderHook(() => useThreadContext(), { wrapper }); - // const sendMessageMock = jest.fn(); - // - // result.current.sendMessage({ message: 'Test Message' }); - // - // expect(sendMessageMock).toHaveBeenCalledWith({ message: 'Test Message' }); - // }); - // - // it('handles channel events correctly', () => { - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // render(); - // // Add assertions for handling channel events - // }); - // - // it('updates state when nicknamesMap is updated', async () => { - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // const { result } = renderHook(() => useThreadContext(), { wrapper }); - // - // await act(async () => { - // result.current.updateState({ - // nicknamesMap: new Map([['user1', 'User One'], ['user2', 'User Two']]), - // }); - // await waitFor(() => { - // expect(result.current.nicknamesMap.get('user1')).toBe('User One'); - // }); - // }); - // }); - // - // it('calls onMoveToParentMessage when provided', async () => { - // const onMoveToParentMessageMock = jest.fn(); - // const wrapper = ({ children }) => ( - // - // {children} - // - // ); - // - // const { result } = renderHook(() => useThreadContext(), { wrapper }); - // - // await act(async () => { - // result.current.onMoveToParentMessage({ message: { messageId: 1 }, channel: {} }); - // await waitFor(() => { - // expect(onMoveToParentMessageMock).toHaveBeenCalled(); - // }); - // }); - // }); + it('update state correctly when sendMessage is called', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + + mockSendUserMessage.mockImplementation((propsMessage) => new MockMessageMethod(mockNewMessage(propsMessage), true)); + result.current.actions.sendMessage({ message: 'Test Message' }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages.at(-1)).toHaveProperty('messageId', 42); + }); + }); + }); diff --git a/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts new file mode 100644 index 0000000000..709db64151 --- /dev/null +++ b/src/modules/Thread/context/__test__/useDeleteMessageCallback.spec.ts @@ -0,0 +1,140 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; + +import useDeleteMessageCallback from '../hooks/useDeleteMessageCallback'; +import { SendableMessageType } from '../../../../utils'; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; +const mockOnMessageDeletedByReqId = jest.fn(); +const mockOnMessageDeleted = jest.fn(); +const mockDeleteMessage = jest.fn(); + +describe('useDeleteMessageCallback', () => { + const mockChannel = { + deleteMessage: mockDeleteMessage, + } as unknown as GroupChannel; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delete failed message from local', async () => { + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const failedMessage = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'failed', + }; + + await result.current(failedMessage as SendableMessageType); + + expect(mockOnMessageDeletedByReqId).toHaveBeenCalledWith('test-req-id'); + expect(mockDeleteMessage).toHaveBeenCalled(); + }); + + it('delete pending message from local', async () => { + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const pendingMessage = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'pending', + }; + + await result.current(pendingMessage as SendableMessageType); + + expect(mockOnMessageDeletedByReqId).toHaveBeenCalledWith('test-req-id'); + expect(mockDeleteMessage).toHaveBeenCalled(); + }); + + it('delete success message from remote', async () => { + mockDeleteMessage.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const successMessage = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'succeeded', + }; + + await result.current(successMessage as SendableMessageType); + + expect(mockDeleteMessage).toHaveBeenCalledWith(successMessage); + expect(mockOnMessageDeleted).toHaveBeenCalledWith(mockChannel, 123); + }); + + it('delete failed message from remote', async () => { + const errorMessage = 'Failed to delete message'; + mockDeleteMessage.mockRejectedValueOnce(new Error(errorMessage)); + + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: mockChannel, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const message = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'succeeded', + }; + + await expect(result.current(message as SendableMessageType)).rejects.toThrow(errorMessage); + expect(mockLogger.warning).toHaveBeenCalled(); + }); + + it('currentChannel is null', async () => { + const { result } = renderHook(() => useDeleteMessageCallback( + { + currentChannel: null, + onMessageDeletedByReqId: mockOnMessageDeletedByReqId, + onMessageDeleted: mockOnMessageDeleted, + }, + { logger: mockLogger }, + ), + ); + + const message = { + messageId: 123, + reqId: 'test-req-id', + sendingStatus: 'succeeded', + }; + + await result.current(message as SendableMessageType); + expect(mockDeleteMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts b/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts new file mode 100644 index 0000000000..cb3db95533 --- /dev/null +++ b/src/modules/Thread/context/__test__/useGetAllEmoji.spec.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useGetAllEmoji from '../hooks/useGetAllEmoji'; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockSetEmojiContainer = jest.fn(); +const mockLogger = { + info: jest.fn(), + error: jest.fn(), + warning: jest.fn(), +}; + +describe('useGetAllEmoji', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('doesnt call getAllEmoji when sdk is null', () => { + renderHook(() => useGetAllEmoji( + { sdk: null }, + { logger: mockLogger }, + )); + + expect(mockSetEmojiContainer).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('doesnt call getAllEmoji when sdk.getAllEmoji is undefined', () => { + renderHook(() => useGetAllEmoji( + { sdk: {} }, + { logger: mockLogger }, + )); + + expect(mockSetEmojiContainer).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it('gets emoji container successfully', async () => { + const mockEmojiContainer = { + emojis: ['😀', '🤣', '🥰'], + }; + const mockGetAllEmoji = jest.fn().mockResolvedValue(mockEmojiContainer); + const mockSdk = { + getAllEmoji: mockGetAllEmoji, + }; + + renderHook(() => useGetAllEmoji( + { sdk: mockSdk }, + { logger: mockLogger }, + )); + + await new Promise(process.nextTick); + + expect(mockGetAllEmoji).toHaveBeenCalled(); + expect(mockSetEmojiContainer).toHaveBeenCalledWith(mockEmojiContainer); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useGetAllEmoji: Getting emojis succeeded.', + mockEmojiContainer, + ); + }); +}); diff --git a/src/modules/Thread/context/__test__/useGetChannel.spec.ts b/src/modules/Thread/context/__test__/useGetChannel.spec.ts new file mode 100644 index 0000000000..0096bd9b9e --- /dev/null +++ b/src/modules/Thread/context/__test__/useGetChannel.spec.ts @@ -0,0 +1,196 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import useGetChannel from '../hooks/useGetChannel'; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + getChannelStart: mockGetChannelStart, + getChannelSuccess: mockGetChannelSuccess, + getChannelFailure: mockGetChannelFailure, + }, + }), +})); + +const mockGetChannelStart = jest.fn(); +const mockGetChannelSuccess = jest.fn(); +const mockGetChannelFailure = jest.fn(); +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useGetChannel', () => { + const mockGroupChannel = {} as GroupChannel; + const mockGetChannel = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('doesnt call getChannel when sdkInit is false', () => { + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: false, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetChannelStart).not.toHaveBeenCalled(); + expect(mockGetChannel).not.toHaveBeenCalled(); + }); + + it('doesnt call getChannel when channelUrl is empty', () => { + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: '', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetChannelStart).not.toHaveBeenCalled(); + expect(mockGetChannel).not.toHaveBeenCalled(); + }); + + it('doesnt call getChannel when sdk.groupChannel is undefined', () => { + const sdk = {}; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetChannelStart).not.toHaveBeenCalled(); + expect(mockGetChannel).not.toHaveBeenCalled(); + }); + + it('gets channel successfully', async () => { + mockGetChannel.mockResolvedValueOnce(mockGroupChannel); + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetChannelStart).toHaveBeenCalled(); + expect(mockGetChannel).toHaveBeenCalledWith('test-channel-url'); + expect(mockGetChannelSuccess).toHaveBeenCalledWith(mockGroupChannel); + expect(mockGetChannelFailure).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useInitialize: Get channel succeeded', + mockGroupChannel, + ); + }); + + it('handles error when getting channel fails', async () => { + const mockError = new Error('Failed to get channel'); + mockGetChannel.mockRejectedValueOnce(mockError); + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + renderHook(() => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit: true, + message: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetChannelStart).toHaveBeenCalled(); + expect(mockGetChannel).toHaveBeenCalledWith('test-channel-url'); + expect(mockGetChannelSuccess).not.toHaveBeenCalled(); + expect(mockGetChannelFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useInitialize: Get channel failed', + mockError, + ); + }); + + it('calls getChannel again when message or sdkInit changes', async () => { + mockGetChannel.mockResolvedValue(mockGroupChannel); + const sdk = { + groupChannel: { + getChannel: mockGetChannel, + }, + }; + + const { rerender } = renderHook( + ({ message, sdkInit }) => useGetChannel( + { + channelUrl: 'test-channel-url', + sdkInit, + message, + }, + { + sdk, + logger: mockLogger, + }, + ), + { + initialProps: { message: null, sdkInit: false }, + }, + ); + + expect(mockGetChannel).not.toHaveBeenCalled(); + rerender({ message: null, sdkInit: true }); + + await new Promise(process.nextTick); + + expect(mockGetChannel).toHaveBeenCalledTimes(1); + expect(mockGetChannelSuccess).toHaveBeenCalledWith(mockGroupChannel); + }); +}); diff --git a/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts b/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts new file mode 100644 index 0000000000..0348a7dd80 --- /dev/null +++ b/src/modules/Thread/context/__test__/useGetParentMessage.spec.ts @@ -0,0 +1,220 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { BaseMessage } from '@sendbird/chat/message'; +import { ChannelType } from '@sendbird/chat'; +import useGetParentMessage from '../hooks/useGetParentMessage'; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + getParentMessageStart: mockGetParentMessageStart, + getParentMessageSuccess: mockGetParentMessageSuccess, + getParentMessageFailure: mockGetParentMessageFailure, + }, + }), +})); + +const mockGetParentMessageStart = jest.fn(); +const mockGetParentMessageSuccess = jest.fn(); +const mockGetParentMessageFailure = jest.fn(); +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useGetParentMessage', () => { + const mockGetMessage = jest.fn(); + const mockParentMessage = { + messageId: 12345, + ogMetaData: { title: 'Test OG' }, + } as unknown as BaseMessage; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('doesnt call getParentMessage when sdkInit is false', () => { + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: false, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetParentMessageStart).not.toHaveBeenCalled(); + expect(mockGetMessage).not.toHaveBeenCalled(); + }); + + it('doesnt call getParentMessage when sdk.message.getMessage is undefined', () => { + const sdk = { + message: {}, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetParentMessageStart).not.toHaveBeenCalled(); + expect(mockGetMessage).not.toHaveBeenCalled(); + }); + + it('doesnt call getParentMessage when parentMessage is null', () => { + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: null, + }, + { + sdk, + logger: mockLogger, + }, + )); + + expect(mockGetParentMessageStart).not.toHaveBeenCalled(); + expect(mockGetMessage).not.toHaveBeenCalled(); + }); + + it('gets parent message successfully', async () => { + const receivedParentMsg = { ...mockParentMessage, ogMetaData: null }; + mockGetMessage.mockResolvedValueOnce(receivedParentMsg); + + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetParentMessageStart).toHaveBeenCalled(); + expect(mockGetMessage).toHaveBeenCalledWith({ + channelUrl: 'test-channel-url', + channelType: ChannelType.GROUP, + messageId: mockParentMessage.messageId, + includeMetaArray: true, + includeReactions: true, + includeThreadInfo: true, + includeParentMessageInfo: true, + }); + expect(mockGetParentMessageSuccess).toHaveBeenCalledWith({ + ...receivedParentMsg, + ogMetaData: mockParentMessage.ogMetaData, + }); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useGetParentMessage: Get parent message succeeded.', + mockParentMessage, + ); + }); + + it('handles error when getting parent message fails', async () => { + const mockError = new Error('Failed to get parent message'); + mockGetMessage.mockRejectedValueOnce(mockError); + + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + renderHook(() => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit: true, + parentMessage: mockParentMessage, + }, + { + sdk, + logger: mockLogger, + }, + )); + + await new Promise(process.nextTick); + + expect(mockGetParentMessageStart).toHaveBeenCalled(); + expect(mockGetMessage).toHaveBeenCalled(); + expect(mockGetParentMessageSuccess).not.toHaveBeenCalled(); + expect(mockGetParentMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useGetParentMessage: Get parent message failed.', + mockError, + ); + }); + + it('calls getParentMessage again when sdkInit or parentMessage.messageId changes', async () => { + mockGetMessage.mockResolvedValue({ ...mockParentMessage, ogMetaData: null }); + + const sdk = { + message: { + getMessage: mockGetMessage, + }, + }; + + const { rerender } = renderHook( + ({ sdkInit, parentMessage }) => useGetParentMessage( + { + channelUrl: 'test-channel-url', + sdkInit, + parentMessage, + }, + { + sdk, + logger: mockLogger, + }, + ), + { + initialProps: { sdkInit: false, parentMessage: null }, + }, + ); + + expect(mockGetMessage).not.toHaveBeenCalled(); + + rerender({ + sdkInit: true, + parentMessage: mockParentMessage, + }); + + await new Promise(process.nextTick); + + expect(mockGetMessage).toHaveBeenCalledTimes(1); + expect(mockGetParentMessageSuccess).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts b/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts new file mode 100644 index 0000000000..340d55cd94 --- /dev/null +++ b/src/modules/Thread/context/__test__/useHandleChannelEvents.spec.ts @@ -0,0 +1,169 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { GroupChannel, GroupChannelHandler } from '@sendbird/chat/groupChannel'; +import { UserMessage } from '@sendbird/chat/message'; +import { User } from '@sendbird/chat'; +import useHandleChannelEvents from '../hooks/useHandleChannelEvents'; +import { waitFor } from '@testing-library/react'; + +const mockThreadActions = { + onMessageReceived: jest.fn(), + onMessageUpdated: jest.fn(), + onMessageDeleted: jest.fn(), + onReactionUpdated: jest.fn(), + onUserMuted: jest.fn(), + onUserUnmuted: jest.fn(), + onUserBanned: jest.fn(), + onUserUnbanned: jest.fn(), + onUserLeft: jest.fn(), + onChannelFrozen: jest.fn(), + onChannelUnfrozen: jest.fn(), + onOperatorUpdated: jest.fn(), + onTypingStatusUpdated: jest.fn(), +}; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: mockThreadActions, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useHandleChannelEvents', () => { + const mockUser = { userId: 'user1' } as User; + const mockMessage = { messageId: 1 } as UserMessage; + const mockReactionEvent = { messageId: 1, key: 'like' }; + + const createMockChannel = () => ({ + url: 'channel-url', + getTypingUsers: jest.fn().mockReturnValue([mockUser]), + }) as unknown as GroupChannel; + + const createMockSdk = (addHandler = jest.fn(), removeHandler = jest.fn()) => ({ + groupChannel: { + addGroupChannelHandler: addHandler, + removeGroupChannelHandler: removeHandler, + }, + }); + + const renderChannelEventsHook = ({ + sdk = createMockSdk(), + currentChannel = createMockChannel(), + } = {}) => { + return renderHook(() => useHandleChannelEvents( + { + sdk, + currentChannel, + }, + { + logger: mockLogger, + }, + )); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should add channel handler on mount', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + + renderChannelEventsHook({ sdk }); + + expect(mockAddHandler).toHaveBeenCalledWith( + expect.any(String), + expect.any(GroupChannelHandler), + ); + }); + + it('should remove channel handler on unmount', () => { + const mockRemoveHandler = jest.fn(); + const sdk = createMockSdk(jest.fn(), mockRemoveHandler); + + const { unmount } = renderChannelEventsHook({ sdk }); + unmount(); + + expect(mockRemoveHandler).toHaveBeenCalledWith(expect.any(String)); + }); + + it('should handle message received event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onMessageReceived(channel, mockMessage); + + expect(mockThreadActions.onMessageReceived).toHaveBeenCalledWith( + channel, + mockMessage, + ); + }); + + it('should handle message updated event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onMessageUpdated(channel, mockMessage); + + expect(mockThreadActions.onMessageUpdated).toHaveBeenCalledWith( + channel, + mockMessage, + ); + }); + + it('should handle reaction updated event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onReactionUpdated(channel, mockReactionEvent); + + expect(mockThreadActions.onReactionUpdated).toHaveBeenCalledWith( + mockReactionEvent, + ); + }); + + it('should handle typing status updated event', () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + const channel = createMockChannel(); + + renderChannelEventsHook({ sdk, currentChannel: channel }); + + const handler = mockAddHandler.mock.calls[0][1]; + handler.onTypingStatusUpdated(channel); + + expect(mockThreadActions.onTypingStatusUpdated).toHaveBeenCalledWith( + channel, + [mockUser], + ); + }); + + it('should not add handler when sdk or currentChannel is missing', async () => { + const mockAddHandler = jest.fn(); + const sdk = createMockSdk(mockAddHandler); + + await act(async () => { + renderChannelEventsHook({ sdk, currentChannel: undefined }); + await waitFor(() => { + expect(mockAddHandler).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts b/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts new file mode 100644 index 0000000000..2e96b96bd5 --- /dev/null +++ b/src/modules/Thread/context/__test__/useHandleThreadPubsubEvents.spec.ts @@ -0,0 +1,167 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useHandleThreadPubsubEvents from '../hooks/useHandleThreadPubsubEvents'; +import { PUBSUB_TOPICS, SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { SendableMessageType } from '../../../../utils'; + +const mockThreadActions = { + sendMessageStart: jest.fn(), + sendMessageSuccess: jest.fn(), + sendMessageFailure: jest.fn(), + onFileInfoUpdated: jest.fn(), + onMessageUpdated: jest.fn(), + onMessageDeleted: jest.fn(), +}; + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: mockThreadActions, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockPubSub = { + subscribe: jest.fn(), + unsubscribe: jest.fn(), +} as unknown as SBUGlobalPubSub; + +describe('useHandleThreadPubsubEvents', () => { + const mockChannel = { url: 'channel-url' } as GroupChannel; + const mockParentMessage = { messageId: 123 } as SendableMessageType; + const mockMessage = { + parentMessageId: 123, + messageId: 456, + isMultipleFilesMessage: () => false, + } as SendableMessageType; + + const renderPubsubEventsHook = ({ + sdkInit = true, + currentChannel = mockChannel, + parentMessage = mockParentMessage, + } = {}) => { + return renderHook(() => useHandleThreadPubsubEvents( + { + sdkInit, + currentChannel, + parentMessage, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should subscribe to pubsub events on mount', () => { + renderPubsubEventsHook(); + + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_MESSAGE_START, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_USER_MESSAGE, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_MESSAGE_FAILED, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.SEND_FILE_MESSAGE, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.UPDATE_USER_MESSAGE, + expect.any(Function), + ); + expect(mockPubSub.subscribe).toHaveBeenCalledWith( + PUBSUB_TOPICS.DELETE_MESSAGE, + expect.any(Function), + ); + }); + + it('should unsubscribe from pubsub events on unmount', () => { + const { unmount } = renderPubsubEventsHook(); + unmount(); + + expect(mockPubSub.subscribe).toHaveBeenCalledTimes(7); + }); + + it('should handle SEND_MESSAGE_START event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_MESSAGE_START)[1]; + handler({ channel: mockChannel, message: mockMessage, publishingModules: [] }); + + expect(mockThreadActions.sendMessageStart).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle ON_FILE_INFO_UPLOADED event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.ON_FILE_INFO_UPLOADED)[1]; + handler({ response: { channelUrl: mockChannel.url }, publishingModules: [] }); + + expect(mockThreadActions.onFileInfoUpdated).toHaveBeenCalled(); + }); + + it('should handle SEND_USER_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_USER_MESSAGE)[1]; + handler({ channel: mockChannel, message: mockMessage }); + + expect(mockThreadActions.sendMessageSuccess).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle SEND_MESSAGE_FAILED event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_MESSAGE_FAILED)[1]; + handler({ channel: mockChannel, message: mockMessage, publishingModules: [] }); + + expect(mockThreadActions.sendMessageFailure).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle SEND_FILE_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.SEND_FILE_MESSAGE)[1]; + handler({ channel: mockChannel, message: mockMessage, publishingModules: [] }); + + expect(mockThreadActions.sendMessageSuccess).toHaveBeenCalledWith(mockMessage); + }); + + it('should handle UPDATE_USER_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.UPDATE_USER_MESSAGE)[1]; + handler({ channel: mockChannel, message: mockMessage }); + + expect(mockThreadActions.onMessageUpdated).toHaveBeenCalledWith(mockChannel, mockMessage); + }); + + it('should handle DELETE_MESSAGE event', () => { + renderPubsubEventsHook(); + + const handler = mockPubSub.subscribe.mock.calls.find(call => call[0] === PUBSUB_TOPICS.DELETE_MESSAGE)[1]; + handler({ channel: mockChannel, messageId: mockMessage.messageId }); + + expect(mockThreadActions.onMessageDeleted).toHaveBeenCalledWith(mockChannel, mockMessage.messageId); + }); +}); diff --git a/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts new file mode 100644 index 0000000000..adf37b84c7 --- /dev/null +++ b/src/modules/Thread/context/__test__/useResendMessageCallback.spec.ts @@ -0,0 +1,277 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { FileMessage, UserMessage, MessageType, SendingStatus, MultipleFilesMessage } from '@sendbird/chat/message'; +import useResendMessageCallback from '../hooks/useResendMessageCallback'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockResendMessageStart = jest.fn(); +const mockSendMessageSuccess = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useResendMessageCallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not resend when message is not resendable', () => { + const mockMessage = { + isResendable: false, + } as unknown as SendableMessageType; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: {} as GroupChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockMessage); + expect(mockResendMessageStart).not.toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Thread | useResendMessageCallback: Message is not resendable.', + mockMessage, + ); + }); + + it('should resend user message successfully', async () => { + const mockUserMessage = { + isResendable: true, + messageType: MessageType.USER, + isUserMessage: () => true, + } as UserMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockUserMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockUserMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockUserMessage); + + expect(mockChannel.resendMessage).toHaveBeenCalledWith(mockUserMessage); + expect(mockResendMessageStart).toHaveBeenCalledWith(mockUserMessage); + expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockUserMessage); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should handle user message resend failure', () => { + const mockError = new Error('Failed to resend message'); + const mockUserMessage = { + isResendable: true, + messageType: MessageType.USER, + isUserMessage: () => true, + sendingStatus: SendingStatus.FAILED, + } as UserMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockUserMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation((cb) => { + cb(mockError); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockUserMessage); + + expect(mockSendMessageFailure).toHaveBeenCalledWith(mockUserMessage); + expect(mockLogger.warning).toHaveBeenCalled(); + }); + + it('should resend file message successfully', () => { + const mockFileMessage = { + isResendable: true, + isFileMessage: () => true, + } as FileMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockFileMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockFileMessage); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFileMessage); + + expect(mockResendMessageStart).toHaveBeenCalledWith(mockFileMessage); + expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockFileMessage); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should resend multiple files message successfully', () => { + const mockMultipleFilesMessage = { + isResendable: true, + isMultipleFilesMessage: () => true, + } as MultipleFilesMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + onFileUploaded: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockMultipleFilesMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockMultipleFilesMessage); + return chainMethods; + }); + + chainMethods.onFileUploaded.mockImplementation((cb) => { + cb('requestId', 0, { url: 'test-url' }, null); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + resendMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useResendMessageCallback( + { + currentChannel: mockChannel, + resendMessageStart: mockResendMessageStart, + sendMessageSuccess: mockSendMessageSuccess, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockMultipleFilesMessage); + + expect(mockResendMessageStart).toHaveBeenCalledWith(mockMultipleFilesMessage); + expect(mockSendMessageSuccess).toHaveBeenCalledWith(mockMultipleFilesMessage); + expect(mockPubSub.publish).toHaveBeenCalledTimes(2); // onFileUploaded and onSucceeded + }); +}); diff --git a/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts b/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts new file mode 100644 index 0000000000..167f6f4034 --- /dev/null +++ b/src/modules/Thread/context/__test__/useSendFileMessage.spec.ts @@ -0,0 +1,214 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { FileMessage, SendingStatus } from '@sendbird/chat/message'; +import useSendFileMessage from '../hooks/useSendFileMessage'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockSendMessageStart = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useSendFileMessage', () => { + const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' }); + const mockQuoteMessage = { + messageId: 12345, + } as unknown as SendableMessageType; + + beforeEach(() => { + jest.clearAllMocks(); + // URL.createObjectURL mock + global.URL.createObjectURL = jest.fn(() => 'mock-url'); + }); + + it('doesnt send file message when currentChannel is null', async () => { + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: null, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + await act(async () => { + await result.current(mockFile); + expect(mockSendMessageStart).not.toHaveBeenCalled(); + }); + }); + + it('sends file message successfully', async () => { + const mockSuccessMessage = { + messageId: 67890, + isUserMessage: false, + isFileMessage: true, + isAdminMessage: false, + isMultipleFilesMessage: false, + } as unknown as FileMessage; + + const mockPendingMessage = { + ...mockSuccessMessage, + sendingStatus: SendingStatus.PENDING, + }; + + // 체이닝 구조 개선 + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockSuccessMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + const response = await result.current(mockFile, mockQuoteMessage); + + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith({ + file: mockFile, + isReplyToChannel: true, + parentMessageId: mockQuoteMessage.messageId, + }); + expect(mockSendMessageStart).toHaveBeenCalledWith({ + ...mockPendingMessage, + url: 'mock-url', + }); + expect(mockPubSub.publish).toHaveBeenCalled(); + expect(response).toBe(mockSuccessMessage); + }); + + it('handles error when sending file message fails', async () => { + const mockError = new Error('Failed to send file message'); + const mockPendingMessage = { + messageId: 67890, + sendingStatus: SendingStatus.PENDING, + } as FileMessage; + + const mockSendFileMessagePromise = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + mockSendFileMessagePromise.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return mockSendFileMessagePromise; + }); + + mockSendFileMessagePromise.onFailed.mockImplementation((cb) => { + cb(mockError, mockPendingMessage); + return mockSendFileMessagePromise; + }); + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(mockSendFileMessagePromise), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + await expect(result.current(mockFile)).rejects.toBe(mockError); + expect(mockSendMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useSendFileMessageCallback: Sending file message failed.', + expect.any(Object), + ); + }); + + it('uses onBeforeSendFileMessage callback', async () => { + const mockCustomParams = { + file: mockFile, + customField: 'test', + }; + const mockOnBeforeSendFileMessage = jest.fn().mockReturnValue(mockCustomParams); + + const mockSendFileMessagePromise = { + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(mockSendFileMessagePromise), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendFileMessage( + { + currentChannel: mockChannel, + onBeforeSendFileMessage: mockOnBeforeSendFileMessage, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockQuoteMessage); + + expect(mockOnBeforeSendFileMessage).toHaveBeenCalledWith(mockFile, mockQuoteMessage); + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith(mockCustomParams); + }); +}); diff --git a/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts new file mode 100644 index 0000000000..4e13d844e4 --- /dev/null +++ b/src/modules/Thread/context/__test__/useSendUserMessageCallback.spec.ts @@ -0,0 +1,250 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { UserMessage, SendingStatus } from '@sendbird/chat/message'; +import { User } from '@sendbird/chat'; +import useSendUserMessageCallback from '../hooks/useSendUserMessageCallback'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockSendMessageStart = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useSendUserMessageCallback', () => { + const mockMessage = 'Hello, world!'; + const mockQuoteMessage = { + messageId: 12345, + } as unknown as SendableMessageType; + const mockMentionedUsers = [{ userId: 'user1' }] as User[]; + const mockMentionTemplate = '@{user1}'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not send message when currentChannel is null', async () => { + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: null, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage }); + expect(mockSendMessageStart).not.toHaveBeenCalled(); + }); + + it('should send message successfully', async () => { + const mockSuccessMessage = { + messageId: 67890, + message: mockMessage, + } as UserMessage; + + const mockPendingMessage = { + ...mockSuccessMessage, + sendingStatus: SendingStatus.PENDING, + }; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockSuccessMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage }); + + expect(mockChannel.sendUserMessage).toHaveBeenCalledWith({ + message: mockMessage, + }); + expect(mockSendMessageStart).toHaveBeenCalledWith(mockPendingMessage); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should handle message sending failure', async () => { + const mockError = new Error('Failed to send message'); + const mockPendingMessage = { + messageId: 67890, + sendingStatus: SendingStatus.PENDING, + } as UserMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation((cb) => { + cb(mockError, mockPendingMessage); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage }); + + expect(mockSendMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useSendUserMessageCallback: Sending user message failed.', + expect.any(Object), + ); + }); + + it('should handle mentions when mention is enabled', () => { + const createMockPromise = () => ({ + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }); + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: true, + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionTemplate: mockMentionTemplate, + }); + + expect(mockChannel.sendUserMessage).toHaveBeenCalledWith({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionedMessageTemplate: mockMentionTemplate, + }); + }); + + it('should use onBeforeSendUserMessage callback when provided', () => { + const mockCustomParams = { + message: mockMessage, + customField: 'test', + }; + const mockOnBeforeSendUserMessage = jest.fn().mockReturnValue(mockCustomParams); + + const createMockPromise = () => ({ + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }); + + const mockChannel = { + sendUserMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendUserMessageCallback( + { + isMentionEnabled: false, + currentChannel: mockChannel, + onBeforeSendUserMessage: mockOnBeforeSendUserMessage, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current({ message: mockMessage, quoteMessage: mockQuoteMessage }); + + expect(mockOnBeforeSendUserMessage).toHaveBeenCalledWith(mockMessage, mockQuoteMessage); + expect(mockChannel.sendUserMessage).toHaveBeenCalledWith(mockCustomParams); + }); +}); diff --git a/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts new file mode 100644 index 0000000000..4327fc1568 --- /dev/null +++ b/src/modules/Thread/context/__test__/useSendVoiceMessageCallback.spec.ts @@ -0,0 +1,236 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { FileMessage, SendingStatus, MessageMetaArray } from '@sendbird/chat/message'; +import useSendVoiceMessageCallback from '../hooks/useSendVoiceMessageCallback'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { SendableMessageType } from '../../../../utils'; +import { + META_ARRAY_MESSAGE_TYPE_KEY, + META_ARRAY_MESSAGE_TYPE_VALUE__VOICE, + META_ARRAY_VOICE_DURATION_KEY, + VOICE_MESSAGE_FILE_NAME, + VOICE_MESSAGE_MIME_TYPE, +} from '../../../../utils/consts'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +const mockSendMessageStart = jest.fn(); +const mockSendMessageFailure = jest.fn(); + +describe('useSendVoiceMessageCallback', () => { + const mockFile = new File(['test'], 'test.mp3', { type: 'audio/mp3' }); + const mockDuration = 10; + const mockQuoteMessage = { + messageId: 12345, + } as unknown as SendableMessageType; + + beforeEach(() => { + jest.clearAllMocks(); + global.URL.createObjectURL = jest.fn(() => 'mock-url'); + }); + + it('should not send voice message when currentChannel is null', () => { + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: null, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + expect(mockSendMessageStart).not.toHaveBeenCalled(); + }); + + it('should send voice message successfully', async () => { + const mockSuccessMessage = { + messageId: 67890, + isUserMessage: false, + isFileMessage: true, + isAdminMessage: false, + isMultipleFilesMessage: false, + } as unknown as FileMessage; + + const mockPendingMessage = { + ...mockSuccessMessage, + sendingStatus: SendingStatus.PENDING, + }; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onSucceeded.mockImplementation((cb) => { + cb(mockSuccessMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation(() => { + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith({ + file: mockFile, + fileName: VOICE_MESSAGE_FILE_NAME, + mimeType: VOICE_MESSAGE_MIME_TYPE, + metaArrays: [ + new MessageMetaArray({ + key: META_ARRAY_VOICE_DURATION_KEY, + value: [`${mockDuration}`], + }), + new MessageMetaArray({ + key: META_ARRAY_MESSAGE_TYPE_KEY, + value: [META_ARRAY_MESSAGE_TYPE_VALUE__VOICE], + }), + ], + isReplyToChannel: true, + parentMessageId: mockQuoteMessage.messageId, + }); + expect(mockSendMessageStart).toHaveBeenCalledWith({ + ...mockPendingMessage, + url: 'mock-url', + sendingStatus: SendingStatus.PENDING, + }); + expect(mockPubSub.publish).toHaveBeenCalled(); + }); + + it('should handle voice message sending failure', async () => { + const mockError = new Error('Failed to send voice message'); + const mockPendingMessage = { + messageId: 67890, + sendingStatus: SendingStatus.PENDING, + } as FileMessage; + + const createMockPromise = () => { + const chainMethods = { + onPending: jest.fn(), + onSucceeded: jest.fn(), + onFailed: jest.fn(), + }; + + chainMethods.onPending.mockImplementation((cb) => { + cb(mockPendingMessage); + return chainMethods; + }); + + chainMethods.onFailed.mockImplementation((cb) => { + cb(mockError, mockPendingMessage); + return chainMethods; + }); + + return chainMethods; + }; + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: mockChannel, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + + expect(mockSendMessageFailure).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useSendVoiceMessageCallback: Sending voice message failed.', + expect.any(Object), + ); + }); + + it('should use onBeforeSendVoiceMessage callback when provided', () => { + const mockCustomParams = { + file: mockFile, + customField: 'test', + }; + const mockOnBeforeSendVoiceMessage = jest.fn().mockReturnValue(mockCustomParams); + + const createMockPromise = () => ({ + onPending: jest.fn().mockReturnThis(), + onSucceeded: jest.fn().mockReturnThis(), + onFailed: jest.fn().mockReturnThis(), + }); + + const mockChannel = { + sendFileMessage: jest.fn().mockReturnValue(createMockPromise()), + } as unknown as GroupChannel; + + const { result } = renderHook(() => useSendVoiceMessageCallback( + { + currentChannel: mockChannel, + onBeforeSendVoiceMessage: mockOnBeforeSendVoiceMessage, + sendMessageStart: mockSendMessageStart, + sendMessageFailure: mockSendMessageFailure, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + + result.current(mockFile, mockDuration, mockQuoteMessage); + + expect(mockOnBeforeSendVoiceMessage).toHaveBeenCalledWith(mockFile, mockQuoteMessage); + expect(mockChannel.sendFileMessage).toHaveBeenCalledWith(mockCustomParams); + }); +}); diff --git a/src/modules/Thread/context/__test__/useThread.spec.tsx b/src/modules/Thread/context/__test__/useThread.spec.tsx new file mode 100644 index 0000000000..9fb264c534 --- /dev/null +++ b/src/modules/Thread/context/__test__/useThread.spec.tsx @@ -0,0 +1,761 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import useThread from '../useThread'; +import { act, waitFor } from '@testing-library/react'; +import { ThreadProvider } from '../ThreadProvider'; +import { ChannelStateTypes, ParentMessageStateTypes, ThreadListStateTypes } from '../../types'; +import { PREV_THREADS_FETCH_SIZE } from '../../consts'; + +const mockApplyReactionEvent = jest.fn(); + +const mockChannel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), +}; + +const mockNewMessage = { + messageId: 42, + message: 'new message', +}; + +const mockParentMessage = { + messageId: 100, + parentMessageId: 0, + parentMessage: null, + message: 'parent message', + reqId: 100, + applyReactionEvent: mockApplyReactionEvent, +}; + +const mockGetChannel = jest.fn().mockResolvedValue(mockChannel); +const mockGetMessage = jest.fn().mockResolvedValue(mockParentMessage); +const mockPubSub = { publish: jest.fn(), subscribe: jest.fn() }; + +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: jest.fn(() => ({ + state: { + stores: { + sdkStore: { + sdk: { + message: { + getMessage: mockGetMessage, + }, + groupChannel: { + getChannel: mockGetChannel, + }, + }, + initialized: true, + }, + userStore: { user: { userId: 'test-user-id' } }, + }, + config: { + logger: console, + pubSub: mockPubSub, + groupChannel: { + enableMention: true, + enableReactions: true, + }, + }, + }, + })), +})); + +describe('useThread', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an error if used outside of ThreadProvider', () => { + const { result } = renderHook(() => useThread()); + expect(result.error).toEqual(new Error('useThread must be used within a ThreadProvider')); + }); + + it('handles sendMessageStart action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toContain(mockMessage); + }); + }); + + it('handles sendMessageSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart, sendMessageSuccess } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + const mockMessage2 = { messageId: 3, message: 'Test message', reqId: 3 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + sendMessageStart(mockMessage2); + sendMessageSuccess(mockMessage2); + }); + + await waitFor(() => { + expect(result.current.state.allThreadMessages).toContain(mockMessage); + expect(result.current.state.allThreadMessages).toContain(mockMessage2); + }); + }); + + it('handles sendMessageFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart, sendMessageFailure } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageFailure(mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toContain(mockMessage); + }); + }); + + it('handles resendMessageStart action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { sendMessageStart, resendMessageStart } = result.current.actions; + + const mockMessage = { messageId: 2, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + resendMessageStart(mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toContain(mockMessage); + }); + }); + + it('handles onMessageUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, sendMessageSuccess, onMessageUpdated } = result.current.actions; + + const otherChannel = { + url: 'test-channel2', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const channel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + onMessageUpdated(otherChannel, mockMessage); + onMessageUpdated(channel, mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.allThreadMessages).toContain(mockMessage); + }); + }); + + it('handles onMessageDeleted action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, sendMessageSuccess, onMessageDeleted } = result.current.actions; + + const otherChannel = { + url: 'test-channel2', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const channel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + onMessageDeleted(otherChannel, mockMessage.messageId); + onMessageDeleted(channel, mockMessage.messageId); + onMessageDeleted(channel, 100); + }); + + await waitFor(() => { + expect(result.current.state.parentMessage).toBe(null); + expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL); + expect(result.current.state.allThreadMessages).toBeEmpty(); + }); + }); + + it('handles onMessageDeletedByReqId action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, onMessageDeletedByReqId } = result.current.actions; + + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2 }; + + await act(() => { + sendMessageStart(mockMessage); + onMessageDeletedByReqId(mockMessage.reqId); + }); + + await waitFor(() => { + expect(result.current.state.localThreadMessages).toBeEmpty(); + }); + }); + + it('handles initializeThreadListStart action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { initializeThreadListStart } = result.current.actions; + + await act(() => { + initializeThreadListStart(); + }); + + await waitFor(() => { + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.LOADING); + }); + }); + + it('handles initializeThreadListSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { initializeThreadListStart, initializeThreadListSuccess } = result.current.actions; + + await act(() => { + initializeThreadListStart(); + initializeThreadListSuccess(mockParentMessage, mockParentMessage, []); + }); + + await waitFor(() => { + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.INITIALIZED); + }); + }); + + it('handles initializeThreadListFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { initializeThreadListStart, initializeThreadListFailure } = result.current.actions; + + await act(() => { + initializeThreadListStart(); + initializeThreadListFailure(); + }); + + await waitFor(() => { + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.LOADING); + expect(result.current.state.allThreadMessages).toBeEmpty(); + }); + }); + + it('handles getPrevMessagesSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getPrevMessagesStart, getPrevMessagesSuccess } = result.current.actions; + + await act(() => { + getPrevMessagesStart(); + getPrevMessagesSuccess(Array(PREV_THREADS_FETCH_SIZE).map((e, i) => { + return { messageId: i + 10, message: `meesage Id: ${i + 10}`, reqId: i + 10 }; + })); + }); + + await waitFor(() => { + expect(result.current.state.hasMorePrev).toBe(true); + expect(result.current.state.allThreadMessages).toHaveLength(30); + }); + }); + + it('handles getPrevMessagesFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getPrevMessagesStart, getPrevMessagesFailure } = result.current.actions; + + await act(() => { + getPrevMessagesStart(); + getPrevMessagesFailure(); + }); + + await waitFor(() => { + expect(result.current.state.hasMorePrev).toBe(false); + }); + }); + + it('handles getNextMessagesSuccess action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getNextMessagesStart, getNextMessagesSuccess } = result.current.actions; + + await act(() => { + getNextMessagesStart(); + getNextMessagesSuccess(Array(PREV_THREADS_FETCH_SIZE).map((e, i) => { + return { messageId: i + 10, message: `meesage Id: ${i + 10}`, reqId: i + 10 }; + })); + }); + + await waitFor(() => { + expect(result.current.state.hasMoreNext).toBe(true); + expect(result.current.state.allThreadMessages).toHaveLength(30); + }); + }); + + it('handles getNextMessagesFailure action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { getNextMessagesStart, getNextMessagesFailure } = result.current.actions; + + await act(() => { + getNextMessagesStart(); + getNextMessagesFailure(); + }); + + await waitFor(() => { + expect(result.current.state.hasMoreNext).toBe(false); + }); + }); + + it('handles setEmojiContainer action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + const { result } = renderHook(() => useThread(), { wrapper }); + const { setEmojiContainer } = result.current.actions; + + const emojiContainer = { + emojiHash: 'test-hash', + emojiCategories: [{ + id: 'test-category-id', + name: 'test-category', + url: 'test-category-url', + emojis: [], + }], + }; + + await act(() => { + setEmojiContainer(emojiContainer); + }); + + await waitFor(() => { + expect(result.current.state.emojiContainer).toBe(emojiContainer); + }); + }); + + it('handles onMessageReceived action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onMessageReceived } = result.current.actions; + + const otherChannel = { + url: 'test-channel2', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const channel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + updateUserMessage: jest.fn().mockImplementation(async () => mockNewMessage), + }; + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2, parentMessage: mockParentMessage }; + + await act(() => { + onMessageReceived(otherChannel, mockMessage); + onMessageReceived(channel, mockMessage); + onMessageReceived(channel, mockMessage); + }); + + await waitFor(() => { + expect(result.current.state.allThreadMessages).toContain(mockMessage); + }); + }); + + it('handles onReactionUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, sendMessageSuccess, onReactionUpdated } = result.current.actions; + + const mockMessage = { messageId: 1, message: 'Test message', reqId: 2, parentMessage: mockParentMessage }; + + await act(() => { + sendMessageStart(mockMessage); + sendMessageSuccess(mockMessage); + onReactionUpdated({ + messageId: mockParentMessage.messageId, + userId: 'test-user-id', + key: '1', + operation: 'ADD', + updatedAt: 0, + }); + }); + + await waitFor(() => { + expect(mockApplyReactionEvent).toHaveBeenCalled(); + }); + }); + + it('handles onUserMuted action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserMuted } = result.current.actions; + + await act(() => { + onUserMuted(mockChannel, { userId: 'other-user-id' }); + onUserMuted(mockChannel, { userId: 'test-user-id' }); + }); + + await waitFor(() => { + expect(result.current.state.isMuted).toBe(true); + }); + }); + + it('handles onUserUnmuted action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserUnmuted } = result.current.actions; + + await act(() => { + onUserUnmuted(mockChannel, { userId: 'other-user-id' }); + onUserUnmuted(mockChannel, { userId: 'test-user-id' }); + }); + + await waitFor(() => { + expect(result.current.state.isMuted).toBe(false); + }); + }); + + it('handles onUserBanned action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserBanned } = result.current.actions; + + await act(() => { + onUserBanned(); + }); + + await waitFor(() => { + expect(result.current.state.channelState).toBe(ChannelStateTypes.NIL); + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.NIL); + expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL); + }); + }); + + it('handles onUserUnbanned action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserUnbanned } = result.current.actions; + + await act(() => { + onUserUnbanned(); + }); + }); + + it('handles onUserLeft action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onUserLeft } = result.current.actions; + + await act(() => { + onUserLeft(); + }); + + await waitFor(() => { + expect(result.current.state.channelState).toBe(ChannelStateTypes.NIL); + expect(result.current.state.threadListState).toBe(ThreadListStateTypes.NIL); + expect(result.current.state.parentMessageState).toBe(ParentMessageStateTypes.NIL); + }); + }); + + it('handles onChannelFrozen action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onChannelFrozen } = result.current.actions; + + await act(() => { + onChannelFrozen(); + }); + + await waitFor(() => { + expect(result.current.state.isChannelFrozen).toBe(true); + }); + }); + + it('handles onChannelFrozen action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onChannelFrozen, onChannelUnfrozen } = result.current.actions; + + await act(() => { + onChannelFrozen(); + onChannelUnfrozen(); + }); + + await waitFor(() => { + expect(result.current.state.isChannelFrozen).toBe(false); + }); + }); + + it('handles onOperatorUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onOperatorUpdated } = result.current.actions; + + const newMockChannel = { + url: 'test-channel', + }; + await act(() => { + onOperatorUpdated(newMockChannel); + }); + + await waitFor(() => { + expect(result.current.state.currentChannel).toBe(newMockChannel); + }); + }); + + it('handles onTypingStatusUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { onTypingStatusUpdated } = result.current.actions; + const mockMember = { userId: '1', nickname: 'user1' }; + + await act(() => { + onTypingStatusUpdated(mockChannel, [mockMember]); + }); + + await waitFor(() => { + expect(result.current.state.typingMembers).toContain(mockMember); + }); + }); + + it('handles onFileInfoUpdated action correctly', async () => { + const wrapper = ({ children }) => ( + {children} + ); + + let result; + await act(async () => { + result = renderHook(() => useThread(), { wrapper }).result; + + await waitFor(() => { + expect(result.current.state.currentChannel).not.toBe(undefined); + }); + }); + const { sendMessageStart, onFileInfoUpdated } = result.current.actions; + const mockMessage = { + messageId: 2, + message: 'Test message', + reqId: 2, + parentMessage: mockParentMessage, + messageParams: { + fileInfoList: [], + }, + }; + const newFileInfo = { name: 'new-file-info' }; + + await act(() => { + sendMessageStart(mockMessage); + onFileInfoUpdated({ + channelUrl: 'test-channel', + requestId: mockMessage.reqId, + index: 0, + uploadableFileInfo: newFileInfo, + }); + }); + + await waitFor(() => { + console.log(result.current.state.localThreadMessages[0]); + expect(result.current.state.localThreadMessages[0].messageParams.fileInfoList).toContain(newFileInfo); + }); + }); + +}); diff --git a/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts b/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts new file mode 100644 index 0000000000..564276d56d --- /dev/null +++ b/src/modules/Thread/context/__test__/useThreadFetchers.spec.ts @@ -0,0 +1,161 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { BaseMessage } from '@sendbird/chat/message'; +import { useThreadFetchers } from '../hooks/useThreadFetchers'; +import { ThreadListStateTypes } from '../../types'; +import { SendableMessageType } from '../../../../utils'; + +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: () => ({ + state: { + stores: { + sdkStore: { + initialized: true, + }, + }, + }, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useThreadFetchers', () => { + const mockParentMessage = { + messageId: 12345, + getThreadedMessagesByTimestamp: jest.fn(), + } as unknown as SendableMessageType; + + const mockAnchorMessage = { + messageId: 67890, + createdAt: 1234567890, + } as unknown as SendableMessageType; + + const mockThreadedMessages = [ + { messageId: 1 }, + { messageId: 2 }, + ] as BaseMessage[]; + + const createMockCallbacks = () => ({ + initializeThreadListStart: jest.fn(), + initializeThreadListSuccess: jest.fn(), + initializeThreadListFailure: jest.fn(), + getPrevMessagesStart: jest.fn(), + getPrevMessagesSuccess: jest.fn(), + getPrevMessagesFailure: jest.fn(), + getNextMessagesStart: jest.fn(), + getNextMessagesSuccess: jest.fn(), + getNextMessagesFailure: jest.fn(), + }); + + const renderThreadFetchersHook = ({ + threadListState = ThreadListStateTypes.INITIALIZED, + oldestMessageTimeStamp = 0, + latestMessageTimeStamp = 0, + callbacks = createMockCallbacks(), + } = {}) => { + return renderHook(() => useThreadFetchers({ + anchorMessage: mockAnchorMessage, + parentMessage: mockParentMessage, + isReactionEnabled: true, + logger: mockLogger, + threadListState, + oldestMessageTimeStamp, + latestMessageTimeStamp, + ...callbacks, + })); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize thread list successfully', async () => { + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({ + threadedMessages: mockThreadedMessages, + parentMessage: mockParentMessage, + }); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ callbacks }); + + await result.current.initializeThreadFetcher(); + + expect(callbacks.initializeThreadListStart).toHaveBeenCalled(); + expect(callbacks.initializeThreadListSuccess).toHaveBeenCalledWith( + mockParentMessage, + mockAnchorMessage, + mockThreadedMessages, + ); + }); + + it('should handle initialization failure', async () => { + const mockError = new Error('Failed to initialize'); + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockRejectedValue(mockError); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ callbacks }); + + await result.current.initializeThreadFetcher(); + + expect(callbacks.initializeThreadListStart).toHaveBeenCalled(); + expect(callbacks.initializeThreadListFailure).toHaveBeenCalled(); + }); + + it('should fetch previous messages successfully', async () => { + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({ + threadedMessages: mockThreadedMessages, + parentMessage: mockParentMessage, + }); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ + oldestMessageTimeStamp: 1000, + latestMessageTimeStamp: 2000, + callbacks, + }); + + await result.current.fetchPrevThreads(); + + expect(callbacks.getPrevMessagesStart).toHaveBeenCalled(); + expect(callbacks.getPrevMessagesSuccess).toHaveBeenCalledWith(mockThreadedMessages); + }); + + it('should fetch next messages successfully', async () => { + (mockParentMessage.getThreadedMessagesByTimestamp as jest.Mock).mockResolvedValue({ + threadedMessages: mockThreadedMessages, + parentMessage: mockParentMessage, + }); + + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ + oldestMessageTimeStamp: 1000, + latestMessageTimeStamp: 2000, + callbacks, + }); + + await result.current.fetchNextThreads(); + + expect(callbacks.getNextMessagesStart).toHaveBeenCalled(); + expect(callbacks.getNextMessagesSuccess).toHaveBeenCalledWith(mockThreadedMessages); + }); + + it('should not fetch when threadListState is not INITIALIZED', async () => { + const callbacks = createMockCallbacks(); + const { result } = renderThreadFetchersHook({ + threadListState: ThreadListStateTypes.LOADING, + oldestMessageTimeStamp: 1000, + latestMessageTimeStamp: 2000, + callbacks, + }); + + await result.current.fetchPrevThreads(); + await result.current.fetchNextThreads(); + + expect(callbacks.getPrevMessagesStart).not.toHaveBeenCalled(); + expect(callbacks.getNextMessagesStart).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts b/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts new file mode 100644 index 0000000000..ad8701ede1 --- /dev/null +++ b/src/modules/Thread/context/__test__/useToggleReactionsCallback.spec.ts @@ -0,0 +1,148 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { BaseMessage } from '@sendbird/chat/message'; +import useToggleReactionCallback from '../hooks/useToggleReactionsCallback'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useToggleReactionCallback', () => { + const mockMessage = { + messageId: 12345, + } as BaseMessage; + const REACTION_KEY = 'thumbs_up'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not toggle reaction when currentChannel is null', () => { + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: null, + }, + { + logger: mockLogger, + }, + )); + + result.current(mockMessage, REACTION_KEY, true); + expect(mockLogger.warning).not.toHaveBeenCalled(); + }); + + it('should delete reaction when isReacted is true', async () => { + const mockDeleteReaction = jest.fn().mockResolvedValue({ success: true }); + const mockChannel = { + deleteReaction: mockDeleteReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + await result.current(mockMessage, REACTION_KEY, true); + + expect(mockDeleteReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Delete reaction succeeded.', + { success: true }, + ); + }); + + it('should handle delete reaction failure', async () => { + const mockError = new Error('Failed to delete reaction'); + const mockDeleteReaction = jest.fn().mockRejectedValue(mockError); + const mockChannel = { + deleteReaction: mockDeleteReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + result.current(mockMessage, REACTION_KEY, true); + + await new Promise(process.nextTick); + + expect(mockDeleteReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Delete reaction failed.', + mockError, + ); + }); + + it('should add reaction when isReacted is false', async () => { + const mockAddReaction = jest.fn().mockResolvedValue({ success: true }); + const mockChannel = { + addReaction: mockAddReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + await result.current(mockMessage, REACTION_KEY, false); + + expect(mockAddReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Add reaction succeeded.', + { success: true }, + ); + }); + + it('should handle add reaction failure', async () => { + const mockError = new Error('Failed to add reaction'); + const mockAddReaction = jest.fn().mockRejectedValue(mockError); + const mockChannel = { + addReaction: mockAddReaction, + } as unknown as GroupChannel; + + const { result } = renderHook(() => useToggleReactionCallback( + { + currentChannel: mockChannel, + }, + { + logger: mockLogger, + }, + )); + + result.current(mockMessage, REACTION_KEY, false); + + await new Promise(process.nextTick); + + expect(mockAddReaction).toHaveBeenCalledWith(mockMessage, REACTION_KEY); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'Thread | useToggleReactionsCallback: Add reaction failed.', + mockError, + ); + }); +}); diff --git a/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts b/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts new file mode 100644 index 0000000000..ba69474995 --- /dev/null +++ b/src/modules/Thread/context/__test__/useUpdateMessageCallback.spec.ts @@ -0,0 +1,169 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import { UserMessage } from '@sendbird/chat/message'; +import { User } from '@sendbird/chat'; +import useUpdateMessageCallback from '../hooks/useUpdateMessageCallback'; +import { PublishingModuleType } from '../../../internalInterfaces'; +import { SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; + +const mockSetEmojiContainer = jest.fn(); + +jest.mock('../useThread', () => ({ + __esModule: true, + default: () => ({ + actions: { + setEmojiContainer: mockSetEmojiContainer, + }, + }), +})); + +const mockPubSub = { + publish: jest.fn(), +} as unknown as SBUGlobalPubSub; + +const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe('useUpdateMessageCallback', () => { + const mockMessageId = 12345; + const mockMessage = 'Updated message content'; + const mockMentionedUsers = [{ userId: 'user1' }] as User[]; + const mockMentionTemplate = '@{user1}'; + + const createMockChannel = (updateUserMessage = jest.fn()) => ({ + updateUserMessage, + }) as unknown as GroupChannel; + const createMockCallbacks = () => ({ + onMessageUpdated: jest.fn(), + }); + + const renderUpdateMessageCallbackHook = ({ + currentChannel = undefined, + isMentionEnabled = false, + callbacks = createMockCallbacks(), + } = {}) => { + return renderHook(() => useUpdateMessageCallback( + { + currentChannel: currentChannel ?? null, + isMentionEnabled, + onMessageUpdated: callbacks.onMessageUpdated, + }, + { + logger: mockLogger, + pubSub: mockPubSub, + }, + )); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update message successfully', async () => { + const updatedMessage = { + messageId: mockMessageId, + message: mockMessage, + } as UserMessage; + + const mockUpdateUserMessage = jest.fn().mockResolvedValue(updatedMessage); + const mockChannel = createMockChannel(mockUpdateUserMessage); + const callbacks = createMockCallbacks(); + + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: mockChannel, + callbacks, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + }); + + expect(mockUpdateUserMessage).toHaveBeenCalledWith( + mockMessageId, + expect.objectContaining({ + message: mockMessage, + }), + ); + expect(callbacks.onMessageUpdated).toHaveBeenCalledWith(mockChannel, updatedMessage); + expect(mockPubSub.publish).toHaveBeenCalledWith( + 'UPDATE_USER_MESSAGE', + expect.objectContaining({ + fromSelector: true, + channel: mockChannel, + message: updatedMessage, + publishingModules: [PublishingModuleType.THREAD], + }), + ); + }); + + it('should include mention data when mention is enabled', async () => { + const mockUpdateUserMessage = jest.fn().mockResolvedValue({} as UserMessage); + const mockChannel = createMockChannel(mockUpdateUserMessage); + + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: mockChannel, + isMentionEnabled: true, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionTemplate: mockMentionTemplate, + }); + + expect(mockUpdateUserMessage).toHaveBeenCalledWith( + mockMessageId, + expect.objectContaining({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionedMessageTemplate: mockMentionTemplate, + }), + ); + }); + + it('should use message as mention template when template is not provided', async () => { + const mockUpdateUserMessage = jest.fn().mockResolvedValue({} as UserMessage); + const mockChannel = createMockChannel(mockUpdateUserMessage); + + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: mockChannel, + isMentionEnabled: true, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + mentionedUsers: mockMentionedUsers, + }); + + expect(mockUpdateUserMessage).toHaveBeenCalledWith( + mockMessageId, + expect.objectContaining({ + message: mockMessage, + mentionedUsers: mockMentionedUsers, + mentionedMessageTemplate: mockMessage, + }), + ); + }); + + it('should not update message when currentChannel is undefined', async () => { + const callbacks = createMockCallbacks(); + const { result } = renderUpdateMessageCallbackHook({ + currentChannel: undefined, + callbacks, + }); + + await result.current({ + messageId: mockMessageId, + message: mockMessage, + }); + + expect(callbacks.onMessageUpdated).not.toHaveBeenCalled(); + expect(mockPubSub.publish).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts index fb81a7def8..e5d888cebe 100644 --- a/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useDeleteMessageCallback.ts @@ -31,9 +31,12 @@ export default function useDeleteMessageCallback({ onMessageDeletedByReqId(message.reqId); resolve(); } - + if (currentChannel == null) { + logger.info('Thread | useDeleteMessageCallback: No current channel'); + resolve(); + } logger.info('Thread | useDeleteMessageCallback: Deleting message from remote:', sendingStatus); - currentChannel?.deleteMessage?.(message) + currentChannel.deleteMessage?.(message) .then(() => { logger.info('Thread | useDeleteMessageCallback: Deleting message success!', message); onMessageDeleted(currentChannel, message.messageId); diff --git a/src/modules/Thread/context/hooks/useResendMessageCallback.ts b/src/modules/Thread/context/hooks/useResendMessageCallback.ts index 428add20ed..07497fb116 100644 --- a/src/modules/Thread/context/hooks/useResendMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useResendMessageCallback.ts @@ -35,107 +35,110 @@ export default function useResendMessageCallback({ pubSub, }: StaticProps): (failedMessage: SendableMessageType) => void { return useCallback((failedMessage: SendableMessageType) => { - if ((failedMessage as SendableMessageType)?.isResendable) { - logger.info('Thread | useResendMessageCallback: Resending failedMessage start.', failedMessage); - if (failedMessage?.isUserMessage?.() || failedMessage?.messageType === MessageType.USER) { - try { - currentChannel?.resendMessage(failedMessage as UserMessage) - .onPending((message) => { - logger.info('Thread | useResendMessageCallback: Resending user message started.', message); - resendMessageStart(message); - }) - .onSucceeded((message) => { - logger.info('Thread | useResendMessageCallback: Resending user message succeeded.', message); - sendMessageSuccess(message); - pubSub.publish(topics.SEND_USER_MESSAGE, { - channel: currentChannel, - message: message, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onFailed((error) => { - logger.warning('Thread | useResendMessageCallback: Resending user message failed.', error); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); + if (!(failedMessage as SendableMessageType)?.isResendable) { + logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); + return; + } + + logger.info('Thread | useResendMessageCallback: Resending failedMessage start.', failedMessage); + if (failedMessage?.isUserMessage?.() || failedMessage?.messageType === MessageType.USER) { + try { + currentChannel?.resendMessage(failedMessage as UserMessage) + .onPending((message) => { + logger.info('Thread | useResendMessageCallback: Resending user message started.', message); + resendMessageStart(message); + }) + .onSucceeded((message) => { + logger.info('Thread | useResendMessageCallback: Resending user message succeeded.', message); + sendMessageSuccess(message); + pubSub.publish(topics.SEND_USER_MESSAGE, { + channel: currentChannel, + message: message, + publishingModules: [PublishingModuleType.THREAD], }); - } catch (err) { - logger.warning('Thread | useResendMessageCallback: Resending user message failed.', err); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); - } - } else if (failedMessage?.isFileMessage?.()) { - try { - currentChannel?.resendMessage?.(failedMessage as FileMessage) - .onPending((message) => { - logger.info('Thread | useResendMessageCallback: Resending file message started.', message); - resendMessageStart(message); - }) - .onSucceeded((message) => { - logger.info('Thread | useResendMessageCallback: Resending file message succeeded.', message); - sendMessageSuccess(message); - pubSub.publish(topics.SEND_FILE_MESSAGE, { - channel: currentChannel, - message: failedMessage, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onFailed((error) => { - logger.warning('Thread | useResendMessageCallback: Resending file message failed.', error); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); + }) + .onFailed((error) => { + logger.warning('Thread | useResendMessageCallback: Resending user message failed.', error); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + }); + } catch (err) { + logger.warning('Thread | useResendMessageCallback: Resending user message failed.', err); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + } + } else if (failedMessage?.isFileMessage?.()) { + try { + currentChannel?.resendMessage?.(failedMessage as FileMessage) + .onPending((message) => { + logger.info('Thread | useResendMessageCallback: Resending file message started.', message); + resendMessageStart(message); + }) + .onSucceeded((message) => { + logger.info('Thread | useResendMessageCallback: Resending file message succeeded.', message); + sendMessageSuccess(message); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message: failedMessage, + publishingModules: [PublishingModuleType.THREAD], }); - } catch (err) { - logger.warning('Thread | useResendMessageCallback: Resending file message failed.', err); - failedMessage.sendingStatus = SendingStatus.FAILED; - sendMessageFailure(failedMessage); - } - } else if (failedMessage?.isMultipleFilesMessage?.()) { - try { - currentChannel?.resendMessage?.(failedMessage as MultipleFilesMessage) - .onPending((message) => { - logger.info('Thread | useResendMessageCallback: Resending multiple files message started.', message); - resendMessageStart(message); - }) - .onFileUploaded((requestId, index, uploadableFileInfo: UploadableFileInfo, error) => { - logger.info('Thread | useResendMessageCallback: onFileUploaded during resending multiple files message.', { + }) + .onFailed((error) => { + logger.warning('Thread | useResendMessageCallback: Resending file message failed.', error); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + }); + } catch (err) { + logger.warning('Thread | useResendMessageCallback: Resending file message failed.', err); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); + } + } else if (failedMessage?.isMultipleFilesMessage?.()) { + try { + currentChannel?.resendMessage?.(failedMessage as MultipleFilesMessage) + .onPending((message) => { + logger.info('Thread | useResendMessageCallback: Resending multiple files message started.', message); + resendMessageStart(message); + }) + .onFileUploaded((requestId, index, uploadableFileInfo: UploadableFileInfo, error) => { + logger.info('Thread | useResendMessageCallback: onFileUploaded during resending multiple files message.', { + requestId, + index, + error, + uploadableFileInfo, + }); + pubSub.publish(topics.ON_FILE_INFO_UPLOADED, { + response: { + channelUrl: currentChannel.url, requestId, index, - error, uploadableFileInfo, - }); - pubSub.publish(topics.ON_FILE_INFO_UPLOADED, { - response: { - channelUrl: currentChannel.url, - requestId, - index, - uploadableFileInfo, - error, - }, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onSucceeded((message: MultipleFilesMessage) => { - logger.info('Thread | useResendMessageCallback: Resending MFM succeeded.', message); - sendMessageSuccess(message); - pubSub.publish(topics.SEND_FILE_MESSAGE, { - channel: currentChannel, - message, - publishingModules: [PublishingModuleType.THREAD], - }); - }) - .onFailed((error, message) => { - logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', error); - sendMessageFailure(message); + error, + }, + publishingModules: [PublishingModuleType.THREAD], }); - } catch (err) { - logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', err); - sendMessageFailure(failedMessage); - } - } else { - logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); - failedMessage.sendingStatus = SendingStatus.FAILED; + }) + .onSucceeded((message: MultipleFilesMessage) => { + logger.info('Thread | useResendMessageCallback: Resending MFM succeeded.', message); + sendMessageSuccess(message); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message, + publishingModules: [PublishingModuleType.THREAD], + }); + }) + .onFailed((error, message) => { + logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', error); + sendMessageFailure(message); + }); + } catch (err) { + logger.warning('Thread | useResendMessageCallback: Resending MFM failed.', err); sendMessageFailure(failedMessage); } + } else { + logger.warning('Thread | useResendMessageCallback: Message is not resendable.', failedMessage); + failedMessage.sendingStatus = SendingStatus.FAILED; + sendMessageFailure(failedMessage); } }, [currentChannel]); } diff --git a/src/modules/Thread/context/hooks/useSendFileMessage.ts b/src/modules/Thread/context/hooks/useSendFileMessage.ts index ef1b4bc14e..fd38846abb 100644 --- a/src/modules/Thread/context/hooks/useSendFileMessage.ts +++ b/src/modules/Thread/context/hooks/useSendFileMessage.ts @@ -50,37 +50,42 @@ export default function useSendFileMessageCallback({ const params = onBeforeSendFileMessage?.(file, quoteMessage) ?? createParamsDefault(); logger.info('Thread | useSendFileMessageCallback: Sending file message start.', params); - currentChannel?.sendFileMessage(params) - .onPending((pendingMessage) => { + if (currentChannel == null) { + logger.warning('Thread | useSendFileMessageCallback: currentChannel is null. Skipping file message send.'); + resolve(null); + } else { + currentChannel.sendFileMessage(params) + .onPending((pendingMessage) => { // @ts-ignore - sendMessageStart({ - ...pendingMessage, - url: URL.createObjectURL(file), - // pending thumbnail message seems to be failed - sendingStatus: SendingStatus.PENDING, - isUserMessage: pendingMessage.isUserMessage, - isFileMessage: pendingMessage.isFileMessage, - isAdminMessage: pendingMessage.isAdminMessage, - isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, + sendMessageStart({ + ...pendingMessage, + url: URL.createObjectURL(file), + // pending thumbnail message seems to be failed + sendingStatus: SendingStatus.PENDING, + isUserMessage: pendingMessage.isUserMessage, + isFileMessage: pendingMessage.isFileMessage, + isAdminMessage: pendingMessage.isAdminMessage, + isMultipleFilesMessage: pendingMessage.isMultipleFilesMessage, + }); + setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND); + }) + .onFailed((error, message) => { + (message as LocalFileMessage).localUrl = URL.createObjectURL(file); + (message as LocalFileMessage).file = file; + logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error }); + sendMessageFailure(message as SendableMessageType); + reject(error); + }) + .onSucceeded((message) => { + logger.info('Thread | useSendFileMessageCallback: Sending file message succeeded.', message); + pubSub.publish(topics.SEND_FILE_MESSAGE, { + channel: currentChannel, + message: message as FileMessage, + publishingModules: [PublishingModuleType.THREAD], + }); + resolve(message as FileMessage); }); - setTimeout(() => scrollIntoLast(), SCROLL_BOTTOM_DELAY_FOR_SEND); - }) - .onFailed((error, message) => { - (message as LocalFileMessage).localUrl = URL.createObjectURL(file); - (message as LocalFileMessage).file = file; - logger.info('Thread | useSendFileMessageCallback: Sending file message failed.', { message, error }); - sendMessageFailure(message as SendableMessageType); - reject(error); - }) - .onSucceeded((message) => { - logger.info('Thread | useSendFileMessageCallback: Sending file message succeeded.', message); - pubSub.publish(topics.SEND_FILE_MESSAGE, { - channel: currentChannel, - message: message as FileMessage, - publishingModules: [PublishingModuleType.THREAD], - }); - resolve(message as FileMessage); - }); + } }); }, [currentChannel], diff --git a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts index 3a23b66583..17881c824a 100644 --- a/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts +++ b/src/modules/Thread/context/hooks/useUpdateMessageCallback.ts @@ -60,7 +60,12 @@ export default function useUpdateMessageCallback({ const params = createParamsDefault(); logger.info('Thread | useUpdateMessageCallback: Message update start.', params); - currentChannel?.updateUserMessage?.(messageId, params) + if (currentChannel == null) { + logger.warning('Thread | useUpdateMessageCallback: currentChannel is null.'); + return; + } + + currentChannel.updateUserMessage?.(messageId, params) .then((message: UserMessage) => { logger.info('Thread | useUpdateMessageCallback: Message update succeeded.', message); onMessageUpdated(currentChannel, message); diff --git a/src/modules/Thread/context/useThread.ts b/src/modules/Thread/context/useThread.ts index 7d68ebcaf5..7cf2767197 100644 --- a/src/modules/Thread/context/useThread.ts +++ b/src/modules/Thread/context/useThread.ts @@ -34,8 +34,7 @@ function hasReqId( const useThread = () => { const store = useContext(ThreadContext); - if (!store) throw new Error('useCreateChannel must be used within a CreateChannelProvider'); - + if (!store) throw new Error('useThread must be used within a ThreadProvider'); // SendbirdStateContext config const { state: { stores, config } } = useSendbird(); const { logger, pubSub } = config; From dc48275297f07a28e8fa918d51d3c511f7c2e8a2 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Mon, 9 Dec 2024 13:20:22 +0900 Subject: [PATCH 21/29] [CLNP-6010] Add more unit tests for existing message search custom hook (#1278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### Before Screenshot 2024-12-06 at 6 23 24 PM #### After Screenshot 2024-12-06 at 6 36 19 PM --- .../__test__/MessageSearchProvider.spec.tsx | 104 ++++++ .../__test__/useGetSearchedMessages.spec.ts | 301 ++++++++++++++++++ .../__test__/useScrollCallback.spec.ts | 209 ++++++++++++ .../__test__/useSearchStringEffect.spec.ts | 176 ++++++++++ .../context/__test__/useSetChannel.spec.ts | 103 ++++++ 5 files changed, 893 insertions(+) create mode 100644 src/modules/MessageSearch/context/__test__/useGetSearchedMessages.spec.ts create mode 100644 src/modules/MessageSearch/context/__test__/useScrollCallback.spec.ts create mode 100644 src/modules/MessageSearch/context/__test__/useSearchStringEffect.spec.ts create mode 100644 src/modules/MessageSearch/context/__test__/useSetChannel.spec.ts diff --git a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx b/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx index 60d9c8590e..4096e6bde4 100644 --- a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx +++ b/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx @@ -5,6 +5,7 @@ import { MessageSearchQuery } from '@sendbird/chat/message'; import { MessageSearchProvider } from '../MessageSearchProvider'; import useMessageSearch from '../hooks/useMessageSearch'; +import useScrollCallback from '../hooks/useScrollCallback'; jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, @@ -46,6 +47,11 @@ jest.mock('../hooks/useSearchStringEffect', () => ({ })); describe('MessageSearchProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useScrollCallback as jest.Mock).mockClear(); + }); + const initialState = { allMessages: [], loading: false, @@ -290,4 +296,102 @@ describe('MessageSearchProvider', () => { }); }); }); + + it('handles onResultClick callback correctly', async () => { + const onResultClick = jest.fn(); + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + expect(result.current.state.onResultClick).toBe(onResultClick); + }); + + it('uses provided messageSearchQuery prop correctly', async () => { + const customQuery = { + limit: 20, + reverse: true, + exactMatch: false, + }; + + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + expect(result.current.state.messageSearchQuery).toEqual(customQuery); + }); + + it('executes onResultClick callback when clicking a search result', async () => { + const onResultClick = jest.fn(); + const mockMessage = { messageId: 1 }; + + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + await act(async () => { + expect(result.current.state.onResultClick).toBe(onResultClick); + result.current.state.onResultClick(mockMessage); + await waitFor(() => { + expect(onResultClick).toHaveBeenCalledWith(mockMessage); + }); + }); + }); + + it('does not trigger scroll callback when hasMoreResult is false', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useMessageSearch(), { wrapper }); + + await act(async () => { + const mockQuery = { channelUrl: 'test-channel', hasNext: false }; + result.current.actions.startGettingSearchedMessages(mockQuery as any); + result.current.actions.getSearchedMessages([{ messageId: 1 }] as any, mockQuery as any); + + await waitFor(() => { + expect(result.current.state.hasMoreResult).toBe(false); + }); + }); + + await act(async () => { + const mockEvent = { + target: { + scrollTop: 100, + scrollHeight: 100, + clientHeight: 50, + }, + }; + + const prevLoading = result.current.state.loading; + result.current.state.handleOnScroll(mockEvent); + + await waitFor(() => { + expect(result.current.state.loading).toBe(prevLoading); + }); + }); + }); }); diff --git a/src/modules/MessageSearch/context/__test__/useGetSearchedMessages.spec.ts b/src/modules/MessageSearch/context/__test__/useGetSearchedMessages.spec.ts new file mode 100644 index 0000000000..adf7740894 --- /dev/null +++ b/src/modules/MessageSearch/context/__test__/useGetSearchedMessages.spec.ts @@ -0,0 +1,301 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { GroupChannel } from '@sendbird/chat/groupChannel'; +import useGetSearchedMessages from '../hooks/useGetSearchedMessages'; +import useMessageSearch from '../hooks/useMessageSearch'; + +jest.mock('../hooks/useMessageSearch', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe('useGetSearchedMessages', () => { + const mockLogger = { + warning: jest.fn(), + info: jest.fn(), + }; + + const mockStartMessageSearch = jest.fn(); + const mockGetSearchedMessages = jest.fn(); + const mockSetQueryInvalid = jest.fn(); + const mockStartGettingSearchedMessages = jest.fn(); + const mockOnResultLoaded = jest.fn(); + + const mockSdk = { + createMessageSearchQuery: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + retryCount: 0, + }, + actions: { + startMessageSearch: mockStartMessageSearch, + getSearchedMessages: mockGetSearchedMessages, + setQueryInvalid: mockSetQueryInvalid, + startGettingSearchedMessages: mockStartGettingSearchedMessages, + }, + }); + }); + + it('should not proceed when requestString is empty', () => { + renderHook( + () => useGetSearchedMessages( + { + currentChannel: null, + channelUrl: 'channel-url', + requestString: '', + onResultLoaded: mockOnResultLoaded, + }, + { + sdk: mockSdk as any, + logger: mockLogger as any, + }, + ), + ); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'MessageSearch | useGetSearchedMessages: search string is empty', + ); + expect(mockStartMessageSearch).toHaveBeenCalled(); + }); + + it('should handle successful message search', async () => { + const mockMessages = [{ messageId: 1 }]; + const mockQuery = { + next: jest.fn().mockResolvedValue(mockMessages), + }; + + mockSdk.createMessageSearchQuery.mockReturnValue(mockQuery); + + const mockChannel = { + url: 'channel-url', + refresh: jest.fn().mockResolvedValue({ + invitedAt: 1234567890, + }), + }; + + renderHook( + () => useGetSearchedMessages( + { + currentChannel: mockChannel as unknown as GroupChannel, + channelUrl: 'channel-url', + requestString: 'search-term', + onResultLoaded: mockOnResultLoaded, + }, + { + sdk: mockSdk as any, + logger: mockLogger as any, + }, + ), + ); + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockStartMessageSearch).toHaveBeenCalled(); + expect(mockChannel.refresh).toHaveBeenCalled(); + expect(mockSdk.createMessageSearchQuery).toHaveBeenCalled(); + expect(mockStartGettingSearchedMessages).toHaveBeenCalled(); + expect(mockGetSearchedMessages).toHaveBeenCalledWith(mockMessages, mockQuery); + expect(mockOnResultLoaded).toHaveBeenCalledWith(mockMessages, undefined); + }); + + it('should handle channel refresh failure', async () => { + const mockError = new Error('Channel refresh failed'); + const mockChannel = { + url: 'channel-url', + refresh: jest.fn().mockRejectedValue(mockError), + }; + + renderHook( + () => useGetSearchedMessages( + { + currentChannel: mockChannel as unknown as GroupChannel, + channelUrl: 'channel-url', + requestString: 'search-term', + onResultLoaded: mockOnResultLoaded, + }, + { + sdk: mockSdk as any, + logger: mockLogger as any, + }, + ), + ); + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockStartMessageSearch).toHaveBeenCalled(); + expect(mockChannel.refresh).toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'MessageSearch | useGetSearchedMessages: failed getting channel.', + mockError, + ); + expect(mockSetQueryInvalid).toHaveBeenCalled(); + expect(mockOnResultLoaded).toHaveBeenCalledWith(undefined, mockError); + }); + + it('should handle message search failure', async () => { + const mockError = new Error('Search failed'); + const mockQuery = { + next: jest.fn().mockRejectedValue(mockError), + }; + + mockSdk.createMessageSearchQuery.mockReturnValue(mockQuery); + + const mockChannel = { + url: 'channel-url', + refresh: jest.fn().mockResolvedValue({ + invitedAt: 1234567890, + }), + }; + + renderHook( + () => useGetSearchedMessages( + { + currentChannel: mockChannel as unknown as GroupChannel, + channelUrl: 'channel-url', + requestString: 'search-term', + onResultLoaded: mockOnResultLoaded, + }, + { + sdk: mockSdk as any, + logger: mockLogger as any, + }, + ), + ); + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockStartMessageSearch).toHaveBeenCalled(); + expect(mockChannel.refresh).toHaveBeenCalled(); + expect(mockSdk.createMessageSearchQuery).toHaveBeenCalled(); + expect(mockStartGettingSearchedMessages).toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'MessageSearch | useGetSearchedMessages: failed getting search messages.', + mockError, + ); + expect(mockSetQueryInvalid).toHaveBeenCalled(); + expect(mockOnResultLoaded).toHaveBeenCalledWith(undefined, mockError); + }); + + it('should use custom messageSearchQuery params when provided', async () => { + const mockMessages = [{ messageId: 1 }]; + const mockQuery = { + next: jest.fn().mockResolvedValue(mockMessages), + }; + + mockSdk.createMessageSearchQuery.mockReturnValue(mockQuery); + + const mockChannel = { + url: 'channel-url', + refresh: jest.fn().mockResolvedValue({ + invitedAt: 1234567890, + }), + }; + + const customSearchQuery = { + limit: 20, + reverse: true, + exactMatch: false, + }; + + renderHook( + () => useGetSearchedMessages( + { + currentChannel: mockChannel as unknown as GroupChannel, + channelUrl: 'channel-url', + requestString: 'search-term', + messageSearchQuery: customSearchQuery, + onResultLoaded: mockOnResultLoaded, + }, + { + sdk: mockSdk as any, + logger: mockLogger as any, + }, + ), + ); + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockSdk.createMessageSearchQuery).toHaveBeenCalledWith( + expect.objectContaining(customSearchQuery), + ); + }); + + it('should not proceed when required dependencies are missing', () => { + renderHook( + () => useGetSearchedMessages( + { + currentChannel: null, + channelUrl: 'channel-url', + requestString: 'search-term', + onResultLoaded: mockOnResultLoaded, + }, + { + sdk: null as any, + logger: mockLogger as any, + }, + ), + ); + + expect(mockStartMessageSearch).toHaveBeenCalled(); + expect(mockSdk.createMessageSearchQuery).not.toHaveBeenCalled(); + }); + + it('should handle retry mechanism when retryCount changes', async () => { + const mockMessages = [{ messageId: 1 }]; + const mockQuery = { + next: jest.fn().mockResolvedValue(mockMessages), + }; + + mockSdk.createMessageSearchQuery.mockReturnValue(mockQuery); + + const mockChannel = { + url: 'channel-url', + refresh: jest.fn().mockResolvedValue({ + invitedAt: 1234567890, + }), + }; + + const { rerender } = renderHook( + () => useGetSearchedMessages( + { + currentChannel: mockChannel as unknown as GroupChannel, + channelUrl: 'channel-url', + requestString: 'search-term', + onResultLoaded: mockOnResultLoaded, + }, + { + sdk: mockSdk as any, + logger: mockLogger as any, + }, + ), + ); + + // Simulate retry by changing retryCount + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + retryCount: 1, + }, + actions: { + startMessageSearch: mockStartMessageSearch, + getSearchedMessages: mockGetSearchedMessages, + setQueryInvalid: mockSetQueryInvalid, + startGettingSearchedMessages: mockStartGettingSearchedMessages, + }, + }); + + rerender(); + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockStartMessageSearch).toHaveBeenCalledTimes(2); + expect(mockChannel.refresh).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/modules/MessageSearch/context/__test__/useScrollCallback.spec.ts b/src/modules/MessageSearch/context/__test__/useScrollCallback.spec.ts new file mode 100644 index 0000000000..56fffd51c2 --- /dev/null +++ b/src/modules/MessageSearch/context/__test__/useScrollCallback.spec.ts @@ -0,0 +1,209 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useScrollCallback from '../hooks/useScrollCallback'; +import useMessageSearch from '../hooks/useMessageSearch'; + +jest.mock('../hooks/useMessageSearch', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe('useScrollCallback', () => { + const mockLogger = { + warning: jest.fn(), + info: jest.fn(), + }; + + const mockOnResultLoaded = jest.fn(); + const mockGetNextSearchedMessages = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + currentMessageSearchQuery: null, + hasMoreResult: false, + }, + actions: { + getNextSearchedMessages: mockGetNextSearchedMessages, + }, + }); + }); + + it('should log warning when there are no more results', () => { + const { result } = renderHook(() => useScrollCallback( + { onResultLoaded: mockOnResultLoaded }, + { logger: mockLogger as any }, + ), + ); + + const callback = jest.fn(); + result.current(callback); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'MessageSearch | useScrollCallback: no more searched results', + false, + ); + }); + + it('should log warning when there is no currentMessageSearchQuery', () => { + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + currentMessageSearchQuery: null, + hasMoreResult: true, + }, + actions: { + getNextSearchedMessages: mockGetNextSearchedMessages, + }, + }); + + const { result } = renderHook(() => useScrollCallback( + { onResultLoaded: mockOnResultLoaded }, + { logger: mockLogger as any }, + ), + ); + + const callback = jest.fn(); + result.current(callback); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'MessageSearch | useScrollCallback: no currentMessageSearchQuery', + ); + }); + + it('should handle successful message search', async () => { + const mockMessages = [{ messageId: 1 }, { messageId: 2 }]; + const mockNext = jest.fn().mockResolvedValue(mockMessages); + + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + currentMessageSearchQuery: { + hasNext: true, + next: mockNext, + }, + hasMoreResult: true, + }, + actions: { + getNextSearchedMessages: mockGetNextSearchedMessages, + }, + }); + + const { result } = renderHook(() => useScrollCallback( + { onResultLoaded: mockOnResultLoaded }, + { logger: mockLogger as any }, + ), + ); + + const callback = jest.fn(); + await result.current(callback); + + expect(mockNext).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'MessageSearch | useScrollCallback: succeeded getting searched messages', + mockMessages, + ); + expect(mockGetNextSearchedMessages).toHaveBeenCalledWith(mockMessages); + expect(callback).toHaveBeenCalledWith(mockMessages, null); + expect(mockOnResultLoaded).toHaveBeenCalledWith(mockMessages, null); + }); + + it('should handle failed message search', async () => { + const mockError = new Error('Search failed'); + const mockNext = jest.fn().mockRejectedValue(mockError); + + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + currentMessageSearchQuery: { + hasNext: true, + next: mockNext, + }, + hasMoreResult: true, + }, + actions: { + getNextSearchedMessages: mockGetNextSearchedMessages, + }, + }); + + const { result } = renderHook(() => useScrollCallback( + { onResultLoaded: mockOnResultLoaded }, + { logger: mockLogger as any }, + ), + ); + + const callback = jest.fn(); + + try { + await result.current(callback); + } catch (error) { + // execute even if error occurs + } + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockNext).toHaveBeenCalled(); + expect(mockLogger.warning).toHaveBeenCalledWith( + 'MessageSearch | useScrollCallback: failed getting searched messages', + mockError, + ); + expect(callback).toHaveBeenCalledWith(null, mockError); + expect(mockOnResultLoaded).toHaveBeenCalledWith(null, mockError); + }); + + it('should not call onResultLoaded if not provided', async () => { + const mockMessages = [{ messageId: 1 }]; + const mockNext = jest.fn().mockResolvedValue(mockMessages); + + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + currentMessageSearchQuery: { + hasNext: true, + next: mockNext, + }, + hasMoreResult: true, + }, + actions: { + getNextSearchedMessages: mockGetNextSearchedMessages, + }, + }); + + const { result } = renderHook(() => useScrollCallback( + { onResultLoaded: undefined }, + { logger: mockLogger as any }, + ), + ); + + const callback = jest.fn(); + await result.current(callback); + + expect(mockNext).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(mockMessages, null); + expect(mockOnResultLoaded).not.toHaveBeenCalled(); + }); + + it('should not proceed with search if query has no next', () => { + (useMessageSearch as jest.Mock).mockReturnValue({ + state: { + currentMessageSearchQuery: { + hasNext: false, + }, + hasMoreResult: true, + }, + actions: { + getNextSearchedMessages: mockGetNextSearchedMessages, + }, + }); + + const { result } = renderHook(() => useScrollCallback( + { onResultLoaded: mockOnResultLoaded }, + { logger: mockLogger as any }, + ), + ); + + const callback = jest.fn(); + result.current(callback); + + expect(mockLogger.warning).toHaveBeenCalledWith( + 'MessageSearch | useScrollCallback: no currentMessageSearchQuery', + ); + }); +}); diff --git a/src/modules/MessageSearch/context/__test__/useSearchStringEffect.spec.ts b/src/modules/MessageSearch/context/__test__/useSearchStringEffect.spec.ts new file mode 100644 index 0000000000..dbf3b2b662 --- /dev/null +++ b/src/modules/MessageSearch/context/__test__/useSearchStringEffect.spec.ts @@ -0,0 +1,176 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import useSearchStringEffect from '../hooks/useSearchStringEffect'; +import useMessageSearch from '../hooks/useMessageSearch'; + +jest.mock('../hooks/useMessageSearch', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.useFakeTimers(); + +describe('useSearchStringEffect', () => { + const mockResetSearchString = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useMessageSearch as jest.Mock).mockReturnValue({ + actions: { + resetSearchString: mockResetSearchString, + }, + }); + }); + + it('should set request string after debounce when search string is provided', async () => { + const { result } = renderHook(() => useSearchStringEffect({ searchString: 'test query' }), + ); + + // Initial state should be empty + expect(result.current).toBe(''); + + // Fast-forward debounce timer + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe('test query'); + }); + + it('should reset search string when empty string is provided', async () => { + const { result } = renderHook(() => useSearchStringEffect({ searchString: '' }), + ); + + // Fast-forward debounce timer + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe(''); + expect(mockResetSearchString).toHaveBeenCalled(); + }); + + it('should handle undefined search string', async () => { + const { result } = renderHook(() => useSearchStringEffect({ searchString: undefined }), + ); + + // Fast-forward debounce timer + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe(''); + expect(mockResetSearchString).toHaveBeenCalled(); + }); + + it('should clear previous timer when search string changes rapidly', () => { + const { result, rerender } = renderHook( + ({ searchString }) => useSearchStringEffect({ searchString }), + { initialProps: { searchString: 'initial' } }, + ); + + // Start first timer + act(() => { + jest.advanceTimersByTime(200); // Advance less than debounce time + }); + + // Change search string before first timer completes + rerender({ searchString: 'updated' }); + + // Advance timer to complete first debounce + act(() => { + jest.advanceTimersByTime(300); + }); + + // Result should not be 'initial' + expect(result.current).not.toBe('initial'); + + // Complete second debounce + act(() => { + jest.advanceTimersByTime(200); + }); + + // Result should be 'updated' + expect(result.current).toBe('updated'); + }); + + it('should clean up timer on unmount', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + const { unmount } = renderHook(() => useSearchStringEffect({ searchString: 'test' }), + ); + + unmount(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('should not trigger unnecessary updates when search string remains the same', () => { + const { result, rerender } = renderHook( + ({ searchString }) => useSearchStringEffect({ searchString }), + { initialProps: { searchString: 'test' } }, + ); + + // Complete first debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + const firstResult = result.current; + + // Rerender with same search string + rerender({ searchString: 'test' }); + + // Complete second debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe(firstResult); + }); + + it('should handle multiple search string changes within debounce period', () => { + const { result, rerender } = renderHook( + ({ searchString }) => useSearchStringEffect({ searchString }), + { initialProps: { searchString: 'first' } }, + ); + + // Change search string multiple times rapidly + rerender({ searchString: 'second' }); + rerender({ searchString: 'third' }); + rerender({ searchString: 'final' }); + + // Advance timer to complete debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + // Should only reflect the final value + expect(result.current).toBe('final'); + }); + + it('should maintain empty state when switching from empty to undefined', () => { + const { result, rerender } = renderHook( + ({ searchString }) => useSearchStringEffect({ searchString }), + { initialProps: { searchString: '' } }, + ); + + // Complete first debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe(''); + expect(mockResetSearchString).toHaveBeenCalledTimes(1); + + // Switch to undefined + rerender({ searchString: undefined }); + + // Complete second debounce + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe(''); + expect(mockResetSearchString).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/modules/MessageSearch/context/__test__/useSetChannel.spec.ts b/src/modules/MessageSearch/context/__test__/useSetChannel.spec.ts new file mode 100644 index 0000000000..6dabb4f498 --- /dev/null +++ b/src/modules/MessageSearch/context/__test__/useSetChannel.spec.ts @@ -0,0 +1,103 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useSetChannel from '../hooks/useSetChannel'; +import useMessageSearch from '../hooks/useMessageSearch'; + +jest.mock('../hooks/useMessageSearch', () => ({ + __esModule: true, + default: jest.fn(), +})); + +describe('useSetChannel', () => { + const mockLogger = { + info: jest.fn(), + warning: jest.fn(), + }; + + const mockSetCurrentChannel = jest.fn(); + const mockSetChannelInvalid = jest.fn(); + + const mockSdk = { + groupChannel: { + getChannel: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useMessageSearch as jest.Mock).mockReturnValue({ + actions: { + setCurrentChannel: mockSetCurrentChannel, + setChannelInvalid: mockSetChannelInvalid, + }, + }); + }); + + it('should set current channel when channelUrl and sdkInit are valid', async () => { + const mockChannel = { url: 'test-channel' }; + mockSdk.groupChannel.getChannel.mockResolvedValue(mockChannel); + + renderHook(() => useSetChannel( + { channelUrl: 'test-channel', sdkInit: true }, + { sdk: mockSdk as any, logger: mockLogger as any }, + )); + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockSdk.groupChannel.getChannel).toHaveBeenCalledWith('test-channel'); + expect(mockLogger.info).toHaveBeenCalledWith( + 'MessageSearch | useSetChannel group channel', + mockChannel, + ); + expect(mockSetCurrentChannel).toHaveBeenCalledWith(mockChannel); + }); + + it('should set channel invalid when getChannel fails', async () => { + const mockError = new Error('Failed to get channel'); + mockSdk.groupChannel.getChannel.mockRejectedValue(mockError); + + renderHook(() => useSetChannel( + { channelUrl: 'test-channel', sdkInit: true }, + { sdk: mockSdk as any, logger: mockLogger as any }, + )); + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockSdk.groupChannel.getChannel).toHaveBeenCalledWith('test-channel'); + expect(mockSetChannelInvalid).toHaveBeenCalled(); + }); + + it('should not attempt to get channel if sdkInit is false', () => { + renderHook(() => useSetChannel( + { channelUrl: 'test-channel', sdkInit: false }, + { sdk: mockSdk as any, logger: mockLogger as any }, + )); + + expect(mockSdk.groupChannel.getChannel).not.toHaveBeenCalled(); + expect(mockSetCurrentChannel).not.toHaveBeenCalled(); + expect(mockSetChannelInvalid).not.toHaveBeenCalled(); + }); + + it('should not attempt to get channel if channelUrl is empty', () => { + renderHook(() => useSetChannel( + { channelUrl: '', sdkInit: true }, + { sdk: mockSdk as any, logger: mockLogger as any }, + )); + + expect(mockSdk.groupChannel.getChannel).not.toHaveBeenCalled(); + expect(mockSetCurrentChannel).not.toHaveBeenCalled(); + expect(mockSetChannelInvalid).not.toHaveBeenCalled(); + }); + + it('should handle missing sdk gracefully', () => { + renderHook(() => useSetChannel( + { channelUrl: 'test-channel', sdkInit: true }, + { sdk: null as any, logger: mockLogger as any }, + )); + + expect(mockSdk.groupChannel.getChannel).not.toHaveBeenCalled(); + expect(mockSetCurrentChannel).not.toHaveBeenCalled(); + expect(mockSetChannelInvalid).not.toHaveBeenCalled(); + }); +}); From 41018898d1cd99bcf676a6a640e888c65cc0fe48 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Tue, 10 Dec 2024 20:20:28 +0900 Subject: [PATCH 22/29] [CLNP-6022] fix scroll position issue when switching GroupChannel (#1282) Fixes https://sendbird.atlassian.net/browse/CLNP-6022 This PR addresses an issue where the scroll position was not correctly set to the bottom when switching from a channel with few messages to one with many messages. The problem was resolved by adding a delay to ensure the scroll reference is updated before attempting to scroll to the bottom. This change ensures that users always see the latest messages when switching channels. --- .../context/hooks/useGroupChannel.ts | 7 ++-- .../context/__test__/useThread.spec.tsx | 1 - src/utils/__tests__/utils.spec.ts | 39 ++++++++++++++++++- src/utils/utils.ts | 5 +++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index 59653fcec3..9827d59890 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -19,6 +19,7 @@ import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; import { GroupChannelContext } from '../GroupChannelProvider'; import type { GroupChannelState, MessageActions } from '../types'; import { useMessageActions } from './useMessageActions'; +import { delay } from '../../../../utils/utils'; export interface GroupChannelActions extends MessageActions { // Channel actions @@ -69,16 +70,16 @@ export const useGroupChannel = () => { const scrollToBottom = async (animated?: boolean) => { if (!state.scrollRef.current) return; + // wait a bit for scroll ref to be updated + await delay(); 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 }); } + state.scrollPubSub.publish('scrollToBottom', { animated }); if (state.currentChannel && !state.hasNext()) { state.resetNewMessages(); diff --git a/src/modules/Thread/context/__test__/useThread.spec.tsx b/src/modules/Thread/context/__test__/useThread.spec.tsx index 9fb264c534..b4329e0a3a 100644 --- a/src/modules/Thread/context/__test__/useThread.spec.tsx +++ b/src/modules/Thread/context/__test__/useThread.spec.tsx @@ -753,7 +753,6 @@ describe('useThread', () => { }); await waitFor(() => { - console.log(result.current.state.localThreadMessages[0]); expect(result.current.state.localThreadMessages[0].messageParams.fileInfoList).toContain(newFileInfo); }); }); diff --git a/src/utils/__tests__/utils.spec.ts b/src/utils/__tests__/utils.spec.ts index 16b1224f1a..ca9aac37ad 100644 --- a/src/utils/__tests__/utils.spec.ts +++ b/src/utils/__tests__/utils.spec.ts @@ -6,7 +6,7 @@ import { isMultipleFilesMessage, } from '../index'; import { AdminMessage, FileMessage, MultipleFilesMessage, UserMessage } from '@sendbird/chat/message'; -import { deleteNullish } from '../utils'; +import { delay, deleteNullish } from '../utils'; import { isMobileIOS } from '../browser'; describe('Global-utils: verify message type util functions', () => { @@ -234,3 +234,40 @@ describe('deleteNullish', () => { expect(component({ a: null, b: '3', c: 4 })).toEqual({ a: 1, b: '3', c: 4 }); }); }); + +describe('delay', () => { + it('should resolve after the specified time', async () => { + const start = Date.now(); + const delayTime = 100; + + await delay(delayTime); + + const end = Date.now(); + const elapsed = end - start; + + // Check if the elapsed time is at least the delay time + expect(elapsed).toBeGreaterThanOrEqual(delayTime); + }); + + it('should resolve immediately for 0 milliseconds', async () => { + const start = Date.now(); + + await delay(0); + + const end = Date.now(); + const elapsed = end - start; + + // Check if the elapsed time is very small + expect(elapsed).toBeLessThan(10); + }); + it('should resolve immediately when no parameter is provided', async () => { + const start = Date.now(); + + await delay(); + + const end = Date.now(); + const elapsed = end - start; + + expect(elapsed).toBeLessThan(10); + }); +}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 844c66969d..45137ae1da 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,10 @@ import { SendableMessageType } from './index'; +/** + * @param ms - milliseconds to delay + * @returns Promise that resolves after the specified time + */ +export const delay = (ms?: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); export const noop = () => { /** noop * */ }; From 21a625df21de23c74f0a3bd3302c19337bb20026 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Wed, 11 Dec 2024 15:13:15 +0900 Subject: [PATCH 23/29] Resolve conflicts --- src/lib/SendbirdProvider.migration.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/SendbirdProvider.migration.spec.tsx b/src/lib/SendbirdProvider.migration.spec.tsx index 42c9e996a9..85d973716c 100644 --- a/src/lib/SendbirdProvider.migration.spec.tsx +++ b/src/lib/SendbirdProvider.migration.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, renderHook, screen } from '@testing-library/react'; import SendbirdProvider, { SendbirdProviderProps } from './Sendbird'; -import useSendbirdStateContext from '../hooks/useSendbirdStateContext'; +import useSendbirdStateContext from './Sendbird/context/hooks/useSendbirdStateContext'; import { match } from 'ts-pattern'; import { DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, DEFAULT_UPLOAD_SIZE_LIMIT } from '../utils/consts'; From 5890933670cf4c9e8473eb02fe784541d5ef2f2c Mon Sep 17 00:00:00 2001 From: HoonBaek Date: Wed, 11 Dec 2024 15:27:51 +0900 Subject: [PATCH 24/29] used useSendbird for mocking instead of useSendbirdStateContext --- .../__test__/ChannelSettings.migration.spec.tsx | 14 ++++++-------- .../__tests__/CreateChannel.migration.spec.tsx | 14 +++++--------- .../__tests__/GroupChannel.migration.spec.tsx | 16 +++++++--------- .../GroupChannelList.migration.spec.tsx | 14 +++++--------- .../__tests__/MessageSearch.migration.spec.tsx | 11 +++++++++-- .../context/__test__/Thread.migration.spec.tsx | 13 +++++-------- 6 files changed, 37 insertions(+), 45 deletions(-) diff --git a/src/modules/ChannelSettings/__test__/ChannelSettings.migration.spec.tsx b/src/modules/ChannelSettings/__test__/ChannelSettings.migration.spec.tsx index 4f2f0e62b6..989d6935d1 100644 --- a/src/modules/ChannelSettings/__test__/ChannelSettings.migration.spec.tsx +++ b/src/modules/ChannelSettings/__test__/ChannelSettings.migration.spec.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { render, renderHook, screen } from '@testing-library/react'; -import { ChannelSettingsContextProps, ChannelSettingsProvider, useChannelSettingsContext } from '../context/ChannelSettingsProvider'; +import type { ChannelSettingsContextProps } from '../context/types'; +import { ChannelSettingsProvider, useChannelSettingsContext } from '../context/ChannelSettingsProvider'; import { match } from 'ts-pattern'; const mockState = { @@ -18,14 +19,11 @@ const mockState = { }, }, }; - -jest.mock('../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: jest.fn(() => mockState), -})); -jest.mock('../../../lib/Sendbird', () => ({ +const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; +jest.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, - useSendbirdStateContext: jest.fn(() => mockState), + default: jest.fn(() => ({ state: mockState, actions: mockActions })), + useSendbird: jest.fn(() => ({ state: mockState, actions: mockActions })), })); const mockProps: ChannelSettingsContextProps = { diff --git a/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx index 5cfb3ee418..892998c406 100644 --- a/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx +++ b/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx @@ -7,7 +7,7 @@ import { useCreateChannelContext, } from '../CreateChannelProvider'; -const mockSendbirdStateContext = { +const mockState = { stores: { userStore: { user: { @@ -34,16 +34,12 @@ const mockSendbirdStateContext = { }, isOnline: true, }, -}; - -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: () => mockSendbirdStateContext, -})); +};const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; -jest.mock('../../../../lib/Sendbird', () => ({ +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, - useSendbirdStateContext: () => mockSendbirdStateContext, + default: jest.fn(() => ({ state: mockState, actions: mockActions })), + useSendbird: jest.fn(() => ({ state: mockState, actions: mockActions })), })); const mockProps: CreateChannelProviderProps = { diff --git a/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx index c661bb3286..27de2c1035 100644 --- a/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { GroupChannelProvider, GroupChannelProviderProps, useGroupChannelContext } from '../GroupChannelProvider'; +import type { GroupChannelProviderProps } from '../types' +import { GroupChannelProvider, useGroupChannelContext } from '../GroupChannelProvider'; import { ThreadReplySelectType } from '../const'; import { match } from 'ts-pattern'; -const mockSendbirdStateContext = { +const mockState = { config: { pubSub: { subscribe: () => ({ remove: () => {} }) }, isOnline: true, @@ -31,14 +32,11 @@ const mockSendbirdStateContext = { }, }, }; - -jest.mock('../../../../lib/Sendbird', () => ({ - __esModule: true, - useSendbirdStateContext: () => mockSendbirdStateContext, -})); -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, - default: () => mockSendbirdStateContext, + default: jest.fn(() => ({ state: mockState, actions: mockActions })), + useSendbird: jest.fn(() => ({ state: mockState, actions: mockActions })), })); const mockProps: GroupChannelProviderProps = { diff --git a/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx b/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx index bd861c5bab..1e514799c2 100644 --- a/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx @@ -7,7 +7,7 @@ import { useGroupChannelListContext, } from '../GroupChannelListProvider'; -const mockSendbirdStateContext = { +const mockState = { stores: { userStore: { user: { @@ -35,15 +35,11 @@ const mockSendbirdStateContext = { isOnline: true, }, }; - -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ - __esModule: true, - default: () => mockSendbirdStateContext, -})); - -jest.mock('../../../../lib/Sendbird', () => ({ +const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, - useSendbirdStateContext: () => mockSendbirdStateContext, + default: jest.fn(() => ({ state: mockState, actions: mockActions })), + useSendbird: jest.fn(() => ({ state: mockState, actions: mockActions })), })); jest.mock('@sendbird/uikit-tools', () => ({ diff --git a/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx b/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx index 3f7f4296fe..56873456f4 100644 --- a/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx +++ b/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx @@ -3,9 +3,16 @@ import { render, screen } from '@testing-library/react'; import { MessageSearchProvider, useMessageSearchContext } from '../MessageSearchProvider'; import { match } from 'ts-pattern'; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +const mockState = { + stores: { sdkStore: {} }, + config: { logger: console, groupChannel: {} }, +}; +const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; + +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, - default: () => ({}), + default: jest.fn(() => ({ state: mockState, actions: mockActions })), + useSendbird: jest.fn(() => ({ state: mockState, actions: mockActions })), })); const mockProps = { diff --git a/src/modules/Thread/context/__test__/Thread.migration.spec.tsx b/src/modules/Thread/context/__test__/Thread.migration.spec.tsx index 262af72526..95c3512381 100644 --- a/src/modules/Thread/context/__test__/Thread.migration.spec.tsx +++ b/src/modules/Thread/context/__test__/Thread.migration.spec.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { ThreadProvider, ThreadProviderProps, useThreadContext } from '../ThreadProvider'; import { SendableMessageType } from '../../../../utils'; -const mockSendbirdStateContext = { +const mockState = { stores: { userStore: { }, @@ -30,15 +30,12 @@ const mockSendbirdStateContext = { isOnline: true, }, }; +const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; -jest.mock('../../../../hooks/useSendbirdStateContext', () => ({ +jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, - default: () => mockSendbirdStateContext, -})); - -jest.mock('../../../../lib/Sendbird', () => ({ - __esModule: true, - useSendbirdStateContext: () => mockSendbirdStateContext, + default: jest.fn(() => ({ state: mockState, actions: mockActions })), + useSendbird: jest.fn(() => ({ state: mockState, actions: mockActions })), })); jest.mock('../hooks/useThreadFetchers', () => ({ From 9c9f520f7f58c5b18fdddfbb023604a2d29750b5 Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Thu, 12 Dec 2024 16:55:00 +0900 Subject: [PATCH 25/29] Fix migration test failure (#1285) ### Description Fixed the failures of migration tests added by #1279 --- .../Sendbird/context/hooks/useSendbird.tsx | 5 +- src/lib/SendbirdProvider.migration.spec.tsx | 138 +++++++++++------- src/lib/hooks/useMessageTemplateUtils.ts | 2 +- .../__test__/ChannelSettingsProvider.spec.tsx | 48 +++--- ...gs.spec.ts => useChannelSettings.spec.tsx} | 54 +++---- .../context/ChannelSettingsProvider.tsx | 6 +- src/modules/ChannelSettings/context/types.ts | 4 +- .../context/useChannelSettings.ts | 6 +- .../CreateChannel.migration.spec.tsx | 2 +- .../context/GroupChannelProvider.tsx | 2 +- .../__tests__/GroupChannel.migration.spec.tsx | 17 +-- src/modules/GroupChannel/context/types.ts | 10 +- .../context/GroupChannelListProvider.tsx | 2 - .../GroupChannelList.migration.spec.tsx | 6 +- .../MessageSearchUI.integration.test.tsx | 4 +- .../components/MessageSearchUI/index.tsx | 4 +- .../context/MessageSearchProvider.tsx | 13 +- .../MessageSearch.migration.spec.tsx | 1 - .../MessageSearchProvider.spec.tsx | 14 +- .../useGetSearchedMessages.spec.ts | 0 .../useMessageSearch.spec.tsx | 6 +- .../useScrollCallback.spec.ts | 0 .../useSearchStringEffect.spec.ts | 0 .../useSetChannel.spec.ts | 0 .../context/hooks/useMessageSearch.ts | 16 +- src/modules/Thread/context/ThreadProvider.tsx | 13 +- .../__test__/Thread.migration.spec.tsx | 6 +- 27 files changed, 202 insertions(+), 177 deletions(-) rename src/modules/ChannelSettings/__test__/{useChannelSettings.spec.ts => useChannelSettings.spec.tsx} (68%) rename src/modules/MessageSearch/context/{__test__ => __tests__}/MessageSearchProvider.spec.tsx (96%) rename src/modules/MessageSearch/context/{__test__ => __tests__}/useGetSearchedMessages.spec.ts (100%) rename src/modules/MessageSearch/context/{__test__ => __tests__}/useMessageSearch.spec.tsx (96%) rename src/modules/MessageSearch/context/{__test__ => __tests__}/useScrollCallback.spec.ts (100%) rename src/modules/MessageSearch/context/{__test__ => __tests__}/useSearchStringEffect.spec.ts (100%) rename src/modules/MessageSearch/context/{__test__ => __tests__}/useSetChannel.spec.ts (100%) diff --git a/src/lib/Sendbird/context/hooks/useSendbird.tsx b/src/lib/Sendbird/context/hooks/useSendbird.tsx index 34844183a9..20dbf5bef7 100644 --- a/src/lib/Sendbird/context/hooks/useSendbird.tsx +++ b/src/lib/Sendbird/context/hooks/useSendbird.tsx @@ -1,4 +1,5 @@ -import { useContext, useMemo, useSyncExternalStore } from 'react'; +import { useContext, useMemo } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { SendbirdError, User } from '@sendbird/chat'; import { SendbirdContext } from '../SendbirdContext'; @@ -11,7 +12,7 @@ export const useSendbird = () => { const store = useContext(SendbirdContext); if (!store) throw new Error(NO_CONTEXT_ERROR); - const state = useSyncExternalStore(store.subscribe, store.getState); + const state: SendbirdState = useSyncExternalStore(store.subscribe, store.getState); const actions = useMemo(() => ({ /* Example: How to set the state basically */ // exampleAction: () => { diff --git a/src/lib/SendbirdProvider.migration.spec.tsx b/src/lib/SendbirdProvider.migration.spec.tsx index 85d973716c..d06344b19a 100644 --- a/src/lib/SendbirdProvider.migration.spec.tsx +++ b/src/lib/SendbirdProvider.migration.spec.tsx @@ -1,11 +1,60 @@ /* eslint-disable no-console */ -import React from 'react'; +import React, { act } from 'react'; import { render, renderHook, screen } from '@testing-library/react'; import SendbirdProvider, { SendbirdProviderProps } from './Sendbird'; import useSendbirdStateContext from './Sendbird/context/hooks/useSendbirdStateContext'; import { match } from 'ts-pattern'; import { DEFAULT_MULTIPLE_FILES_MESSAGE_LIMIT, DEFAULT_UPLOAD_SIZE_LIMIT } from '../utils/consts'; +jest.mock('@sendbird/chat', () => { + const mockConnect = jest.fn().mockResolvedValue({ + userId: 'test-user-id', + nickname: 'test-nickname', + profileUrl: 'test-profile-url', + }); + const mockDisconnect = jest.fn().mockResolvedValue(null); + const mockUpdateCurrentUserInfo = jest.fn().mockResolvedValue(null); + const mockAddExtension = jest.fn().mockReturnThis(); + const mockAddSendbirdExtensions = jest.fn().mockReturnThis(); + const mockGetMessageTemplatesByToken = jest.fn().mockResolvedValue({ + hasMore: false, + token: null, + templates: [], + }); + + const mockSdk = { + init: jest.fn().mockImplementation(() => mockSdk), + connect: mockConnect, + disconnect: mockDisconnect, + updateCurrentUserInfo: mockUpdateCurrentUserInfo, + addExtension: mockAddExtension, + addSendbirdExtensions: mockAddSendbirdExtensions, + GroupChannel: { createMyGroupChannelListQuery: jest.fn() }, + message: { + getMessageTemplatesByToken: mockGetMessageTemplatesByToken, + }, + appInfo: { + uploadSizeLimit: 1024 * 1024 * 5, + multipleFilesMessageFileCountLimit: 10, + }, + }; + + return { + __esModule: true, + default: mockSdk, + SendbirdProduct: { + UIKIT_CHAT: 'UIKIT_CHAT', + }, + SendbirdPlatform: { + JS: 'JS', + }, + DeviceOsPlatform: { + WEB: 'WEB', + MOBILE_WEB: 'MOBILE_WEB', + }, + }; +}); + const mockProps: SendbirdProviderProps = { appId: 'test-app-id', userId: 'test-user-id', @@ -39,37 +88,6 @@ const mockProps: SendbirdProviderProps = { children:
Test Child
, }; -const mockDisconnect = jest.fn(); -const mockConnect = jest.fn(); -const mockUpdateCurrentUserInfo = jest.fn(); - -/** - * Mocking Sendbird SDK - * sdk.connect causes DOMException issue in jest. - * Because it retries many times to connect indexDB. - */ -jest.mock('@sendbird/chat', () => { - return { - __esModule: true, - default: jest.fn().mockImplementation(() => { - return { - connect: mockConnect.mockResolvedValue({ - userId: 'test-user-id', - nickname: 'test-nickname', - profileUrl: 'test-profile-url', - }), - disconnect: mockDisconnect.mockResolvedValue(null), - updateCurrentUserInfo: mockUpdateCurrentUserInfo.mockResolvedValue(null), - GroupChannel: { createMyGroupChannelListQuery: jest.fn() }, - appInfo: { - uploadSizeLimit: 1024 * 1024 * 5, // 5MB - multipleFilesMessageFileCountLimit: 10, - }, - }; - }), - }; -}); - describe('SendbirdProvider Props & Context Interface Validation', () => { const originalConsoleError = console.error; let originalFetch; @@ -95,9 +113,6 @@ describe('SendbirdProvider Props & Context Interface Validation', () => { beforeEach(() => { jest.clearAllMocks(); - mockConnect.mockClear(); - mockDisconnect.mockClear(); - mockUpdateCurrentUserInfo.mockClear(); global.MediaRecorder = { isTypeSupported: jest.fn((type) => { @@ -119,24 +134,27 @@ describe('SendbirdProvider Props & Context Interface Validation', () => { }); it('should accept all legacy props without type errors', async () => { - const { rerender } = render( - - {mockProps.children} - , - ); + const { rerender } = await act(async () => ( + render( + + {mockProps.children} + , + ) + )); - rerender( - - {mockProps.children} - , - ); + await act(async () => ( + rerender( + + {mockProps.children} + , + ) + )); }); - it('should provide all expected keys in context', () => { + it('should provide all expected keys in context', async () => { const expectedKeys = [ 'config', 'stores', - 'dispatchers', 'eventHandlers', 'emojiManager', 'utils', @@ -159,11 +177,13 @@ describe('SendbirdProvider Props & Context Interface Validation', () => { ); }; - render( - - - , - ); + await act(() => ( + render( + + + , + ) + )); expectedKeys.forEach((key) => { const element = screen.getByTestId(`context-${key}`); @@ -171,7 +191,7 @@ describe('SendbirdProvider Props & Context Interface Validation', () => { }); }); - it('should pass all expected values to the config object', () => { + it('should pass all expected values to the config object', async () => { const mockProps: SendbirdProviderProps = { appId: 'test-app-id', userId: 'test-user-id', @@ -192,7 +212,10 @@ describe('SendbirdProvider Props & Context Interface Validation', () => { {children} ); - const { result } = renderHook(() => useSendbirdStateContext(), { wrapper }); + let result; + await act(async () => { + result = renderHook(() => useSendbirdStateContext(), { wrapper }).result; + }); const config = result.current.config; @@ -220,14 +243,17 @@ describe('SendbirdProvider Props & Context Interface Validation', () => { expect(config.markAsDeliveredScheduler).toBeDefined(); }); - it('should handle optional and default values correctly', () => { + it('should handle optional and default values correctly', async () => { const wrapper = ({ children }) => ( {children} ); - const { result } = renderHook(() => useSendbirdStateContext(), { wrapper }); + let result; + await act(async () => { + result = renderHook(() => useSendbirdStateContext(), { wrapper }).result; + }); expect(result.current.config.pubSub).toBeDefined(); expect(result.current.config.logger).toBeDefined(); diff --git a/src/lib/hooks/useMessageTemplateUtils.ts b/src/lib/hooks/useMessageTemplateUtils.ts index 50907bfcc3..64766307ec 100644 --- a/src/lib/hooks/useMessageTemplateUtils.ts +++ b/src/lib/hooks/useMessageTemplateUtils.ts @@ -197,7 +197,7 @@ export default function useMessageTemplateUtils({ }, [ actions.upsertMessageTemplates, actions.upsertWaitingTemplateKeys, - sdk.message?.getMessageTemplatesByToken, + sdk?.message?.getMessageTemplatesByToken, ]); return { getCachedTemplate, diff --git a/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx index cad11298ce..682aafb77c 100644 --- a/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx +++ b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx @@ -13,26 +13,24 @@ const mockLogger = { error: jest.fn(), }; +const mockStore = { + getState: jest.fn(), + setState: jest.fn(), + subscribe: jest.fn(() => jest.fn()), +}; + const initialState = { channelUrl: 'test-channel', - onCloseClick: undefined, - onLeaveChannel: undefined, - onChannelModified: undefined, - onBeforeUpdateChannel: undefined, - renderUserListItem: undefined, - queries: undefined, - overrideInviteUser: undefined, channel: null, loading: false, invalidChannel: false, - forceUpdateUI: expect.any(Function), - setChannelUpdateId: expect.any(Function), }; describe('ChannelSettingsProvider', () => { let wrapper; beforeEach(() => { + mockStore.getState.mockReturnValue(initialState); useSendbird.mockReturnValue({ state: { stores: { sdkStore: { sdk: {}, initialized: true } }, @@ -51,10 +49,13 @@ describe('ChannelSettingsProvider', () => { jest.clearAllMocks(); }); - it('provides the correct initial state', () => { + it('provides the correct initial state and actions', () => { const { result } = renderHook(() => useChannelSettingsContext(), { wrapper }); - expect(result.current.getState()).toEqual(expect.objectContaining(initialState)); + expect(result.current.channelUrl).toBe(initialState.channelUrl); + expect(result.current.channel).toBe(initialState.channel); + expect(result.current.loading).toBe(initialState.loading); + expect(result.current.invalidChannel).toBe(initialState.invalidChannel); }); it('logs a warning if SDK is not initialized', () => { @@ -66,32 +67,29 @@ describe('ChannelSettingsProvider', () => { }); renderHook(() => useChannelSettingsContext(), { wrapper }); - expect(mockLogger.warning).toHaveBeenCalledWith('ChannelSettings: SDK or GroupChannelModule is not available'); }); - it('updates state correctly when setChannelUpdateId is called', async () => { + it('updates channel state correctly', async () => { const { result } = renderHook(() => useChannelSettingsContext(), { wrapper }); + const newChannel = { url: 'new-channel' } as any; await act(async () => { - result.current.setState({ channelUrl: 'new-channel' }); - await waitForStateUpdate(); - expect(result.current.getState().channelUrl).toBe('new-channel'); + result.current.setChannel(newChannel); }); + + expect(result.current.channel).toEqual(newChannel); }); - it('maintains other state values when channel changes', async () => { + it('maintains loading and invalid states', async () => { const { result } = renderHook(() => useChannelSettingsContext(), { wrapper }); await act(async () => { - result.current.setState({ channel: { name: 'Updated Channel' } }); - await waitForStateUpdate(); - const updatedState = result.current.getState(); - expect(updatedState.channel).toEqual({ name: 'Updated Channel' }); - expect(updatedState.loading).toBe(false); - expect(updatedState.invalidChannel).toBe(false); + result.current.setLoading(true); + result.current.setInvalid(true); }); - }); - const waitForStateUpdate = () => new Promise(resolve => { setTimeout(resolve, 0); }); + expect(result.current.loading).toBe(true); + expect(result.current.invalidChannel).toBe(true); + }); }); diff --git a/src/modules/ChannelSettings/__test__/useChannelSettings.spec.ts b/src/modules/ChannelSettings/__test__/useChannelSettings.spec.tsx similarity index 68% rename from src/modules/ChannelSettings/__test__/useChannelSettings.spec.ts rename to src/modules/ChannelSettings/__test__/useChannelSettings.spec.tsx index 9abb21d5c3..24f38dc637 100644 --- a/src/modules/ChannelSettings/__test__/useChannelSettings.spec.ts +++ b/src/modules/ChannelSettings/__test__/useChannelSettings.spec.tsx @@ -1,32 +1,32 @@ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useChannelSettings } from '../context/useChannelSettings'; -import { useChannelSettingsContext } from '../context/ChannelSettingsProvider'; +import { ChannelSettingsContext } from '../context/ChannelSettingsProvider'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; -jest.mock('../context/ChannelSettingsProvider', () => ({ - useChannelSettingsContext: jest.fn(), -})); - -const mockStore = { - getState: jest.fn(), - setState: jest.fn(), - subscribe: jest.fn(() => jest.fn()), -}; - -const mockChannel: GroupChannel = { - url: 'test-channel', - name: 'Test Channel', -} as GroupChannel; - -beforeEach(() => { - jest.clearAllMocks(); - (useChannelSettingsContext as jest.Mock).mockReturnValue(mockStore); -}); - describe('useChannelSettings', () => { - it('throws an error if used outside of ChannelSettingsProvider', () => { - (useChannelSettingsContext as jest.Mock).mockReturnValueOnce(null); + const mockStore = { + getState: jest.fn(), + setState: jest.fn(), + subscribe: jest.fn(() => jest.fn()), + }; + + const mockChannel: GroupChannel = { + url: 'test-channel', + name: 'Test Channel', + } as GroupChannel; + + const wrapper = ({ children }) => ( + + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('throws an error if used outside of ChannelSettingsProvider', () => { const { result } = renderHook(() => useChannelSettings()); expect(result.error).toEqual( @@ -43,13 +43,13 @@ describe('useChannelSettings', () => { mockStore.getState.mockReturnValue(initialState); - const { result } = renderHook(() => useChannelSettings()); + const { result } = renderHook(() => useChannelSettings(), { wrapper }); expect(result.current.state).toEqual(initialState); }); it('calls setChannel with the correct channel object', () => { - const { result } = renderHook(() => useChannelSettings()); + const { result } = renderHook(() => useChannelSettings(), { wrapper }); act(() => { result.current.actions.setChannel(mockChannel); @@ -61,7 +61,7 @@ describe('useChannelSettings', () => { }); it('calls setLoading with the correct value', () => { - const { result } = renderHook(() => useChannelSettings()); + const { result } = renderHook(() => useChannelSettings(), { wrapper }); act(() => { result.current.actions.setLoading(true); @@ -73,7 +73,7 @@ describe('useChannelSettings', () => { }); it('calls setInvalid with the correct value', () => { - const { result } = renderHook(() => useChannelSettings()); + const { result } = renderHook(() => useChannelSettings(), { wrapper }); act(() => { result.current.actions.setInvalid(true); diff --git a/src/modules/ChannelSettings/context/ChannelSettingsProvider.tsx b/src/modules/ChannelSettings/context/ChannelSettingsProvider.tsx index c964c5ba86..f6a783d5ae 100644 --- a/src/modules/ChannelSettings/context/ChannelSettingsProvider.tsx +++ b/src/modules/ChannelSettings/context/ChannelSettingsProvider.tsx @@ -11,6 +11,7 @@ import { classnames } from '../../../utils/utils'; import { createStore } from '../../../utils/storeManager'; import { UserProfileProvider } from '../../../lib/UserProfileContext'; import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; +import useChannelSettings from './useChannelSettings'; export const ChannelSettingsContext = createContext> | null>(null); @@ -127,9 +128,8 @@ const ChannelSettingsProvider = (props: ChannelSettingsContextProps) => { }; const useChannelSettingsContext = () => { - const context = React.useContext(ChannelSettingsContext); - if (!context) throw new Error('ChannelSettingsContext not found. Use within the ChannelSettings module'); - return context; + const { state, actions } = useChannelSettings(); + return { ...state, ...actions }; }; export { ChannelSettingsProvider, useChannelSettingsContext }; diff --git a/src/modules/ChannelSettings/context/types.ts b/src/modules/ChannelSettings/context/types.ts index e3e1bb7865..685c0e586f 100644 --- a/src/modules/ChannelSettings/context/types.ts +++ b/src/modules/ChannelSettings/context/types.ts @@ -10,7 +10,7 @@ interface ApplicationUserListQuery { metaDataValuesFilter?: Array; } -interface ChannelSettingsQueries { +export interface ChannelSettingsQueries { applicationUserListQuery?: ApplicationUserListQuery; } @@ -20,7 +20,7 @@ type OverrideInviteUserType = { channel: GroupChannel; }; -interface CommonChannelSettingsProps { +export interface CommonChannelSettingsProps { channelUrl: string; onCloseClick?(): void; onLeaveChannel?(): void; diff --git a/src/modules/ChannelSettings/context/useChannelSettings.ts b/src/modules/ChannelSettings/context/useChannelSettings.ts index 3d67202be2..da099c8b4e 100644 --- a/src/modules/ChannelSettings/context/useChannelSettings.ts +++ b/src/modules/ChannelSettings/context/useChannelSettings.ts @@ -1,12 +1,12 @@ -import { useMemo } from 'react'; +import { useMemo, useContext } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; -import { useChannelSettingsContext } from './ChannelSettingsProvider'; +import { ChannelSettingsContext } from './ChannelSettingsProvider'; import { ChannelSettingsState } from './types'; export const useChannelSettings = () => { - const store = useChannelSettingsContext(); + const store = useContext(ChannelSettingsContext); if (!store) throw new Error('useChannelSettings must be used within a ChannelSettingsProvider'); const state: ChannelSettingsState = useSyncExternalStore(store.subscribe, store.getState); diff --git a/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx index 892998c406..e2e49caff7 100644 --- a/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx +++ b/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx @@ -34,7 +34,7 @@ const mockState = { }, isOnline: true, }, -};const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; +}; const mockActions = { connect: jest.fn(), disconnect: jest.fn() }; jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index f852955fa1..527e51a46c 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -311,7 +311,7 @@ const GroupChannelManager :React.FC> = (props) => { +const GroupChannelProvider: React.FC = (props) => { return ( diff --git a/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx index 27de2c1035..0c4d6db02f 100644 --- a/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { act } from 'react'; import { render, screen } from '@testing-library/react'; -import type { GroupChannelProviderProps } from '../types' +import type { GroupChannelProviderProps } from '../types'; import { GroupChannelProvider, useGroupChannelContext } from '../GroupChannelProvider'; import { ThreadReplySelectType } from '../const'; import { match } from 'ts-pattern'; @@ -57,8 +57,7 @@ const mockProps: GroupChannelProviderProps = { scrollBehavior: 'smooth', forceLeftToRightMessageLayout: false, - startingPoint: 0, - + startingPoint: undefined, // Message Focusing animatedMessageId: null, onMessageAnimated: jest.fn(), @@ -93,15 +92,15 @@ const mockProps: GroupChannelProviderProps = { describe('GroupChannel Migration Compatibility Tests', () => { // 1. Provider Props Interface test describe('GroupChannelProvider Props Compatibility', () => { - it('should accept all legacy props without type errors', () => { - const { rerender } = render( + it('should accept all legacy props without type errors', async () => { + const { rerender } = await act(async () => render( {mockProps.children} , - ); + )); // Props change scenario test - rerender( + await act(async () => rerender( { > {mockProps.children} , - ); + )); }); }); diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts index 0002a12227..76cff319e3 100644 --- a/src/modules/GroupChannel/context/types.ts +++ b/src/modules/GroupChannel/context/types.ts @@ -16,6 +16,7 @@ import { ReplyType } from '../../../types'; import { useMessageActions } from './hooks/useMessageActions'; import { useGroupChannelMessages } from '@sendbird/uikit-tools'; import { ThreadReplySelectType } from './const'; +import { PropsWithChildren } from 'react'; // Message data source types type MessageDataSource = ReturnType; @@ -66,8 +67,13 @@ interface InternalGroupChannelState extends MessageDataSource { scrollPubSub: PubSubTypes; } -export interface GroupChannelProviderProps extends - Pick { +export interface GroupChannelProviderProps extends PropsWithChildren< + Pick> { // Required channelUrl: string; diff --git a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx index d07fe617fe..405c14b2d2 100644 --- a/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx +++ b/src/modules/GroupChannelList/context/GroupChannelListProvider.tsx @@ -204,8 +204,6 @@ export const GroupChannelListManager: React.FC = typingChannelUrls, refreshing, initialized, - refresh, - loadMore, scrollRef, ]); useDeepCompareEffect(() => { diff --git a/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx b/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx index 1e514799c2..2bb9f5bba6 100644 --- a/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx +++ b/src/modules/GroupChannelList/context/__tests__/GroupChannelList.migration.spec.tsx @@ -44,13 +44,13 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ jest.mock('@sendbird/uikit-tools', () => ({ ...jest.requireActual('@sendbird/uikit-tools'), - useGroupChannelList: () => ({ + useGroupChannelList: jest.fn(() => ({ refreshing: false, initialized: true, - groupChannels: [], + groupChannels: [{ url: 'test-groupchannel-url-1', serialize: () => JSON.stringify(this) }], refresh: jest.fn(), loadMore: jest.fn(), - }), + })), })); const mockProps: GroupChannelListProviderProps = { diff --git a/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx b/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx index 5b7b074dc1..98581392b5 100644 --- a/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx +++ b/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx @@ -21,7 +21,7 @@ const mockLocalizationContext = { }; const defaultMockState = { - isQueryInvalid: false, + isInvalid: false, searchString: '', requestString: '', currentChannel: null, @@ -89,7 +89,7 @@ describe('MessageSearchUI Integration Tests', () => { it('handles error state and retry', async () => { const handleRetryToConnect = jest.fn(); renderComponent( - { isQueryInvalid: true, searchString: 'error query', requestString: 'error query' }, + { isInvalid: true, searchString: 'error query', requestString: 'error query' }, { handleRetryToConnect }, ); expect(screen.getByText(mockStringSet.PLACE_HOLDER__RETRY_TO_CONNECT)).toBeInTheDocument(); diff --git a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx index e81c497604..fb54b86e3b 100644 --- a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx +++ b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx @@ -35,7 +35,7 @@ export const MessageSearchUI: React.FC = ({ }: MessageSearchUIProps) => { const { state: { - isQueryInvalid, + isInvalid, searchString, requestString, currentChannel, @@ -83,7 +83,7 @@ export const MessageSearchUI: React.FC = ({ return stringSet.NO_TITLE; }; - if (isQueryInvalid && searchString && requestString) { + if (isInvalid && searchString && requestString) { return renderPlaceHolderError?.() || (
> | null>(null); @@ -162,9 +167,7 @@ const MessageSearchProvider: React.FC = ({ ); }; -/** - * Keep this function for backward compatibility. - */ +// Keep this function for backward compatibility. const useMessageSearchContext = () => { const { state, actions } = useMessageSearch(); return { ...state, ...actions }; diff --git a/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx b/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx index 56873456f4..7e1205d9ec 100644 --- a/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx +++ b/src/modules/MessageSearch/context/__tests__/MessageSearch.migration.spec.tsx @@ -62,7 +62,6 @@ describe('MessageSearch Migration Compatibility Tests', () => { 'setRetryCount', 'selectedMessageId', 'setSelectedMessageId', - 'messageSearchDispatcher', 'scrollRef', 'allMessages', 'loading', diff --git a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx b/src/modules/MessageSearch/context/__tests__/MessageSearchProvider.spec.tsx similarity index 96% rename from src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx rename to src/modules/MessageSearch/context/__tests__/MessageSearchProvider.spec.tsx index 4096e6bde4..c7f9e18e8f 100644 --- a/src/modules/MessageSearch/context/__test__/MessageSearchProvider.spec.tsx +++ b/src/modules/MessageSearch/context/__tests__/MessageSearchProvider.spec.tsx @@ -55,7 +55,7 @@ describe('MessageSearchProvider', () => { const initialState = { allMessages: [], loading: false, - isQueryInvalid: false, + isInvalid: false, initialized: false, currentChannel: null, currentMessageSearchQuery: null, @@ -113,7 +113,7 @@ describe('MessageSearchProvider', () => { expect(newState.channelUrl).toBe(newUrl); expect(newState.allMessages).toEqual(initialState.allMessages); expect(newState.loading).toBe(initialState.loading); - expect(newState.isQueryInvalid).toBe(initialState.isQueryInvalid); + expect(newState.isInvalid).toBe(initialState.isInvalid); }); }); }); @@ -154,7 +154,7 @@ describe('MessageSearchProvider', () => { expect(updatedState.initialized).toBe(true); // Verify other states remain unchanged expect(updatedState.loading).toBe(initialState.loading); - expect(updatedState.isQueryInvalid).toBe(initialState.isQueryInvalid); + expect(updatedState.isInvalid).toBe(initialState.isInvalid); expect(updatedState.allMessages).toEqual(initialState.allMessages); expect(updatedState.hasMoreResult).toBe(initialState.hasMoreResult); }); @@ -175,7 +175,7 @@ describe('MessageSearchProvider', () => { await waitFor(() => { const updatedState = result.current.state; expect(updatedState.loading).toBe(false); - expect(updatedState.isQueryInvalid).toBe(false); + expect(updatedState.isInvalid).toBe(false); // Verify other states remain unchanged expect(updatedState.allMessages).toEqual(initialState.allMessages); expect(updatedState.currentChannel).toBe(initialState.currentChannel); @@ -205,7 +205,7 @@ describe('MessageSearchProvider', () => { expect(updatedState.hasMoreResult).toBe(true); expect(updatedState.currentMessageSearchQuery).toEqual(mockQuery); // Verify other states remain unchanged - expect(updatedState.isQueryInvalid).toBe(initialState.isQueryInvalid); + expect(updatedState.isInvalid).toBe(initialState.isInvalid); expect(updatedState.initialized).toBe(initialState.initialized); }); }); @@ -264,7 +264,7 @@ describe('MessageSearchProvider', () => { result.current.actions.setQueryInvalid(); await waitFor(() => { const updatedState = result.current.state; - expect(updatedState.isQueryInvalid).toBe(true); + expect(updatedState.isInvalid).toBe(true); // Verify other states remain unchanged expect(updatedState.allMessages).toEqual(initialState.allMessages); expect(updatedState.loading).toBe(initialState.loading); @@ -291,7 +291,7 @@ describe('MessageSearchProvider', () => { const updatedState = result.current.state; expect(updatedState.allMessages).toEqual([]); // Verify other states remain unchanged - expect(updatedState.isQueryInvalid).toBe(initialState.isQueryInvalid); + expect(updatedState.isInvalid).toBe(initialState.isInvalid); expect(updatedState.currentChannel).toBe(initialState.currentChannel); }); }); diff --git a/src/modules/MessageSearch/context/__test__/useGetSearchedMessages.spec.ts b/src/modules/MessageSearch/context/__tests__/useGetSearchedMessages.spec.ts similarity index 100% rename from src/modules/MessageSearch/context/__test__/useGetSearchedMessages.spec.ts rename to src/modules/MessageSearch/context/__tests__/useGetSearchedMessages.spec.ts diff --git a/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx b/src/modules/MessageSearch/context/__tests__/useMessageSearch.spec.tsx similarity index 96% rename from src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx rename to src/modules/MessageSearch/context/__tests__/useMessageSearch.spec.tsx index dfcc16a5e4..10fc3eb867 100644 --- a/src/modules/MessageSearch/context/__test__/useMessageSearch.spec.tsx +++ b/src/modules/MessageSearch/context/__tests__/useMessageSearch.spec.tsx @@ -37,7 +37,7 @@ describe('useMessageSearch', () => { expect(result.current.state).toEqual(expect.objectContaining({ allMessages: [], loading: false, - isQueryInvalid: false, + isInvalid: false, initialized: false, currentChannel: null, currentMessageSearchQuery: null, @@ -63,7 +63,7 @@ describe('useMessageSearch', () => { result.current.actions.startMessageSearch(); }); - expect(result.current.state.isQueryInvalid).toBe(false); + expect(result.current.state.isInvalid).toBe(false); expect(result.current.state.loading).toBe(false); }); @@ -89,7 +89,7 @@ describe('useMessageSearch', () => { result.current.actions.setQueryInvalid(); }); - expect(result.current.state.isQueryInvalid).toBe(true); + expect(result.current.state.isInvalid).toBe(true); }); it('updates state when getNextSearchedMessages is called', () => { diff --git a/src/modules/MessageSearch/context/__test__/useScrollCallback.spec.ts b/src/modules/MessageSearch/context/__tests__/useScrollCallback.spec.ts similarity index 100% rename from src/modules/MessageSearch/context/__test__/useScrollCallback.spec.ts rename to src/modules/MessageSearch/context/__tests__/useScrollCallback.spec.ts diff --git a/src/modules/MessageSearch/context/__test__/useSearchStringEffect.spec.ts b/src/modules/MessageSearch/context/__tests__/useSearchStringEffect.spec.ts similarity index 100% rename from src/modules/MessageSearch/context/__test__/useSearchStringEffect.spec.ts rename to src/modules/MessageSearch/context/__tests__/useSearchStringEffect.spec.ts diff --git a/src/modules/MessageSearch/context/__test__/useSetChannel.spec.ts b/src/modules/MessageSearch/context/__tests__/useSetChannel.spec.ts similarity index 100% rename from src/modules/MessageSearch/context/__test__/useSetChannel.spec.ts rename to src/modules/MessageSearch/context/__tests__/useSetChannel.spec.ts diff --git a/src/modules/MessageSearch/context/hooks/useMessageSearch.ts b/src/modules/MessageSearch/context/hooks/useMessageSearch.ts index 14eab43384..6051ddb9a6 100644 --- a/src/modules/MessageSearch/context/hooks/useMessageSearch.ts +++ b/src/modules/MessageSearch/context/hooks/useMessageSearch.ts @@ -4,13 +4,13 @@ import { MessageSearchQuery } from '@sendbird/chat/message'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { ClientSentMessages } from '../../../../types'; -import { MessageSearchContext } from '../MessageSearchProvider'; +import { MessageSearchContext, type MessageSearchState } from '../MessageSearchProvider'; const useMessageSearch = () => { const store = useContext(MessageSearchContext); if (!store) throw new Error('useMessageSearch must be used within a MessageSearchProvider'); - const state = useSyncExternalStore(store.subscribe, store.getState); + const state: MessageSearchState = useSyncExternalStore(store.subscribe, store.getState); const actions = useMemo(() => ({ setCurrentChannel: (channel: GroupChannel) => store.setState(state => ({ ...state, @@ -31,7 +31,7 @@ const useMessageSearch = () => { return { ...state, loading: false, - isQueryInvalid: false, + isInvalid: false, allMessages: messages, hasMoreResult: state.currentMessageSearchQuery.hasNext, }; @@ -40,11 +40,11 @@ const useMessageSearch = () => { }); }, - setQueryInvalid: () => store.setState(state => ({ ...state, isQueryInvalid: true })), + setQueryInvalid: () => store.setState(state => ({ ...state, isInvalid: true })), startMessageSearch: () => store.setState(state => ({ ...state, - isQueryInvalid: false, + isInvalid: false, loading: false, })), @@ -71,6 +71,12 @@ const useMessageSearch = () => { ...state, retryCount: state.retryCount + 1, })), + + // Looks exactly same as handleRetryToConnect but keep just for backward compatibility + setRetryCount: () => store.setState(state => ({ + ...state, + retryCount: state.retryCount + 1, + })), }), [store]); return { state, actions }; diff --git a/src/modules/Thread/context/ThreadProvider.tsx b/src/modules/Thread/context/ThreadProvider.tsx index 8d3b0c9b11..0cac9cfda9 100644 --- a/src/modules/Thread/context/ThreadProvider.tsx +++ b/src/modules/Thread/context/ThreadProvider.tsx @@ -41,18 +41,7 @@ export interface ThreadProviderProps extends filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][]; } -export interface ThreadState { - channelUrl: string; - message: SendableMessageType | null; - onHeaderActionClick?: () => void; - onMoveToParentMessage?: (props: { message: SendableMessageType, channel: GroupChannel }) => void; - onBeforeSendUserMessage?: (message: string, quotedMessage?: SendableMessageType) => UserMessageCreateParams; - onBeforeSendFileMessage?: (file: File, quotedMessage?: SendableMessageType) => FileMessageCreateParams; - onBeforeSendVoiceMessage?: (file: File, quotedMessage?: SendableMessageType) => FileMessageCreateParams; - onBeforeSendMultipleFilesMessage?: (files: Array, quotedMessage?: SendableMessageType) => MultipleFilesMessageCreateParams; - onBeforeDownloadFileMessage?: OnBeforeDownloadFileMessageType; - isMultipleFilesMessageEnabled?: boolean; - filterEmojiCategoryIds?: (message: SendableMessageType) => EmojiCategory['id'][]; +export interface ThreadState extends ThreadProviderProps { currentChannel: GroupChannel; allThreadMessages: Array; localThreadMessages: Array; diff --git a/src/modules/Thread/context/__test__/Thread.migration.spec.tsx b/src/modules/Thread/context/__test__/Thread.migration.spec.tsx index 95c3512381..39e58d2cd8 100644 --- a/src/modules/Thread/context/__test__/Thread.migration.spec.tsx +++ b/src/modules/Thread/context/__test__/Thread.migration.spec.tsx @@ -40,9 +40,9 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ jest.mock('../hooks/useThreadFetchers', () => ({ useThreadFetchers: jest.fn().mockReturnValue({ - initialize: jest.fn(), - loadPrevious: jest.fn(), - loadNext: jest.fn(), + initializeThreadFetcher: jest.fn(), + fetchPrevThreads: jest.fn(), + fetchNextThreads: jest.fn(), }), })); From 38ff25bca7cd08ca7ec8617d5ce5e50457b0d56c Mon Sep 17 00:00:00 2001 From: Junyoung Lim Date: Fri, 13 Dec 2024 10:22:18 +0900 Subject: [PATCH 26/29] [CLNP-6003] test: Add tests for hooks in `src/hooks` (#1283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changelog * Added tests for hooks in `src/hooks` #### before 스크린샷 2024-12-10 오후 4 09 19 #### after 스크린샷 2024-12-10 오후 4 08 14 --- src/hooks/__tests__/useAsyncRequest.spec.tsx | 44 +++++++++++++ src/hooks/__tests__/useDebounce.spec.tsx | 28 +++++++++ src/hooks/__tests__/useLongPress.spec.tsx | 63 +++++++++++++++++++ src/hooks/__tests__/useMouseHover.spec.tsx | 38 +++++++++++ .../__tests__/useOutsideAlerter.spec.tsx | 35 +++++++++++ .../__tests__/useThrottleCallback.spec.tsx | 60 ++++++++++++++++++ src/hooks/useAppendDomNode.ts | 27 -------- src/hooks/useThrottleCallback.ts | 40 ------------ 8 files changed, 268 insertions(+), 67 deletions(-) create mode 100644 src/hooks/__tests__/useAsyncRequest.spec.tsx create mode 100644 src/hooks/__tests__/useDebounce.spec.tsx create mode 100644 src/hooks/__tests__/useLongPress.spec.tsx create mode 100644 src/hooks/__tests__/useMouseHover.spec.tsx create mode 100644 src/hooks/__tests__/useOutsideAlerter.spec.tsx create mode 100644 src/hooks/__tests__/useThrottleCallback.spec.tsx delete mode 100644 src/hooks/useAppendDomNode.ts diff --git a/src/hooks/__tests__/useAsyncRequest.spec.tsx b/src/hooks/__tests__/useAsyncRequest.spec.tsx new file mode 100644 index 0000000000..a953af14ee --- /dev/null +++ b/src/hooks/__tests__/useAsyncRequest.spec.tsx @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useAsyncRequest } from '../useAsyncRequest'; + +describe('useAsyncRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handle request with no response correctly', async () => { + const mockPromise = Promise.resolve(); + const mockRequest = jest.fn().mockReturnValue(mockPromise); + + const { result } = renderHook(() => useAsyncRequest(mockRequest)); + + await mockPromise; + + expect(result.current.loading).toBe(false); + }); + + it('handle request with response correctly', async () => { + const mockResponse = { code: 'ok' }; + const mockPromise = Promise.resolve(mockResponse); + const mockRequest = jest.fn().mockReturnValue(mockPromise); + + const { result } = renderHook(() => useAsyncRequest(mockRequest)); + + await mockPromise; + + expect(result.current.response).toBe(mockResponse); + expect(result.current.loading).toBe(false); + }); + + it('cancel request correctly', async () => { + const mockCancel = jest.fn(); + const mockRequest = { cancel: mockCancel }; + + const { unmount } = renderHook(() => useAsyncRequest(mockRequest)); + + unmount(); + + expect(mockCancel).toBeCalled(); + }); + +}); diff --git a/src/hooks/__tests__/useDebounce.spec.tsx b/src/hooks/__tests__/useDebounce.spec.tsx new file mode 100644 index 0000000000..c848fdfe8e --- /dev/null +++ b/src/hooks/__tests__/useDebounce.spec.tsx @@ -0,0 +1,28 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useDebounce } from '../useDebounce'; + +describe('useAsyncRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handle useDebounce correctly', async () => { + const mockFunction = jest.fn(); + const { result } = renderHook(() => useDebounce(mockFunction, 1000)); + + const debounceFunction = result.current; + + debounceFunction(); + debounceFunction(); + debounceFunction(); + debounceFunction(); + debounceFunction(); + + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + + expect(mockFunction).toBeCalledTimes(1); + }); + +}); diff --git a/src/hooks/__tests__/useLongPress.spec.tsx b/src/hooks/__tests__/useLongPress.spec.tsx new file mode 100644 index 0000000000..7b18d31e67 --- /dev/null +++ b/src/hooks/__tests__/useLongPress.spec.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import useLongPress from '../useLongPress'; +import { screen, fireEvent, render, waitFor } from '@testing-library/react'; + +describe('useLongPress', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handle long press correctly', async () => { + const mockOnLongPress = jest.fn(); + const mockOnClick = jest.fn(); + + const { result } = renderHook(() => useLongPress({ + onLongPress: mockOnLongPress, + onClick: mockOnClick, + })); + const { onTouchStart, onTouchEnd } = result.current; + + const targetComponent =
touch this
; + render(targetComponent); + + const element = screen.getByText('touch this'); + fireEvent.touchStart(element); + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + fireEvent.touchEnd(element); + + await waitFor(() => { + expect(mockOnLongPress).toHaveBeenCalled(); + }); + }); + + it('cancel long press if touch is too short', async () => { + const mockOnLongPress = jest.fn(); + const mockOnClick = jest.fn(); + + const { result } = renderHook(() => useLongPress({ + onLongPress: mockOnLongPress, + onClick: mockOnClick, + })); + const { onTouchStart, onTouchEnd } = result.current; + + const targetComponent =
touch this
; + render(targetComponent); + + const element = screen.getByText('touch this'); + fireEvent.touchStart(element); + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + fireEvent.touchEnd(element); + + await waitFor(() => { + expect(mockOnClick).toHaveBeenCalled(); + expect(mockOnLongPress).not.toHaveBeenCalled(); + }); + }); + +}); diff --git a/src/hooks/__tests__/useMouseHover.spec.tsx b/src/hooks/__tests__/useMouseHover.spec.tsx new file mode 100644 index 0000000000..eefd90231d --- /dev/null +++ b/src/hooks/__tests__/useMouseHover.spec.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { screen, fireEvent, render, waitFor } from '@testing-library/react'; +import useMouseHover from '../useMouseHover'; + +describe('useMouseHover', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handle mouse over and out correctly', async () => { + const mockSetHover = jest.fn(); + + const targetComponent =
hover
; + render(targetComponent); + + const hoverElement = screen.getByText('hover'); + const ref = { + current: hoverElement, + }; + + renderHook(() => useMouseHover({ + ref, + setHover: mockSetHover, + })); + + fireEvent.mouseEnter(hoverElement); + fireEvent.mouseLeave(hoverElement); + + await waitFor(() => { + expect(mockSetHover).toHaveBeenCalledTimes(2); + expect(mockSetHover).toHaveBeenCalledWith(true); + expect(mockSetHover).toHaveBeenCalledWith(false); + }); + }); + +}); diff --git a/src/hooks/__tests__/useOutsideAlerter.spec.tsx b/src/hooks/__tests__/useOutsideAlerter.spec.tsx new file mode 100644 index 0000000000..1cf288c3c6 --- /dev/null +++ b/src/hooks/__tests__/useOutsideAlerter.spec.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { screen, fireEvent, render, waitFor } from '@testing-library/react'; +import useOutsideAlerter from '../useOutsideAlerter'; + +describe('useOutsideAlerter', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handle click outside correctly', async () => { + const mockClickOutside = jest.fn(); + + const targetComponent =
inside
; + render(targetComponent); + + const insideElement = screen.getByText('inside'); + const ref = { + current: insideElement, + }; + + renderHook(() => useOutsideAlerter({ + ref, + callback: mockClickOutside, + })); + + fireEvent.mouseDown(insideElement); + + await waitFor(() => { + expect(mockClickOutside).toHaveBeenCalledTimes(1); + }); + }); + +}); diff --git a/src/hooks/__tests__/useThrottleCallback.spec.tsx b/src/hooks/__tests__/useThrottleCallback.spec.tsx new file mode 100644 index 0000000000..ecaadec1cd --- /dev/null +++ b/src/hooks/__tests__/useThrottleCallback.spec.tsx @@ -0,0 +1,60 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useThrottleCallback } from '../useThrottleCallback'; + +describe('useThrottleCallback', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handle throttle callback correctly when leading is true', async () => { + const mockCallback = jest.fn(); + + const { result: { current: throttleCallback } } = renderHook(() => useThrottleCallback(mockCallback, 1000, { leading: true })); + + throttleCallback(); + throttleCallback(); + throttleCallback(); + throttleCallback(); + throttleCallback(); + + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + expect(mockCallback).toHaveBeenCalledTimes(1); + + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + expect(mockCallback).toHaveBeenCalledTimes(1); + + }); + + it('handle throttle callback correctly when trailing is true', async () => { + const mockCallback = jest.fn(); + + const { result: { current: throttleCallback } } = renderHook(() => useThrottleCallback(mockCallback, 1000, { trailing: true })); + + throttleCallback(); + throttleCallback(); + throttleCallback(); + throttleCallback(); + throttleCallback(); + + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + expect(mockCallback).toHaveBeenCalledTimes(0); + + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + expect(mockCallback).toHaveBeenCalledTimes(1); + + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + +}); diff --git a/src/hooks/useAppendDomNode.ts b/src/hooks/useAppendDomNode.ts deleted file mode 100644 index c55b7dd97c..0000000000 --- a/src/hooks/useAppendDomNode.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect } from 'react'; - -function useAppendDomNode( - ids: string[] = [], - rootSelector = 'unknown', -) { - useEffect(() => { - const root = document.querySelector(rootSelector); - if (root) { - ids.forEach((id) => { - const elem = document.createElement('div'); - elem.setAttribute('id', id); - root.appendChild(elem); - }); - } - return () => { - if (root) { - ids.forEach((id) => { - const target = document.getElementById(id); - if (target) root.removeChild(target); - }); - } - }; - }, []); -} - -export default useAppendDomNode; diff --git a/src/hooks/useThrottleCallback.ts b/src/hooks/useThrottleCallback.ts index 99db7b175d..95b7a1d23d 100644 --- a/src/hooks/useThrottleCallback.ts +++ b/src/hooks/useThrottleCallback.ts @@ -46,43 +46,3 @@ export function useThrottleCallback void>( timer.current = setTimeout(invoke, delay); }) as T; } - -/** - * Note: `leading` has higher priority rather than `trailing` - * */ -export function throttle void>( - callback: T, - delay: number, - options: { leading?: boolean; trailing?: boolean } = { - leading: true, - trailing: false, - }, -) { - let timer: ReturnType | null = null; - let trailingArgs: null | any[] = null; - - return ((...args: any[]) => { - if (timer) { - trailingArgs = args; - return; - } - - if (options.leading) { - callback(...args); - } else { - trailingArgs = args; - } - - const invoke = () => { - if (options.trailing && trailingArgs) { - callback(...trailingArgs); - trailingArgs = null; - timer = setTimeout(invoke, delay); - } else { - timer = null; - } - }; - - timer = setTimeout(invoke, delay); - }) as T; -} From 7d4b63833a1f0ae6c1ca17f3bb8212ea7c9d056f Mon Sep 17 00:00:00 2001 From: "junyoung.lim" Date: Fri, 13 Dec 2024 13:36:14 +0900 Subject: [PATCH 27/29] Fix merge error --- .../__tests__/useMessageActions.spec.tsx | 1 + .../context/hooks/useGroupChannel.ts | 2 +- .../context/hooks/useMessageActions.ts | 21 ++++++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx index 396fb028d0..27be32680e 100644 --- a/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx @@ -54,6 +54,7 @@ jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ isOnline: true, pubSub: { subscribe: () => ({ remove: jest.fn() }), + publish: jest.fn(), }, }, }, diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index 9827d59890..3b9abcb57d 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -55,7 +55,7 @@ export const useGroupChannel = () => { if (!store) throw new Error('useGroupChannel must be used within a GroupChannelProvider'); const { state: { config } } = useSendbird(); - const { markAsReadScheduler } = config; + const { markAsReadScheduler, pubSub } = config; const state: GroupChannelState = useSyncExternalStore(store.subscribe, store.getState); const flagActions = { diff --git a/src/modules/GroupChannel/context/hooks/useMessageActions.ts b/src/modules/GroupChannel/context/hooks/useMessageActions.ts index 0989dcf709..d2d6d8958e 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts +++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts @@ -23,7 +23,7 @@ import { import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; import type { GroupChannelState, OnBeforeHandler } from '../types'; import type { CoreMessageType } from '../../../../utils'; -import { PublishingModuleType, PUBSUB_TOPICS, SBUGlobalPubSub } from '../../../../lib/pubSub/topics'; +import { PublishingModuleType, PUBSUB_TOPICS } from '../../../../lib/pubSub/topics'; import { GroupChannel } from '@sendbird/chat/groupChannel'; type MessageListDataSource = ReturnType; @@ -37,8 +37,7 @@ type MessageActions = { interface Params extends GroupChannelState { scrollToBottom(animated?: boolean): Promise; - pubSub: SBUGlobalPubSub; - channel: GroupChannel; + currentChannel: GroupChannel; } const pass = (value: T) => value; @@ -73,10 +72,16 @@ export function useMessageActions(params: Params): MessageActions { scrollToBottom, quoteMessage, replyType, - channel, - pubSub, + currentChannel, } = params; - const { state: { eventHandlers } } = useSendbird(); + const { + state: { + eventHandlers, + config: { + pubSub, + }, + }, + } = useSendbird(); const buildInternalMessageParams = useCallback( (basicParams: T): T => { const messageParams = { ...basicParams } as T; @@ -195,7 +200,7 @@ export function useMessageActions(params: Params): MessageActions { return updateUserMessage(messageId, processedParams) .then((message) => { pubSub.publish(PUBSUB_TOPICS.UPDATE_USER_MESSAGE, { - channel, + channel: currentChannel, message, publishingModules: [PublishingModuleType.CHANNEL], }); @@ -203,7 +208,7 @@ export function useMessageActions(params: Params): MessageActions { return message; }); }, - [buildInternalMessageParams, updateUserMessage, processParams, channel?.url], + [buildInternalMessageParams, updateUserMessage, processParams, currentChannel?.url], ), updateFileMessage, resendMessage, From 8ca7861e87af8edbf9c84e61e1077109e4e3ae93 Mon Sep 17 00:00:00 2001 From: "junyoung.lim" Date: Fri, 13 Dec 2024 13:39:04 +0900 Subject: [PATCH 28/29] Fix lint error --- src/modules/GroupChannel/context/hooks/useGroupChannel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index 3b9abcb57d..9827d59890 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -55,7 +55,7 @@ export const useGroupChannel = () => { if (!store) throw new Error('useGroupChannel must be used within a GroupChannelProvider'); const { state: { config } } = useSendbird(); - const { markAsReadScheduler, pubSub } = config; + const { markAsReadScheduler } = config; const state: GroupChannelState = useSyncExternalStore(store.subscribe, store.getState); const flagActions = { From 2c0387fdd7a9733521bc36f6abb118563977de79 Mon Sep 17 00:00:00 2001 From: Irene Ryu Date: Mon, 16 Dec 2024 11:44:57 +0900 Subject: [PATCH 29/29] [CLNP-6022] Memoize useGroupChannel actions to prevent unnecessary re-rendering (#1288) Addresses https://sendbird.atlassian.net/browse/CLNP-6022?focusedCommentId=301263 ### Key changes Memoized action handlers(+ scroll related functions as well) in useGroupChannel to reduced unnecessary re-rendering. --- .../components/MessageList/InfiniteList.tsx | 6 +- .../context/GroupChannelProvider.tsx | 21 +- .../__tests__/useMessageListScroll.spec.tsx | 3 +- .../context/hooks/useGroupChannel.ts | 237 ++++++++++-------- .../context/hooks/useMessageListScroll.tsx | 3 - src/utils/storeManager.ts | 13 +- 6 files changed, 158 insertions(+), 125 deletions(-) diff --git a/src/modules/GroupChannel/components/MessageList/InfiniteList.tsx b/src/modules/GroupChannel/components/MessageList/InfiniteList.tsx index e98076f8fb..299bbcfcea 100644 --- a/src/modules/GroupChannel/components/MessageList/InfiniteList.tsx +++ b/src/modules/GroupChannel/components/MessageList/InfiniteList.tsx @@ -1,4 +1,4 @@ -import React, { DependencyList, forwardRef, UIEventHandler, useLayoutEffect, useRef } from 'react'; +import React, { DependencyList, forwardRef, UIEventHandler, useCallback, useLayoutEffect, useRef } from 'react'; import type { BaseMessage } from '@sendbird/chat/message'; import { isAboutSame } from '../../../Channel/context/utils'; import { SCROLL_BUFFER } from '../../../../utils/consts'; @@ -61,7 +61,7 @@ export const InfiniteList = forwardRef((props: Props, listRef: React.RefObject = async () => { + const handleScroll: UIEventHandler = useCallback(async () => { if (!listRef.current) return; const list = listRef.current; @@ -87,7 +87,7 @@ export const InfiniteList = forwardRef((props: Props, listRef: React.RefObject diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 527e51a46c..15c616a0e3 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -112,10 +112,11 @@ const GroupChannelManager :React.FC markAsReadScheduler.push(it)); } }, - onMessagesReceived: () => { - if (isScrollBottomReached && isContextMenuClosed()) { + onMessagesReceived: (messages) => { + if (isScrollBottomReached + && isContextMenuClosed() + // Note: this shouldn't happen ideally, but it happens on re-rendering GroupChannelManager + // even though the next messages and the current messages length are the same. + // So added this condition to check if they are the same to prevent unnecessary calling scrollToBottom action + && messages.length !== state.messages.length) { setTimeout(() => actions.scrollToBottom(true), 10); } }, @@ -157,7 +163,12 @@ const GroupChannelManager :React.FC { - actions.setCurrentChannel(channel); + // Note: this shouldn't happen ideally, but it happens on re-rendering GroupChannelManager + // even though the next channel and the current channel are the same. + // So added this condition to check if they are the same to prevent unnecessary calling setCurrentChannel action + if (!state.currentChannel?.isEqual(channel)) { + actions.setCurrentChannel(channel); + } }, logger: logger as any, }); @@ -196,7 +207,7 @@ const GroupChannelManager :React.FC { subscriptions.forEach(subscription => subscription.remove()); }; - }, [messageDataSource.initialized, state.currentChannel?.url, pubSub?.subscribe]); + }, [messageDataSource.initialized, state.currentChannel?.url]); // Starting point handling useEffect(() => { diff --git a/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx b/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx index 9e7b74ac56..5fd37c1d2a 100644 --- a/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx @@ -25,7 +25,6 @@ describe('useMessageListScroll', () => { const { result } = renderHook(() => useMessageListScroll('auto')); expect(result.current.scrollRef.current).toBe(null); - expect(result.current.isScrollBottomReached).toBe(true); expect(result.current.scrollDistanceFromBottomRef.current).toBe(0); expect(result.current.scrollPositionRef.current).toBe(0); expect(typeof result.current.scrollPubSub.publish).toBe('function'); @@ -63,7 +62,7 @@ describe('useMessageListScroll', () => { result.current.scrollPubSub.publish('scrollToBottom', {}); await waitFor(() => { expect(result.current.scrollDistanceFromBottomRef.current).toBe(0); - expect(result.current.isScrollBottomReached).toBe(true); + expect(mockSetIsScrollBottomReached).toHaveBeenCalledWith(true); }); }); }); diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts index 9827d59890..0a9b67f49d 100644 --- a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts +++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts @@ -1,4 +1,4 @@ -import { useContext, useMemo } from 'react'; +import { useContext, useMemo, useCallback } from 'react'; import type { GroupChannel } from '@sendbird/chat/groupChannel'; import type { SendbirdError } from '@sendbird/chat'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; @@ -58,24 +58,21 @@ export const useGroupChannel = () => { const { markAsReadScheduler } = config; const state: GroupChannelState = useSyncExternalStore(store.subscribe, store.getState); - const flagActions = { - setAnimatedMessageId: (messageId: number | null) => { - store.setState(state => ({ ...state, animatedMessageId: messageId })); - }, + const setAnimatedMessageId = useCallback((messageId: number | null) => { + store.setState(state => ({ ...state, animatedMessageId: messageId })); + }, []); - setIsScrollBottomReached: (isReached: boolean) => { - store.setState(state => ({ ...state, isScrollBottomReached: isReached })); - }, - }; + const setIsScrollBottomReached = useCallback((isReached: boolean) => { + store.setState(state => ({ ...state, isScrollBottomReached: isReached })); + }, []); - const scrollToBottom = async (animated?: boolean) => { + const scrollToBottom = useCallback(async (animated?: boolean) => { if (!state.scrollRef.current) return; + setAnimatedMessageId(null); + setIsScrollBottomReached(true); + // wait a bit for scroll ref to be updated await delay(); - - flagActions.setAnimatedMessageId(null); - flagActions.setIsScrollBottomReached(true); - if (config.isOnline && state.hasNext()) { await state.resetWithStartingPoint(Number.MAX_SAFE_INTEGER); } @@ -87,109 +84,127 @@ export const useGroupChannel = () => { markAsReadScheduler.push(state.currentChannel); } } - }; + }, [state.scrollRef.current, config.isOnline, markAsReadScheduler]); + + const scrollToMessage = useCallback(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(); + + 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) 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) setAnimatedMessageId(messageId); + }); + } + clickHandler.activate(); + }, [setAnimatedMessageId, state.scrollRef.current, state.messages?.map(it => it?.messageId)]); + + const toggleReaction = useCallback((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); + }); + } + }, [state.currentChannel?.deleteReaction, state.currentChannel?.addReaction]); + 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 })); - }, - + const setCurrentChannel = useCallback((channel: GroupChannel) => { + store.setState(state => ({ + ...state, + currentChannel: channel, + fetchChannelError: null, + quoteMessage: null, + animatedMessageId: null, + nicknamesMap: new Map( + channel.members.map(({ userId, nickname }) => [userId, nickname]), + ), + })); + }, []); + + const handleChannelError = useCallback((error: SendbirdError) => { + store.setState(state => ({ + ...state, + currentChannel: null, + fetchChannelError: error, + quoteMessage: null, + animatedMessageId: null, + })); + }, []); + + const setQuoteMessage = useCallback((message: SendableMessageType | null) => { + store.setState(state => ({ ...state, quoteMessage: message })); + }, []); + + const actions: GroupChannelActions = useMemo(() => { + return { + setCurrentChannel, + handleChannelError, + setQuoteMessage, + scrollToBottom, + scrollToMessage, + toggleReaction, + setAnimatedMessageId, + setIsScrollBottomReached, + ...messageActions, + }; + }, [ + setCurrentChannel, + handleChannelError, + setQuoteMessage, 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]); + scrollToMessage, + toggleReaction, + setAnimatedMessageId, + setIsScrollBottomReached, + messageActions, + ]); return { state, actions }; }; diff --git a/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx b/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx index 503a74653a..084b288c7b 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx +++ b/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx @@ -32,7 +32,6 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen const [scrollPubSub] = useState(() => pubSubFactory({ publishSynchronous: true })); const { - state: { isScrollBottomReached }, actions: { setIsScrollBottomReached }, } = useGroupChannel(); @@ -99,8 +98,6 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen return { scrollRef, scrollPubSub, - isScrollBottomReached, - setIsScrollBottomReached, scrollDistanceFromBottomRef, scrollPositionRef, }; diff --git a/src/utils/storeManager.ts b/src/utils/storeManager.ts index 1274cee053..b825faa5ff 100644 --- a/src/utils/storeManager.ts +++ b/src/utils/storeManager.ts @@ -1,3 +1,5 @@ +import isEqual from 'lodash/isEqual'; + // Referrence: https://github.com/pmndrs/zustand export type Store = { getState: () => T; @@ -7,7 +9,16 @@ export type Store = { export function hasStateChanged(prevState: T, updates: Partial): boolean { return Object.entries(updates).some(([key, value]) => { - return prevState[key as keyof T] !== value; + if (typeof prevState[key as keyof T] === 'function' && typeof value === 'function') { + /** + * Function is not considered as state change. Why? + * Because function is not a value, it's a reference. + * If we consider non-memoized function as state change, + * it will always be true and cause unnecessary re-renders. + */ + return false; + } + return !isEqual(prevState[key as keyof T], value); }); }