Skip to content

Commit 2720797

Browse files
committed
[CLNP-5047] Migrate GroupChannelProvider to new state management pattern (#1246)
Addresses https://sendbird.atlassian.net/browse/CLNP-5047 This PR migrates the GroupChannelProvider to use a new state management pattern. This change introduces a more predictable and maintainable way to manage channel state while maintaining backward compatibility. - Introduced new store-based state management - Separated concerns between state management and event handling - Added `useGroupChannel` hook for accessing state and actions - Optimized performance with proper memoization Old pattern: ```typescript const { someState, someHandler } = useGroupChannelContext(); ``` New pattern: ```typescript // For state and actions const { state, actions } = useGroupChannel(); // For handlers and props (backward compatibility) const { someHandler } = useGroupChannelContext(); ``` - More predictable state updates - Better separation of concerns - Enhanced performance through optimized renders - All existing functionality remains unchanged - Added tests for new hooks and state management - Verified backward compatibility - Tested with real-time updates and message handling - [x] useGroupChannelContext is kept for backward compatibility - [x] Unit & integration tests will be added for new hooks and state management Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If unsure, ask the members. This is a reminder of what we look for before merging your code. - [x] **All tests pass locally with my changes** - [x] **I have added tests that prove my fix is effective or that my feature works** - [ ] **Public components / utils / props are appropriately exported** - [ ] I have added necessary documentation (if appropriate)
1 parent 0cc69d5 commit 2720797

File tree

28 files changed

+1390
-433
lines changed

28 files changed

+1390
-433
lines changed

apps/testing/vite.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import postcssRtlOptions from '../../postcssRtlOptions.mjs';
99
export default defineConfig({
1010
plugins: [react(), vitePluginSvgr({ include: '**/*.svg' })],
1111
css: {
12+
preprocessorOptions: {
13+
scss: {
14+
silenceDeprecations: ['legacy-js-api'],
15+
},
16+
},
1217
postcss: {
1318
plugins: [postcssRtl(postcssRtlOptions)],
1419
},

src/modules/Channel/context/ChannelProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import useUpdateMessageCallback from './hooks/useUpdateMessageCallback';
4343
import useResendMessageCallback from './hooks/useResendMessageCallback';
4444
import useSendMessageCallback from './hooks/useSendMessageCallback';
4545
import useSendFileMessageCallback from './hooks/useSendFileMessageCallback';
46-
import useToggleReactionCallback from '../../GroupChannel/context/hooks/useToggleReactionCallback';
46+
import useToggleReactionCallback from './hooks/useToggleReactionCallback';
4747
import useScrollToMessage from './hooks/useScrollToMessage';
4848
import useSendVoiceMessageCallback from './hooks/useSendVoiceMessageCallback';
4949
import { getCaseResolvedReplyType, getCaseResolvedThreadReplySelectType } from '../../../lib/utils/resolvedReplyType';

src/modules/Channel/context/hooks/useToggleReactionCallback.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,53 @@ import { GroupChannel } from '@sendbird/chat/groupChannel';
33
import { LoggerInterface } from '../../../../lib/Logger';
44
import { BaseMessage } from '@sendbird/chat/message';
55

6-
type UseToggleReactionCallbackOptions = {
7-
currentGroupChannel: GroupChannel | null;
8-
};
9-
type UseToggleReactionCallbackParams = {
10-
logger: LoggerInterface;
11-
};
6+
const LOG_PRESET = 'useToggleReactionCallback:';
7+
8+
/**
9+
* POTENTIAL IMPROVEMENT NEEDED:
10+
* Current implementation might have race condition issues when the hook is called multiple times in rapid succession:
11+
*
12+
* 1. Race Condition Risk:
13+
* - Multiple rapid clicks on reaction buttons could trigger concurrent API calls
14+
* - The server responses might arrive in different order than the requests were sent
15+
* - This could lead to inconsistent UI states where the final reaction state doesn't match user's last action
16+
*
17+
* 2. Performance Impact:
18+
* - Each click generates a separate API call without debouncing/throttling
19+
* - Under high-frequency clicks, this could cause unnecessary server load
20+
*
21+
* But we won't address these issues for now since it's being used only in the legacy codebase.
22+
* */
1223
export default function useToggleReactionCallback(
13-
{ currentGroupChannel }: UseToggleReactionCallbackOptions,
14-
{ logger }: UseToggleReactionCallbackParams,
24+
currentChannel: GroupChannel | null,
25+
logger?: LoggerInterface,
1526
) {
1627
return useCallback(
1728
(message: BaseMessage, key: string, isReacted: boolean) => {
29+
if (!currentChannel) {
30+
logger?.warning(`${LOG_PRESET} currentChannel doesn't exist`, currentChannel);
31+
return;
32+
}
1833
if (isReacted) {
19-
currentGroupChannel
20-
?.deleteReaction(message, key)
34+
currentChannel
35+
.deleteReaction(message, key)
2136
.then((res) => {
22-
logger.info('Delete reaction success', res);
37+
logger?.info(`${LOG_PRESET} Delete reaction success`, res);
2338
})
2439
.catch((err) => {
25-
logger.warning('Delete reaction failed', err);
40+
logger?.warning(`${LOG_PRESET} Delete reaction failed`, err);
2641
});
2742
} else {
28-
currentGroupChannel
29-
?.addReaction(message, key)
43+
currentChannel
44+
.addReaction(message, key)
3045
.then((res) => {
31-
logger.info('Add reaction success', res);
46+
logger?.info(`${LOG_PRESET} Add reaction success`, res);
3247
})
3348
.catch((err) => {
34-
logger.warning('Add reaction failed', err);
49+
logger?.warning(`${LOG_PRESET} Add reaction failed`, err);
3550
});
3651
}
3752
},
38-
[currentGroupChannel],
53+
[currentChannel],
3954
);
4055
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom/extend-expect';
4+
import { GroupChannelUIView } from '../components/GroupChannelUI/GroupChannelUIView';
5+
import useSendbirdStateContext from '../../../hooks/useSendbirdStateContext';
6+
7+
jest.mock('../../../hooks/useSendbirdStateContext');
8+
9+
const mockUseSendbirdStateContext = useSendbirdStateContext as jest.Mock;
10+
11+
describe('GroupChannelUIView Integration Tests', () => {
12+
const defaultProps = {
13+
channelUrl: 'test-channel',
14+
isInvalid: false,
15+
renderChannelHeader: jest.fn(() => <div>Channel Header</div>),
16+
renderMessageList: jest.fn(() => <div>Message List</div>),
17+
renderMessageInput: jest.fn(() => <div>Message Input</div>),
18+
};
19+
20+
beforeEach(() => {
21+
mockUseSendbirdStateContext.mockImplementation(() => ({
22+
stores: {
23+
sdkStore: { error: null },
24+
},
25+
config: {
26+
logger: { info: jest.fn() },
27+
isOnline: true,
28+
groupChannel: {
29+
enableTypingIndicator: true,
30+
typingIndicatorTypes: new Set(['text']),
31+
},
32+
},
33+
}));
34+
});
35+
36+
it('renders basic channel components correctly', () => {
37+
render(<GroupChannelUIView {...defaultProps} />);
38+
39+
expect(screen.getByText('Channel Header')).toBeInTheDocument();
40+
expect(screen.getByText('Message List')).toBeInTheDocument();
41+
expect(screen.getByText('Message Input')).toBeInTheDocument();
42+
});
43+
44+
it('renders loading placeholder when isLoading is true', () => {
45+
render(<GroupChannelUIView {...defaultProps} isLoading={true} />);
46+
// Placeholder is a just loading spinner in this case
47+
expect(screen.getByRole('button')).toHaveClass('sendbird-icon-spinner');
48+
});
49+
50+
it('renders invalid placeholder when channelUrl is missing', () => {
51+
render(<GroupChannelUIView {...defaultProps} channelUrl="" />);
52+
expect(screen.getByText('No channels')).toBeInTheDocument();
53+
});
54+
55+
it('renders error placeholder when isInvalid is true', () => {
56+
render(<GroupChannelUIView {...defaultProps} isInvalid={true} />);
57+
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
58+
});
59+
60+
it('renders SDK error placeholder when SDK has error', () => {
61+
mockUseSendbirdStateContext.mockImplementation(() => ({
62+
stores: {
63+
sdkStore: { error: new Error('SDK Error') },
64+
},
65+
config: {
66+
logger: { info: jest.fn() },
67+
isOnline: true,
68+
},
69+
}));
70+
71+
render(<GroupChannelUIView {...defaultProps} />);
72+
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
73+
expect(screen.getByText('Retry')).toBeInTheDocument();
74+
});
75+
76+
it('renders custom placeholders when provided', () => {
77+
const renderPlaceholderLoader = () => <div>Custom Loader</div>;
78+
const renderPlaceholderInvalid = () => <div>Custom Invalid</div>;
79+
80+
const { rerender } = render(
81+
<GroupChannelUIView
82+
{...defaultProps}
83+
isLoading={true}
84+
renderPlaceholderLoader={renderPlaceholderLoader}
85+
/>,
86+
);
87+
expect(screen.getByText('Custom Loader')).toBeInTheDocument();
88+
89+
rerender(
90+
<GroupChannelUIView
91+
{...defaultProps}
92+
isInvalid={true}
93+
renderPlaceholderInvalid={renderPlaceholderInvalid}
94+
/>,
95+
);
96+
expect(screen.getByText('Custom Invalid')).toBeInTheDocument();
97+
});
98+
});

src/modules/GroupChannel/components/FileViewer/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React from 'react';
22
import type { FileMessage } from '@sendbird/chat/message';
33

4+
import { useGroupChannel } from '../../context/hooks/useGroupChannel';
45
import { FileViewerView } from './FileViewerView';
5-
import { useGroupChannelContext } from '../../context/GroupChannelProvider';
66
import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
77

88
export interface FileViewerProps {
@@ -11,7 +11,10 @@ export interface FileViewerProps {
1111
}
1212

1313
export const FileViewer = (props: FileViewerProps) => {
14-
const { deleteMessage, onBeforeDownloadFileMessage } = useGroupChannelContext();
14+
const {
15+
state: { onBeforeDownloadFileMessage },
16+
actions: { deleteMessage },
17+
} = useGroupChannel();
1518
const { config } = useSendbirdStateContext();
1619
const { logger } = config;
1720
return (

src/modules/GroupChannel/components/GroupChannelUI/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import GroupChannelHeader, { GroupChannelHeaderProps } from '../GroupChannelHead
77
import MessageList, { GroupChannelMessageListProps } from '../MessageList';
88
import MessageInputWrapper from '../MessageInputWrapper';
99
import { deleteNullish } from '../../../../utils/utils';
10+
import { useGroupChannel } from '../../context/hooks/useGroupChannel';
1011

1112
export interface GroupChannelUIProps extends GroupChannelUIBasicProps {}
1213

1314
export const GroupChannelUI = (props: GroupChannelUIProps) => {
1415
const context = useGroupChannelContext();
15-
const { channelUrl, fetchChannelError } = context;
16+
const { state: { channelUrl, fetchChannelError } } = useGroupChannel();
1617

1718
// Inject components to presentation layer
1819
const {

src/modules/GroupChannel/components/Message/MessageView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import MessageContent, { MessageContentProps } from '../../../../ui/MessageConte
2020

2121
import SuggestedReplies, { SuggestedRepliesProps } from '../SuggestedReplies';
2222
import SuggestedMentionListView from '../SuggestedMentionList/SuggestedMentionListView';
23-
import type { OnBeforeDownloadFileMessageType } from '../../context/GroupChannelProvider';
23+
import type { OnBeforeDownloadFileMessageType } from '../../context/types';
2424
import { classnames, deleteNullish } from '../../../../utils/utils';
2525

2626
export interface MessageProps {

src/modules/GroupChannel/components/Message/index.tsx

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,42 @@ import { useIIFE } from '@sendbird/uikit-tools';
44
import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
55
import { getSuggestedReplies, isSendableMessage } from '../../../../utils';
66
import { isDisabledBecauseFrozen, isDisabledBecauseMuted } from '../../context/utils';
7-
import { useGroupChannelContext } from '../../context/GroupChannelProvider';
87
import MessageView, { MessageProps } from './MessageView';
98
import FileViewer from '../FileViewer';
109
import RemoveMessageModal from '../RemoveMessageModal';
1110
import { ThreadReplySelectType } from '../../context/const';
11+
import { useGroupChannel } from '../../context/hooks/useGroupChannel';
1212

1313
export const Message = (props: MessageProps): React.ReactElement => {
1414
const { config, emojiManager } = useSendbirdStateContext();
1515
const {
16-
loading,
17-
currentChannel,
18-
animatedMessageId,
19-
setAnimatedMessageId,
20-
scrollToMessage,
21-
replyType,
22-
threadReplySelectType,
23-
isReactionEnabled,
24-
toggleReaction,
25-
nicknamesMap,
26-
setQuoteMessage,
27-
renderUserMentionItem,
28-
filterEmojiCategoryIds,
29-
onQuoteMessageClick,
30-
onReplyInThreadClick,
31-
onMessageAnimated,
32-
onBeforeDownloadFileMessage,
33-
messages,
34-
updateUserMessage,
35-
sendUserMessage,
36-
resendMessage,
37-
deleteMessage,
38-
} = useGroupChannelContext();
16+
state: {
17+
loading,
18+
currentChannel,
19+
animatedMessageId,
20+
replyType,
21+
threadReplySelectType,
22+
isReactionEnabled,
23+
nicknamesMap,
24+
renderUserMentionItem,
25+
filterEmojiCategoryIds,
26+
onQuoteMessageClick,
27+
onReplyInThreadClick,
28+
onMessageAnimated,
29+
onBeforeDownloadFileMessage,
30+
messages,
31+
},
32+
actions: {
33+
toggleReaction,
34+
setQuoteMessage,
35+
setAnimatedMessageId,
36+
scrollToMessage,
37+
updateUserMessage,
38+
sendUserMessage,
39+
resendMessage,
40+
deleteMessage,
41+
},
42+
} = useGroupChannel();
3943

4044
const { message } = props;
4145
const initialized = !loading && Boolean(currentChannel);

src/modules/GroupChannel/components/MessageInputWrapper/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import MessageInputWrapperView from './MessageInputWrapperView';
3-
import { useGroupChannelContext } from '../../context/GroupChannelProvider';
43
import { GroupChannelUIBasicProps } from '../GroupChannelUI/GroupChannelUIView';
4+
import { useGroupChannel } from '../../context/hooks/useGroupChannel';
55

66
export interface MessageInputWrapperProps {
77
value?: string;
@@ -13,8 +13,8 @@ export interface MessageInputWrapperProps {
1313
}
1414

1515
export const MessageInputWrapper = (props: MessageInputWrapperProps) => {
16-
const context = useGroupChannelContext();
17-
return <MessageInputWrapperView {...props} {...context} />;
16+
const { state, actions } = useGroupChannel();
17+
return <MessageInputWrapperView {...props} {...state} { ...actions} />;
1818
};
1919

2020
export { VoiceMessageInputWrapper, type VoiceMessageInputWrapperProps } from './VoiceMessageInputWrapper';

src/modules/GroupChannel/components/MessageList/index.tsx

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ import FrozenNotification from '../FrozenNotification';
1414
import { SCROLL_BUFFER } from '../../../../utils/consts';
1515
import useSendbirdStateContext from '../../../../hooks/useSendbirdStateContext';
1616
import TypingIndicatorBubble from '../../../../ui/TypingIndicatorBubble';
17-
import { useGroupChannelContext } from '../../context/GroupChannelProvider';
1817
import { GroupChannelUIBasicProps } from '../GroupChannelUI/GroupChannelUIView';
1918
import { deleteNullish } from '../../../../utils/utils';
2019
import { getMessagePartsInfo } from './getMessagePartsInfo';
2120
import { MessageProvider } from '../../../Message/context/MessageProvider';
2221
import { getComponentKeyFromMessage } from '../../context/utils';
2322
import { InfiniteList } from './InfiniteList';
23+
import { useGroupChannel } from '../../context/hooks/useGroupChannel';
2424

2525
export interface GroupChannelMessageListProps {
2626
className?: string;
@@ -67,25 +67,29 @@ export const MessageList = (props: GroupChannelMessageListProps) => {
6767
} = deleteNullish(props);
6868

6969
const {
70-
channelUrl,
71-
hasNext,
72-
loading,
73-
messages,
74-
newMessages,
75-
scrollToBottom,
76-
isScrollBottomReached,
77-
isMessageGroupingEnabled,
78-
scrollRef,
79-
scrollDistanceFromBottomRef,
80-
scrollPositionRef,
81-
currentChannel,
82-
replyType,
83-
scrollPubSub,
84-
loadNext,
85-
loadPrevious,
86-
setIsScrollBottomReached,
87-
resetNewMessages,
88-
} = useGroupChannelContext();
70+
state: {
71+
channelUrl,
72+
hasNext,
73+
loading,
74+
messages,
75+
newMessages,
76+
isScrollBottomReached,
77+
isMessageGroupingEnabled,
78+
currentChannel,
79+
replyType,
80+
scrollPubSub,
81+
loadNext,
82+
loadPrevious,
83+
resetNewMessages,
84+
scrollRef,
85+
scrollPositionRef,
86+
scrollDistanceFromBottomRef,
87+
},
88+
actions: {
89+
scrollToBottom,
90+
setIsScrollBottomReached,
91+
},
92+
} = useGroupChannel();
8993

9094
const store = useSendbirdStateContext();
9195

0 commit comments

Comments
 (0)