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/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/package.json b/package.json
index f2435667b2..cb59fedf3c 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.4.3",
"@types/node": "^22.7.2",
+ "@types/use-sync-external-store": "^0.0.6",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"autoprefixer": "^9.7.4",
@@ -148,6 +149,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/rollup.module-exports.mjs b/rollup.module-exports.mjs
index addf55d8ab..d706c4a373 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',
@@ -49,7 +50,7 @@ export default {
// GroupChannelList
GroupChannelList: 'src/modules/GroupChannelList/index.tsx',
- 'GroupChannelList/context': 'src/modules/GroupChannelList/context/GroupChannelListProvider.tsx',
+ 'GroupChannelList/context': 'src/modules/GroupChannelList/context/index.tsx',
'GroupChannelList/components/AddGroupChannel': 'src/modules/GroupChannelList/components/AddGroupChannel/index.tsx',
'GroupChannelList/components/GroupChannelListUI': 'src/modules/GroupChannelList/components/GroupChannelListUI/index.tsx',
'GroupChannelList/components/GroupChannelListHeader': 'src/modules/GroupChannelList/components/GroupChannelListHeader/index.tsx',
@@ -58,7 +59,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',
@@ -93,7 +94,7 @@ export default {
'Channel/components/SuggestedMentionList': 'src/modules/Channel/components/SuggestedMentionList/index.tsx',
GroupChannel: 'src/modules/GroupChannel/index.tsx',
- 'GroupChannel/context': 'src/modules/GroupChannel/context/GroupChannelProvider.tsx',
+ 'GroupChannel/context': 'src/modules/GroupChannel/context/index.tsx',
'GroupChannel/components/GroupChannelHeader': 'src/modules/GroupChannel/components/GroupChannelHeader/index.tsx',
'GroupChannel/components/GroupChannelUI': 'src/modules/GroupChannel/components/GroupChannelUI/index.tsx',
'GroupChannel/components/FileViewer': 'src/modules/GroupChannel/components/FileViewer/index.tsx',
@@ -139,7 +140,7 @@ export default {
// MessageSearch
MessageSearch: 'src/modules/MessageSearch/index.tsx',
- 'MessageSearch/context': 'src/modules/MessageSearch/context/MessageSearchProvider.tsx',
+ 'MessageSearch/context': 'src/modules/MessageSearch/context/index.tsx',
'MessageSearch/components/MessageSearchUI': 'src/modules/MessageSearch/components/MessageSearchUI/index.tsx',
// Message
@@ -148,7 +149,7 @@ export default {
// Thread
Thread: 'src/modules/Thread/index.tsx',
- 'Thread/context': 'src/modules/Thread/context/ThreadProvider.tsx',
+ 'Thread/context': 'src/modules/Thread/context/index.tsx',
'Thread/context/types': 'src/modules/Thread/types.tsx',
'Thread/components/ThreadUI': 'src/modules/Thread/components/ThreadUI/index.tsx',
'Thread/components/ThreadHeader': 'src/modules/Thread/components/ThreadHeader/index.tsx',
@@ -160,7 +161,7 @@ export default {
// CreateChannel
CreateChannel: 'src/modules/CreateChannel/index.tsx',
- 'CreateChannel/context': 'src/modules/CreateChannel/context/CreateChannelProvider.tsx',
+ 'CreateChannel/context': 'src/modules/CreateChannel/context/index.tsx',
'CreateChannel/components/CreateChannelUI': 'src/modules/CreateChannel/components/CreateChannelUI/index.tsx',
'CreateChannel/components/InviteUsers': 'src/modules/CreateChannel/components/InviteUsers/index.tsx',
'CreateChannel/components/SelectChannelType': 'src/modules/CreateChannel/components/SelectChannelType.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/__tests__/useAsyncRequest.spec.tsx b/src/hooks/__tests__/useAsyncRequest.spec.tsx
new file mode 100644
index 0000000000..be314bfb31
--- /dev/null
+++ b/src/hooks/__tests__/useAsyncRequest.spec.tsx
@@ -0,0 +1,48 @@
+import { act, renderHook } from '@testing-library/react';
+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 act(async () => {
+ 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 act(async () => {
+ 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..452dc989a6
--- /dev/null
+++ b/src/hooks/__tests__/useDebounce.spec.tsx
@@ -0,0 +1,28 @@
+import { renderHook } from '@testing-library/react';
+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..73f5a92de1
--- /dev/null
+++ b/src/hooks/__tests__/useLongPress.spec.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { renderHook, screen, fireEvent, render, waitFor } from '@testing-library/react';
+import useLongPress from '../useLongPress';
+
+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..f935efe599
--- /dev/null
+++ b/src/hooks/__tests__/useMouseHover.spec.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { renderHook, 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..2b01660d67
--- /dev/null
+++ b/src/hooks/__tests__/useOutsideAlerter.spec.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { renderHook, 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..c6eee5b49f
--- /dev/null
+++ b/src/hooks/__tests__/useThrottleCallback.spec.tsx
@@ -0,0 +1,60 @@
+import { renderHook } from '@testing-library/react';
+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/useConnectionState.ts b/src/hooks/useConnectionState.ts
index 8ff477cfb1..54bb269209 100644
--- a/src/hooks/useConnectionState.ts
+++ b/src/hooks/useConnectionState.ts
@@ -2,11 +2,11 @@ import { useEffect, useState } from 'react';
import { ConnectionState } from '@sendbird/chat';
import ConnectionHandler from '../lib/handlers/ConnectionHandler';
-import useSendbirdStateContext from './useSendbirdStateContext';
import uuidv4 from '../utils/uuid';
+import useSendbird from '../lib/Sendbird/context/hooks/useSendbird';
export const useConnectionState = (): ConnectionState => {
- const { stores } = useSendbirdStateContext();
+ const { state: { stores } } = useSendbird();
const { sdkStore } = stores;
const { sdk } = sdkStore;
diff --git a/src/hooks/useDeepCompareEffect.ts b/src/hooks/useDeepCompareEffect.ts
new file mode 100644
index 0000000000..cdcaf7a0b6
--- /dev/null
+++ b/src/hooks/useDeepCompareEffect.ts
@@ -0,0 +1,43 @@
+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.
+ *
+ * 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
+ *
+ * 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(
+ callback: () => void | (() => void),
+ dependencies: any[],
+) {
+ useEffect(callback, dependencies.map(useDeepCompareMemoize));
+}
+
+export default useDeepCompareEffect;
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/hooks/useStore.ts b/src/hooks/useStore.ts
new file mode 100644
index 0000000000..9e75f7240b
--- /dev/null
+++ b/src/hooks/useStore.ts
@@ -0,0 +1,54 @@
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+import { useContext, useRef, useCallback, useMemo } from 'react';
+import { type Store, hasStateChanged } 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) => {
+ const currentState = store.getState();
+
+ if (hasStateChanged(currentState, updates)) {
+ store.setState((prevState) => ({
+ ...prevState,
+ ...updates,
+ }));
+ }
+ }, [store]);
+
+ return useMemo(() => ({
+ state,
+ updateState,
+ }), [state, updateState]);
+}
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;
-}
diff --git a/src/index.ts b/src/index.ts
index 14dd7e661e..b2f961fff5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,11 +15,12 @@ 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 { useSendbird } from './lib/Sendbird/context/hooks/useSendbird';
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/__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..8cff9a1365
--- /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',
+ }),
+ undefined,
+ );
+ });
+});
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..557f7595f5
--- /dev/null
+++ b/src/lib/Sendbird/__tests__/useSendbird.spec.tsx
@@ -0,0 +1,412 @@
+import React from 'react';
+import { renderHook, act } from '@testing-library/react';
+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', () => {
+ try {
+ renderHook(() => useSendbird());
+ } catch (error) {
+ expect(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/SendbirdContext.tsx b/src/lib/Sendbird/context/SendbirdContext.tsx
new file mode 100644
index 0000000000..5f495f8411
--- /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..71c03d9573
--- /dev/null
+++ b/src/lib/Sendbird/context/SendbirdProvider.tsx
@@ -0,0 +1,404 @@
+/* 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';
+import useDeepCompareEffect from '../../../hooks/useDeepCompareEffect';
+
+/**
+ * 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,
+ ]);
+
+ useDeepCompareEffect(() => {
+ 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..bbefc8e101
--- /dev/null
+++ b/src/lib/Sendbird/context/hooks/useSendbird.tsx
@@ -0,0 +1,233 @@
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+import { useContext, useMemo } 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: SendbirdState = 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, state.stores.appInfoStore]);
+
+ 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..f071aa83e4
--- /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/SendbirdProvider.migration.spec.tsx b/src/lib/SendbirdProvider.migration.spec.tsx
index 42c9e996a9..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 '../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';
+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/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 1bfd70d803..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 './Sendbird';
+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..64766307ec 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 36a1b14341..6fc9ef4cb9 100644
--- a/src/modules/Channel/components/Message/index.tsx
+++ b/src/modules/Channel/components/Message/index.tsx
@@ -1,12 +1,12 @@
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';
/**
* @deprecated This component is deprecated and will be removed in the next major update.
@@ -15,7 +15,7 @@ import RemoveMessageModal from '../RemoveMessageModal';
* https://docs.sendbird.com/docs/chat/uikit/v3/react/introduction/group-channel-migration-guide
*/
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 f0c7f5f63d..365c382ee5 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';
import { useLocalization } from '../../../../lib/LocalizationContext';
const SCROLL_BOTTOM_PADDING = 50;
@@ -80,7 +80,7 @@ export const MessageList = (props: MessageListProps) => {
typingMembers,
} = useChannelContext();
- const store = useSendbirdStateContext();
+ const { state: store } = useSendbird();
const { stringSet } = useLocalization();
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 8c8d769e36..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';
@@ -43,7 +42,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';
@@ -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/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/ChannelList/components/ChannelListUI/index.tsx b/src/modules/ChannelList/components/ChannelListUI/index.tsx
index 0c7882aa91..2f152bece4 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;
@@ -44,7 +44,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 e665feba93..657da11bae 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 */
@@ -28,7 +28,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__/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/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx
new file mode 100644
index 0000000000..5af7b1af68
--- /dev/null
+++ b/src/modules/ChannelSettings/__test__/ChannelSettingsProvider.spec.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import { renderHook, act } from '@testing-library/react';
+import { ChannelSettingsProvider, useChannelSettingsContext } from '../context/ChannelSettingsProvider';
+import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird';
+import { SendbirdContext } from '../../../lib/Sendbird/context/SendbirdContext';
+
+jest.mock('../../../lib/Sendbird/context/hooks/useSendbird');
+jest.mock('../context/hooks/useSetChannel');
+
+const mockLogger = {
+ warning: jest.fn(),
+ info: jest.fn(),
+ error: jest.fn(),
+};
+
+const mockStore = {
+ getState: jest.fn(),
+ setState: jest.fn(),
+ subscribe: jest.fn(() => jest.fn()),
+};
+
+const initialState = {
+ channelUrl: 'test-channel',
+ channel: null,
+ loading: false,
+ invalidChannel: false,
+};
+
+describe('ChannelSettingsProvider', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ mockStore.getState.mockReturnValue(initialState);
+ useSendbird.mockReturnValue({
+ state: {
+ stores: { sdkStore: { sdk: {}, initialized: true } },
+ config: { logger: mockLogger },
+ },
+ });
+
+ wrapper = ({ children }) => (
+
+
+ {children}
+
+
+ );
+
+ jest.clearAllMocks();
+ });
+
+ it('provides the correct initial state and actions', () => {
+ const { result } = renderHook(() => useChannelSettingsContext(), { wrapper });
+
+ 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', () => {
+ useSendbird.mockReturnValue({
+ state: {
+ 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 channel state correctly', async () => {
+ const { result } = renderHook(() => useChannelSettingsContext(), { wrapper });
+ const newChannel = { url: 'new-channel' } as any;
+
+ await act(async () => {
+ result.current.setChannel(newChannel);
+ });
+
+ expect(result.current.channel).toEqual(newChannel);
+ });
+
+ it('maintains loading and invalid states', async () => {
+ const { result } = renderHook(() => useChannelSettingsContext(), { wrapper });
+
+ await act(async () => {
+ result.current.setLoading(true);
+ result.current.setInvalid(true);
+ });
+
+ expect(result.current.loading).toBe(true);
+ expect(result.current.invalidChannel).toBe(true);
+ });
+});
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..f0c8ab8009
--- /dev/null
+++ b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+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 { 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',
+ 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();
+ const stateContextValue = {
+ state: {
+ config: {},
+ stores: {},
+ },
+ };
+ useSendbird.mockReturnValue(stateContextValue);
+ renderHook(() => useSendbird());
+ });
+
+ 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..36a939fc82
--- /dev/null
+++ b/src/modules/ChannelSettings/__test__/useChannelHandler.spec.ts
@@ -0,0 +1,78 @@
+import { renderHook, act } from '@testing-library/react';
+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.tsx b/src/modules/ChannelSettings/__test__/useChannelSettings.spec.tsx
new file mode 100644
index 0000000000..2053cef9e3
--- /dev/null
+++ b/src/modules/ChannelSettings/__test__/useChannelSettings.spec.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { renderHook, act } from '@testing-library/react';
+import { useChannelSettings } from '../context/useChannelSettings';
+import { ChannelSettingsContext } from '../context/ChannelSettingsProvider';
+import type { GroupChannel } from '@sendbird/chat/groupChannel';
+
+describe('useChannelSettings', () => {
+ 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', () => {
+ try {
+ renderHook(() => useChannelSettings());
+ } catch (error) {
+ expect(error.message).toBe('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(), { wrapper });
+
+ expect(result.current.state).toEqual(initialState);
+ });
+
+ it('calls setChannel with the correct channel object', () => {
+ const { result } = renderHook(() => useChannelSettings(), { wrapper });
+
+ 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(), { wrapper });
+
+ 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(), { wrapper });
+
+ 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..099d93e17d
--- /dev/null
+++ b/src/modules/ChannelSettings/__test__/useSetChannel.spec.ts
@@ -0,0 +1,90 @@
+import { renderHook, act } from '@testing-library/react';
+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/components/ChannelProfile/index.tsx b/src/modules/ChannelSettings/components/ChannelProfile/index.tsx
index a03600d000..54ce41d6aa 100644
--- a/src/modules/ChannelSettings/components/ChannelProfile/index.tsx
+++ b/src/modules/ChannelSettings/components/ChannelProfile/index.tsx
@@ -1,9 +1,8 @@
import './channel-profile.scss';
import React, { useState, useContext, useMemo } from 'react';
+import useChannelSettings from '../../context/useChannelSettings';
import { LocalizationContext } from '../../../../lib/LocalizationContext';
-import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
-import { useChannelSettingsContext } from '../../context/ChannelSettingsProvider';
import ChannelAvatar from '../../../../ui/ChannelAvatar';
import TextButton from '../../../../ui/TextButton';
@@ -11,13 +10,13 @@ import Label, {
LabelTypography,
LabelColors,
} from '../../../../ui/Label';
-
import EditDetailsModal from '../EditDetailsModal';
+import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
import { isDefaultChannelName } from '../../../../utils';
const ChannelProfile: React.FC = () => {
- const state = useSendbirdStateContext();
- const channelSettingStore = useChannelSettingsContext();
+ const { state } = useSendbird();
+ const { state: { channel } } = useChannelSettings();
const { stringSet } = useContext(LocalizationContext);
const [showModal, setShowModal] = useState(false);
@@ -26,8 +25,6 @@ const ChannelProfile: React.FC = () => {
const isOnline = state?.config?.isOnline;
const disabled = !isOnline;
- const channel = channelSettingStore?.channel;
-
const channelName = useMemo(() => {
if (!channel?.name && !channel?.members) return stringSet.NO_TITLE;
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/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..67c4925185 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';
+import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
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,21 @@ const ChannelSettingsUI = (props: ChannelSettingsUIProps) => {
renderPlaceholderError,
renderPlaceholderLoading,
} = deleteNullish(props);
+ const { state } = useSendbird();
+ const { isOnline } = state.config;
+ 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 +127,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..00c162a623 100644
--- a/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx
+++ b/src/modules/ChannelSettings/components/EditDetailsModal/index.tsx
@@ -1,8 +1,7 @@
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';
@@ -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;
@@ -26,14 +26,16 @@ 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();
+ 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 6701fafdd2..d7365bd263 100644
--- a/src/modules/ChannelSettings/components/LeaveChannel/index.tsx
+++ b/src/modules/ChannelSettings/components/LeaveChannel/index.tsx
@@ -3,8 +3,6 @@ import './leave-channel.scss';
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';
@@ -16,6 +14,8 @@ import Label, {
LabelColors,
} from '../../../../ui/Label';
import { isDefaultChannelName } from '../../../../utils';
+import useChannelSettings from '../../context/useChannelSettings';
+import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
export type LeaveChannelProps = {
onSubmit: () => void;
@@ -28,9 +28,9 @@ 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 { state } = useSendbird();
const logger = state?.config?.logger;
const isOnline = state?.config?.isOnline;
const { isMobile } = useMediaQueryContext();
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..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 { 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';
+import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
type UserId = string;
export interface InviteUsersModalProps {
@@ -26,11 +26,11 @@ 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;
- 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';
+import useChannelSettings from './useChannelSettings';
+
+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;
+ const { state, actions } = useChannelSettings();
+ return { ...state, ...actions };
};
+
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..1cbdfb5643
--- /dev/null
+++ b/src/modules/ChannelSettings/context/hooks/useChannelHandler.ts
@@ -0,0 +1,56 @@
+import { useEffect } from 'react';
+import { GroupChannelHandler } from '@sendbird/chat/groupChannel';
+
+import uuidv4 from '../../../../utils/uuid';
+import compareIds from '../../../../utils/compareIds';
+import type { SdkStore, Logger } from '../../../../lib/Sendbird/types';
+
+interface UseChannelHandlerProps {
+ sdk: SdkStore['sdk'];
+ channelUrl: string;
+ logger: Logger;
+ forceUpdateUI: () => void;
+ dependencies?: any[];
+}
+
+export const useChannelHandler = ({
+ sdk,
+ channelUrl,
+ logger,
+ forceUpdateUI,
+ dependencies = [],
+}: UseChannelHandlerProps) => {
+ 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) => {
+ 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();
+ }
+ },
+ });
+
+ sdk.groupChannel.addGroupChannelHandler(newChannelHandlerId, channelHandler);
+
+ return () => {
+ if (sdk.groupChannel && newChannelHandlerId) {
+ logger.info('ChannelSettings: Removing message receiver handler', newChannelHandlerId);
+ sdk.groupChannel.removeGroupChannelHandler(newChannelHandlerId);
+ }
+ };
+ }, [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..fbd9905ea7
--- /dev/null
+++ b/src/modules/ChannelSettings/context/hooks/useSetChannel.ts
@@ -0,0 +1,68 @@
+import { useEffect } from 'react';
+import type { Logger, SdkStore } from '../../../../lib/Sendbird/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();
+
+ useEffect(() => {
+ 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 (error) {
+ if (!signal.aborted) {
+ logger.error('ChannelSettings | useSetChannel: failed fetching channel', error);
+ setInvalid(true);
+ }
+ } finally {
+ if (!signal.aborted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ fetchChannel();
+
+ return () => controller.abort(); // Cleanup with AbortController
+ }, [channelUrl, initialized, sdk, ...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..0a95763cf7
--- /dev/null
+++ b/src/modules/ChannelSettings/context/index.tsx
@@ -0,0 +1,3 @@
+export { ChannelSettingsProvider, useChannelSettingsContext } from './ChannelSettingsProvider';
+export { useChannelSettings } from './useChannelSettings';
+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..685c0e586f
--- /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;
+}
+
+export interface ChannelSettingsQueries {
+ applicationUserListQuery?: ApplicationUserListQuery;
+}
+
+type OverrideInviteUserType = {
+ users: Array;
+ onClose: () => void;
+ channel: GroupChannel;
+};
+
+export 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..26e04d4467
--- /dev/null
+++ b/src/modules/ChannelSettings/context/useChannelSettings.ts
@@ -0,0 +1,33 @@
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+import { useMemo, useContext } from 'react';
+import type { GroupChannel } from '@sendbird/chat/groupChannel';
+
+import { ChannelSettingsContext } from './ChannelSettingsProvider';
+import { ChannelSettingsState } from './types';
+
+export const useChannelSettings = () => {
+ const store = useContext(ChannelSettingsContext);
+ 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/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..e4ddfbf7d4
--- /dev/null
+++ b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx
@@ -0,0 +1,114 @@
+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('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ state: {
+ 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..9b4a8fb5d8 100644
--- a/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx
+++ b/src/modules/CreateChannel/components/InviteUsers/__tests__/index.spec.tsx
@@ -1,21 +1,31 @@
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';
+
+const mockState = {
+ stores: {
+ sdkStore: {
+ sdk: {
+ currentUser: {
+ userId: 'test-user-id',
+ },
+ },
+ initialized: true,
+ },
+ },
+ 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');
// Mock createPortal function to render content directly without portal
jest.mock('react-dom', () => ({
@@ -23,7 +33,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 +95,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..32c7ede757 100644
--- a/src/modules/CreateChannel/components/InviteUsers/index.tsx
+++ b/src/modules/CreateChannel/components/InviteUsers/index.tsx
@@ -4,8 +4,7 @@ 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 useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
import { useMediaQueryContext } from '../../../../lib/MediaQueryContext';
import Modal from '../../../../ui/Modal';
import Label, { LabelColors, LabelTypography } from '../../../../ui/Label';
@@ -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,18 +28,20 @@ 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;
- 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 9394d7667e..305d43a875 100644
--- a/src/modules/CreateChannel/components/SelectChannelType.tsx
+++ b/src/modules/CreateChannel/components/SelectChannelType.tsx
@@ -1,9 +1,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';
@@ -16,6 +13,8 @@ import {
isSuperGroupChannelEnabled,
} 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;
@@ -23,15 +22,15 @@ 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 createChannelProps = useCreateChannelContext();
const {
- setStep,
- setType,
- } = createChannelProps;
+ actions: {
+ setPageStep,
+ setType,
+ },
+ } = useCreateChannel();
const { stringSet } = useContext(LocalizationContext);
@@ -50,13 +49,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 { 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);
+
+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 store = useSendbirdStateContext();
- const _userListQuery = userListQuery ?? store?.config?.userListQuery;
+ const { updateState } = useCreateChannelStore();
+ const { state: { config } } = useSendbird();
+ const _userListQuery = userListQuery ?? 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__/CreateChannel.migration.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannel.migration.spec.tsx
index 5cfb3ee418..e2e49caff7 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/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx
new file mode 100644
index 0000000000..44389007b1
--- /dev/null
+++ b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.spec.tsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import { act, waitFor, renderHook } from '@testing-library/react';
+import { CreateChannelProvider } from '../CreateChannelProvider';
+import { CHANNEL_TYPE } from '../../types';
+import useCreateChannel from '../useCreateChannel';
+
+jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ state: {
+ 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..bdc83b5e5b
--- /dev/null
+++ b/src/modules/CreateChannel/context/__tests__/useCreateChannel.spec.tsx
@@ -0,0 +1,63 @@
+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('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ state: {
+ 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/index.tsx b/src/modules/CreateChannel/context/index.tsx
new file mode 100644
index 0000000000..98af4d5c14
--- /dev/null
+++ b/src/modules/CreateChannel/context/index.tsx
@@ -0,0 +1,2 @@
+export * from './CreateChannelProvider';
+export { default as useCreateChannel } from './useCreateChannel';
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/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
new file mode 100644
index 0000000000..d951d7a81b
--- /dev/null
+++ b/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx
@@ -0,0 +1,102 @@
+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 useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird';
+
+jest.mock('../../../lib/Sendbird/context/hooks/useSendbird');
+
+const mockUseSendbird = useSendbird 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(() => {
+ mockUseSendbird.mockImplementation(() => ({
+ state: {
+ 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', () => {
+ mockUseSendbird.mockImplementation(() => ({
+ state: {
+ 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/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;
@@ -11,9 +11,11 @@ export interface FileViewerProps {
}
export const FileViewer = (props: FileViewerProps) => {
- const { deleteMessage, onBeforeDownloadFileMessage } = useGroupChannelContext();
- const { config } = useSendbirdStateContext();
- const { logger } = config;
+ const {
+ state: { onBeforeDownloadFileMessage },
+ actions: { deleteMessage },
+ } = useGroupChannel();
+ 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/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..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';
@@ -20,8 +19,9 @@ 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';
+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 96f0ff675c..f86a91f9a8 100644
--- a/src/modules/GroupChannel/components/Message/index.tsx
+++ b/src/modules/GroupChannel/components/Message/index.tsx
@@ -1,41 +1,46 @@
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 { 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';
+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 {
- 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/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/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/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/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/components/MessageList/index.tsx b/src/modules/GroupChannel/components/MessageList/index.tsx
index 86411554a4..d58cc534ea 100644
--- a/src/modules/GroupChannel/components/MessageList/index.tsx
+++ b/src/modules/GroupChannel/components/MessageList/index.tsx
@@ -12,15 +12,15 @@ 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 { 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, isContextMenuClosed } from '../../context/utils';
import { InfiniteList } from './InfiniteList';
+import { useGroupChannel } from '../../context/hooks/useGroupChannel';
+import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
import { useLocalization } from '../../../../lib/LocalizationContext';
export interface GroupChannelMessageListProps {
@@ -68,27 +68,31 @@ 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();
-
- const store = useSendbirdStateContext();
+ state: {
+ channelUrl,
+ hasNext,
+ loading,
+ messages,
+ newMessages,
+ isScrollBottomReached,
+ isMessageGroupingEnabled,
+ currentChannel,
+ replyType,
+ scrollPubSub,
+ loadNext,
+ loadPrevious,
+ resetNewMessages,
+ scrollRef,
+ scrollPositionRef,
+ scrollDistanceFromBottomRef,
+ },
+ actions: {
+ scrollToBottom,
+ setIsScrollBottomReached,
+ },
+ } = useGroupChannel();
+
+ const { state } = useSendbird();
const { stringSet } = useLocalization();
const [unreadSinceDate, setUnreadSinceDate] = useState();
@@ -165,8 +169,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({
@@ -212,8 +216,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) && (
)
}
@@ -227,8 +231,13 @@ export const MessageList = (props: GroupChannelMessageListProps) => {
};
const TypingIndicatorBubbleWrapper = (props: { handleScroll: () => void; channelUrl: string }) => {
- const { stores } = useSendbirdStateContext();
- const { isScrollBottomReached, scrollPubSub } = useGroupChannelContext();
+ const { state: { stores } } = useSendbird();
+ const {
+ state: {
+ isScrollBottomReached,
+ scrollPubSub,
+ },
+ } = useGroupChannel();
const [typingMembers, setTypingMembers] = useState([]);
useGroupChannelHandler(stores.sdkStore.sdk, {
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/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/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx
index 48cb42f1b0..bd76b0d8ad 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,267 +101,252 @@ 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);
+ // ScrollHandler initialization
+ const {
+ scrollRef,
+ scrollPubSub,
+ scrollDistanceFromBottomRef,
+ scrollPositionRef,
+ } = useMessageListScroll(scrollBehavior, [state.currentChannel?.url]);
- // Ref
- const { scrollRef, scrollPubSub, scrollDistanceFromBottomRef, isScrollBottomReached, setIsScrollBottomReached, scrollPositionRef } = useMessageListScroll(scrollBehavior, [currentChannel?.url]);
- const messageInputRef = useRef(null);
+ const { isScrollBottomReached } = state;
- const toggleReaction = useToggleReactionCallback(currentChannel, logger);
- const replyType = getCaseResolvedReplyType(moduleReplyType ?? config.groupChannel.replyType).upperCase;
- const threadReplySelectType = getCaseResolvedThreadReplySelectType(
+ // 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) {
channels.forEach((it) => markAsReadScheduler.push(it));
}
},
- onMessagesReceived: () => {
- // FIXME: onMessagesReceived called with onApiResult
- if (isScrollBottomReached && isContextMenuClosed()) {
- setTimeout(() => {
- scrollPubSub.publish('scrollToBottom', {});
- }, 10);
+ 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);
}
},
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]);
- // 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?.serialize(),
+ configurations,
+ scrollState,
+ eventHandlers,
+ renderProps,
+ messageDataSource.initialized,
+ messageDataSource.loading,
+ messageDataSource.messages.map(it => it.serialize()),
+ ]);
+
+ return children;
+};
- clickHandler.activate();
- },
+const GroupChannelProvider: React.FC = (props) => {
+ return (
+
+
+
+ {props.children}
+
+
+
);
+};
- const messageActions = useMessageActions({
- ...props,
- ...messageDataSource,
- scrollToBottom,
- quoteMessage,
- replyType,
- pubSub: config.pubSub,
- channel: currentChannel,
- });
+/**
+ * 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/__tests__/GroupChannel.migration.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannel.migration.spec.tsx
index c661bb3286..0c4d6db02f 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 React, { act } 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 = {
@@ -59,8 +57,7 @@ const mockProps: GroupChannelProviderProps = {
scrollBehavior: 'smooth',
forceLeftToRightMessageLayout: false,
- startingPoint: 0,
-
+ startingPoint: undefined,
// Message Focusing
animatedMessageId: null,
onMessageAnimated: jest.fn(),
@@ -95,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/__tests__/GroupChannelProvider.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx
new file mode 100644
index 0000000000..52614d19bf
--- /dev/null
+++ b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.spec.tsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import { waitFor, act, renderHook } from '@testing-library/react';
+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' }],
+ serialize: () => JSON.stringify(this),
+};
+
+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(),
+ messages: [],
+};
+jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ state: {
+ 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(() => {
+ 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('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ default: () => ({
+ state: {
+ stores: {
+ sdkStore: {
+ sdk: {
+ groupChannel: {
+ getChannel: jest.fn().mockRejectedValue(mockError),
+ },
+ },
+ initialized: true,
+ },
+ },
+ config: { logger: console },
+ },
+ }),
+ }));
+
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(() => {
+ waitFor(() => {
+ expect(result.current.state.currentChannel).toBeTruthy();
+ expect(result.current.state.fetchChannelError).toBeNull();
+ });
+ });
+
+ act(() => {
+ waitFor(() => {
+ expect(result.current.state.currentChannel).toBeNull();
+ });
+ });
+ });
+
+ it('correctly handles scroll to bottom', async () => {
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(() => {
+ result.current.actions.scrollToBottom();
+ waitFor(() => {
+ expect(result.current.state.isScrollBottomReached).toBe(true);
+ });
+ });
+ });
+});
diff --git a/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx b/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx
new file mode 100644
index 0000000000..ef336312d2
--- /dev/null
+++ b/src/modules/GroupChannel/context/__tests__/useGroupChannel.spec.tsx
@@ -0,0 +1,475 @@
+import React from 'react';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { GroupChannel } from '@sendbird/chat/groupChannel';
+import { GroupChannelProvider, GroupChannelContext } 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' }],
+ serialize: () => JSON.stringify(this),
+};
+
+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(),
+ messages: [],
+};
+const mockMarkAsReadScheduler = { push: jest.fn() };
+jest.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ state: {
+ stores: {
+ sdkStore: {
+ sdk: {
+ groupChannel: {
+ getChannel: mockGetChannel,
+ addGroupChannelHandler: jest.fn(),
+ removeGroupChannelHandler: jest.fn(),
+ },
+ createMessageCollection: jest.fn().mockReturnValue(mockMessageCollection),
+ },
+ initialized: true,
+ },
+ },
+ config: {
+ logger: mockLogger,
+ markAsReadScheduler: mockMarkAsReadScheduler,
+ groupChannel: {
+ replyType: 'NONE',
+ threadReplySelectType: 'PARENT',
+ },
+ groupChannelSettings: {
+ enableMessageSearch: true,
+ },
+ isOnline: true,
+ pubSub: {
+ subscribe: () => ({ remove: jest.fn() }),
+ },
+ },
+ },
+ })),
+}));
+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 }) => (
+
+ {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 as any);
+ });
+
+ 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', () => {
+ 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, serialize: () => JSON.stringify(this) };
+ 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,
+ 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 deleteReaction = jest.fn().mockRejectedValue(mockError);
+ const mockChannelWithReactions = {
+ ...mockChannel,
+ deleteReaction,
+ };
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(() => {
+ result.current.actions.setCurrentChannel(mockChannelWithReactions as any);
+ });
+
+ act(() => {
+ result.current.actions.toggleReaction(
+ { messageId: 1 } as SendableMessageType,
+ 'thumbs_up',
+ true,
+ );
+ });
+
+ waitFor(() => {
+ expect(mockChannelWithReactions.deleteReaction).toHaveBeenCalled();
+ expect(mockLogger.warning).toHaveBeenCalledWith(
+ 'Failed to delete reaction:',
+ mockError,
+ );
+ });
+
+ });
+
+ 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;
+
+ await waitFor(() => {
+ expect(result.current.actions).toBeDefined();
+ });
+
+ 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({}),
+ };
+
+ const { result } = renderHook(() => useGroupChannel(), { wrapper });
+
+ act(() => {
+ 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/__tests__/useMessageActions.spec.ts b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts
deleted file mode 100644
index 1741ecc37d..0000000000
--- a/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { renderHook } from '@testing-library/react';
-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/__tests__/useMessageActions.spec.tsx b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx
new file mode 100644
index 0000000000..5817a0f8fe
--- /dev/null
+++ b/src/modules/GroupChannel/context/__tests__/useMessageActions.spec.tsx
@@ -0,0 +1,379 @@
+import { renderHook } from '@testing-library/react';
+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() }),
+ publish: jest.fn(),
+ },
+ },
+ },
+ })),
+}));
+
+describe('useMessageActions', () => {
+ // Setup common mocks
+ const mockSendUserMessage = jest.fn();
+ const mockSendFileMessage = jest.fn();
+ const mockSendMultipleFilesMessage = jest.fn();
+ const mockUpdateUserMessage = jest.fn(async () => {});
+ 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',
+ pubSub: {
+ publish: jest.fn(),
+ },
+ channel: {},
+ };
+
+ 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',
+ },
+ );
+ });
+ });
+
+ 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/__tests__/useMessageListScroll.spec.tsx b/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx
new file mode 100644
index 0000000000..5fd37c1d2a
--- /dev/null
+++ b/src/modules/GroupChannel/context/__tests__/useMessageListScroll.spec.tsx
@@ -0,0 +1,299 @@
+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.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(mockSetIsScrollBottomReached).toHaveBeenCalledWith(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 = `
+
+
+ `;
+ 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);
+ });
+ });
+});
diff --git a/src/modules/GroupChannel/context/hooks/useGroupChannel.ts b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts
new file mode 100644
index 0000000000..445bcbe2f7
--- /dev/null
+++ b/src/modules/GroupChannel/context/hooks/useGroupChannel.ts
@@ -0,0 +1,210 @@
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+import { useContext, useCallback, useMemo } from 'react';
+import type { GroupChannel } from '@sendbird/chat/groupChannel';
+import type { SendbirdError } from '@sendbird/chat';
+
+import type {
+ FileMessage,
+ FileMessageCreateParams,
+ MultipleFilesMessage,
+ MultipleFilesMessageCreateParams,
+ UserMessage,
+ UserMessageCreateParams,
+ UserMessageUpdateParams,
+} from '@sendbird/chat/message';
+
+import { SendableMessageType } from '../../../../utils';
+import { getMessageTopOffset } from '../utils';
+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
+ 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 { state: { config } } = useSendbird();
+ const { markAsReadScheduler } = config;
+ const state: GroupChannelState = useSyncExternalStore(store.subscribe, store.getState);
+
+ const setAnimatedMessageId = useCallback((messageId: number | null) => {
+ store.setState(state => ({ ...state, animatedMessageId: messageId }));
+ }, []);
+
+ const setIsScrollBottomReached = useCallback((isReached: boolean) => {
+ store.setState(state => ({ ...state, isScrollBottomReached: isReached }));
+ }, []);
+
+ 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();
+ if (config.isOnline && state.hasNext()) {
+ await state.resetWithStartingPoint(Number.MAX_SAFE_INTEGER);
+ }
+ state.scrollPubSub.publish('scrollToBottom', { animated });
+
+ if (state.currentChannel && !state.hasNext()) {
+ state.resetNewMessages();
+ if (!state.disableMarkAsRead) {
+ 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 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]),
+ ),
+ }), true);
+ }, []);
+
+ 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,
+ toggleReaction,
+ setAnimatedMessageId,
+ setIsScrollBottomReached,
+ messageActions,
+ ]);
+
+ return { state, actions };
+};
diff --git a/src/modules/GroupChannel/context/hooks/useMessageActions.ts b/src/modules/GroupChannel/context/hooks/useMessageActions.ts
index e588ff815f..d2d6d8958e 100644
--- a/src/modules/GroupChannel/context/hooks/useMessageActions.ts
+++ b/src/modules/GroupChannel/context/hooks/useMessageActions.ts
@@ -20,11 +20,10 @@ import {
VOICE_MESSAGE_FILE_NAME,
VOICE_MESSAGE_MIME_TYPE,
} from '../../../../utils/consts';
-import type { CoreMessageType, SendableMessageType } from '../../../../utils';
-import type { ReplyType } from '../../../../types';
-import type { GroupChannelProviderProps, OnBeforeHandler } from '../GroupChannelProvider';
-import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
-import { PublishingModuleType, PUBSUB_TOPICS, SBUGlobalPubSub } from '../../../../lib/pubSub/topics';
+import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird';
+import type { GroupChannelState, OnBeforeHandler } from '../types';
+import type { CoreMessageType } from '../../../../utils';
+import { PublishingModuleType, PUBSUB_TOPICS } from '../../../../lib/pubSub/topics';
import { GroupChannel } from '@sendbird/chat/groupChannel';
type MessageListDataSource = ReturnType;
@@ -34,14 +33,11 @@ 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;
- pubSub: SBUGlobalPubSub;
- channel: GroupChannel;
+ currentChannel: GroupChannel;
}
const pass = (value: T) => value;
@@ -68,14 +64,24 @@ export function useMessageActions(params: Params): MessageActions {
sendMultipleFilesMessage,
sendUserMessage,
updateUserMessage,
+ updateFileMessage,
+ resendMessage,
+ deleteMessage,
+ resetNewMessages,
scrollToBottom,
quoteMessage,
replyType,
- channel,
- pubSub,
+ currentChannel,
} = params;
- const { eventHandlers } = useSendbirdStateContext();
+ const {
+ state: {
+ eventHandlers,
+ config: {
+ pubSub,
+ },
+ },
+ } = useSendbird();
const buildInternalMessageParams = useCallback(
(basicParams: T): T => {
const messageParams = { ...basicParams } as T;
@@ -194,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],
});
@@ -202,7 +208,11 @@ export function useMessageActions(params: Params): MessageActions {
return message;
});
},
- [buildInternalMessageParams, updateUserMessage, processParams, channel?.url],
+ [buildInternalMessageParams, updateUserMessage, processParams, currentChannel?.url],
),
+ updateFileMessage,
+ resendMessage,
+ deleteMessage,
+ resetNewMessages,
};
}
diff --git a/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx b/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx
index bce7be5528..084b288c7b 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,9 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen
const scrollDistanceFromBottomRef = useRef(0);
const [scrollPubSub] = useState(() => pubSubFactory({ publishSynchronous: true }));
- const [isScrollBottomReached, setIsScrollBottomReached] = useState(true);
+ const {
+ actions: { setIsScrollBottomReached },
+ } = useGroupChannel();
// SideEffect: Reset scroll state
useLayoutEffect(() => {
@@ -95,8 +98,6 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen
return {
scrollRef,
scrollPubSub,
- isScrollBottomReached,
- setIsScrollBottomReached,
scrollDistanceFromBottomRef,
scrollPositionRef,
};
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/index.tsx b/src/modules/GroupChannel/context/index.tsx
new file mode 100644
index 0000000000..2c13c89e0c
--- /dev/null
+++ b/src/modules/GroupChannel/context/index.tsx
@@ -0,0 +1,2 @@
+export * from './GroupChannelProvider';
+export { useGroupChannel } from './hooks/useGroupChannel';
diff --git a/src/modules/GroupChannel/context/types.ts b/src/modules/GroupChannel/context/types.ts
new file mode 100644
index 0000000000..76cff319e3
--- /dev/null
+++ b/src/modules/GroupChannel/context/types.ts
@@ -0,0 +1,118 @@
+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';
+import { PropsWithChildren } from 'react';
+
+// Message data source types
+type MessageDataSource = ReturnType;
+export type MessageActions = ReturnType;
+export type MessageListQueryParamsType = Omit