Skip to content

Commit 06bfe35

Browse files
authored
feat: add uikitOptions prop to App & SendbirdProvider (#650)
Addresses https://sendbird.atlassian.net/browse/UIKIT-4194 Added a new prop`uikitOptions` to Sendbird or App like root component, to make user can control the `UIKitConfig` related configs without dashboard config setting.
1 parent 0656af3 commit 06bfe35

File tree

7 files changed

+201
-41
lines changed

7 files changed

+201
-41
lines changed

src/index.d.ts

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
} from '@sendbird/chat/openChannel';
4444
import { UikitMessageHandler } from './lib/selectors';
4545
import { RenderCustomSeparatorProps } from './types';
46+
import { UIKitOptions } from './lib/types';
4647

4748
type ReplyType = 'NONE' | 'QUOTE_REPLY' | 'THREAD';
4849

@@ -123,6 +124,7 @@ interface SendBirdProviderProps {
123124
};
124125
isTypingIndicatorEnabledOnChannelList?: boolean;
125126
isMessageReceiptStatusEnabledOnChannelList?: boolean;
127+
uikitOptions?: UIKitOptions;
126128
}
127129

128130
interface SendBirdStateConfig {
@@ -309,6 +311,7 @@ interface AppProps {
309311
isMentionEnabled?: boolean;
310312
isTypingIndicatorEnabledOnChannelList?: boolean;
311313
isMessageReceiptStatusEnabledOnChannelList?: boolean;
314+
uikitOptions?: UIKitOptions;
312315
}
313316

314317
interface ApplicationUserListQuery {

src/lib/Sendbird.tsx

+26-40
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ import { LocalizationProvider } from './LocalizationContext';
2727
import { MediaQueryProvider } from './MediaQueryContext';
2828
import getStringSet from '../ui/Label/stringSet';
2929
import { VOICE_RECORDER_DEFAULT_MAX, VOICE_RECORDER_DEFAULT_MIN } from '../utils/consts';
30+
import { uikitConfigMapper } from './utils/uikitConfigMapper';
31+
3032
import { useMarkAsReadScheduler } from './hooks/useMarkAsReadScheduler';
3133
import { ConfigureSessionTypes } from './hooks/useConnect/types';
3234
import { useMarkAsDeliveredScheduler } from './hooks/useMarkAsDeliveredScheduler';
3335
import { getCaseResolvedReplyType, getCaseResolvedThreadReplySelectType } from './utils/resolvedReplyType';
3436
import { useUnmount } from '../hooks/useUnmount';
3537
import { disconnectSdk } from './hooks/useConnect/disconnectSdk';
38+
import { UIKitOptions, CommonUIKitConfigProps } from './types';
3639

3740
export type UserListQueryType = {
3841
hasNext?: boolean;
@@ -59,17 +62,6 @@ export interface SendbirdConfig {
5962
};
6063
isREMUnitEnabled?: boolean;
6164
}
62-
63-
interface CommonUIKitConfigProps {
64-
replyType?: 'NONE' | 'QUOTE_REPLY' | 'THREAD';
65-
isMentionEnabled?: boolean;
66-
isReactionEnabled?: boolean;
67-
disableUserProfile?: boolean;
68-
isVoiceMessageEnabled?: boolean;
69-
isTypingIndicatorEnabledOnChannelList?: boolean;
70-
isMessageReceiptStatusEnabledOnChannelList?: boolean;
71-
}
72-
7365
export interface SendbirdProviderProps extends CommonUIKitConfigProps {
7466
appId: string;
7567
userId: string;
@@ -90,46 +82,40 @@ export interface SendbirdProviderProps extends CommonUIKitConfigProps {
9082
imageCompression?: ImageCompressionOptions;
9183
allowProfileEdit?: boolean;
9284
disableMarkAsDelivered?: boolean;
93-
showSearchIcon?: boolean;
9485
breakpoint?: string | boolean;
9586
renderUserProfile?: () => React.ReactElement;
9687
onUserProfileMessage?: () => void;
88+
uikitOptions?: UIKitOptions;
9789
}
9890

9991
function Sendbird(props: SendbirdProviderProps) {
100-
const {
101-
replyType,
102-
isMentionEnabled,
103-
isReactionEnabled,
104-
disableUserProfile,
105-
isVoiceMessageEnabled,
106-
isTypingIndicatorEnabledOnChannelList,
107-
isMessageReceiptStatusEnabledOnChannelList,
108-
showSearchIcon,
109-
} = props;
92+
const localConfigs = uikitConfigMapper({
93+
legacyConfig: {
94+
replyType: props.replyType,
95+
isMentionEnabled: props.isMentionEnabled,
96+
isReactionEnabled: props.isReactionEnabled,
97+
disableUserProfile: props.disableUserProfile,
98+
isVoiceMessageEnabled: props.isVoiceMessageEnabled,
99+
isTypingIndicatorEnabledOnChannelList:
100+
props.isTypingIndicatorEnabledOnChannelList,
101+
isMessageReceiptStatusEnabledOnChannelList:
102+
props.isMessageReceiptStatusEnabledOnChannelList,
103+
showSearchIcon: props.showSearchIcon,
104+
},
105+
uikitOptions: props.uikitOptions,
106+
});
110107

111108
return (
112109
<UIKitConfigProvider
113110
localConfigs={{
114-
common: {
115-
enableUsingDefaultUserProfile: typeof disableUserProfile === 'boolean'
116-
? !disableUserProfile
117-
: undefined,
118-
},
111+
common: localConfigs?.common,
119112
groupChannel: {
120-
channel: {
121-
enableReactions: isReactionEnabled,
122-
enableMention: isMentionEnabled,
123-
enableVoiceMessage: isVoiceMessageEnabled,
124-
replyType: replyType != null ? getCaseResolvedReplyType(replyType).lowerCase : undefined,
125-
},
126-
channelList: {
127-
enableTypingIndicator: isTypingIndicatorEnabledOnChannelList,
128-
enableMessageReceiptStatus: isMessageReceiptStatusEnabledOnChannelList,
129-
},
130-
setting: {
131-
enableMessageSearch: showSearchIcon,
132-
},
113+
channel: localConfigs?.groupChannel,
114+
channelList: localConfigs?.groupChannelList,
115+
setting: localConfigs?.groupChannelSettings,
116+
},
117+
openChannel: {
118+
channel: localConfigs?.openChannel,
133119
},
134120
}}
135121
>

src/lib/types.ts

+20
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Logger } from './SendbirdState';
2525
import { ReplyType } from 'SendbirdUIKitGlobal';
2626
import { MarkAsReadSchedulerType } from './hooks/useMarkAsReadScheduler';
2727
import { MarkAsDeliveredSchedulerType } from './hooks/useMarkAsDeliveredScheduler';
28+
import { PartialDeep } from '../utils/typeHelpers/partialDeep';
2829

2930
import { SBUConfig } from '@sendbird/uikit-tools';
3031

@@ -224,3 +225,22 @@ export interface sendbirdSelectorsInterface {
224225
getResendUserMessage: (store: SendBirdState) => GetResendUserMessage;
225226
getResendFileMessage: (store: SendBirdState) => GetResendFileMessage;
226227
}
228+
229+
export interface CommonUIKitConfigProps {
230+
replyType?: 'NONE' | 'QUOTE_REPLY' | 'THREAD';
231+
isMentionEnabled?: boolean;
232+
isReactionEnabled?: boolean;
233+
disableUserProfile?: boolean;
234+
isVoiceMessageEnabled?: boolean;
235+
isTypingIndicatorEnabledOnChannelList?: boolean;
236+
isMessageReceiptStatusEnabledOnChannelList?: boolean;
237+
showSearchIcon?: boolean;
238+
}
239+
240+
export type UIKitOptions = PartialDeep<{
241+
common: SBUConfig['common'];
242+
groupChannel: SBUConfig['groupChannel']['channel'];
243+
groupChannelList: SBUConfig['groupChannel']['channelList'];
244+
groupChannelSettings: SBUConfig['groupChannel']['setting'];
245+
openChannel: SBUConfig['openChannel']['channel'];
246+
}>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { getCaseResolvedReplyType } from '../resolvedReplyType';
2+
import { uikitConfigMapper } from '../uikitConfigMapper';
3+
import { CommonUIKitConfigProps, UIKitOptions } from '../../types';
4+
5+
const mockLegacyConfig = {
6+
// common related
7+
disableUserProfile: false,
8+
// group channel related
9+
isMentionEnabled: true,
10+
replyType: 'THREAD',
11+
isReactionEnabled: true,
12+
isVoiceMessageEnabled: true,
13+
// group channel list related
14+
isTypingIndicatorEnabledOnChannelList: true,
15+
isMessageReceiptStatusEnabledOnChannelList: true,
16+
// group channel setting related
17+
showSearchIcon: true,
18+
} as CommonUIKitConfigProps;
19+
20+
describe('uikitConfigMapper', () => {
21+
it('should correctly map legacy configs to each corresponding uikitOptions', () => {
22+
const result = uikitConfigMapper({ legacyConfig: mockLegacyConfig });
23+
24+
expect(result.common?.enableUsingDefaultUserProfile).toBe(true);
25+
expect(result.groupChannel?.enableMention).toBe(true);
26+
expect(result.groupChannel?.enableReactions).toBe(true);
27+
expect(result.groupChannel?.replyType).toBe(getCaseResolvedReplyType('THREAD').lowerCase);
28+
expect(result.groupChannel?.enableVoiceMessage).toBe(true);
29+
expect(result.groupChannelList?.enableMessageReceiptStatus).toBe(true);
30+
expect(result.groupChannelList?.enableTypingIndicator).toBe(true);
31+
expect(result.groupChannelSettings?.enableMessageSearch).toBe(true);
32+
});
33+
34+
it('should return new uikitOptions too whichs are not existing in legacy configs', () => {
35+
const result = uikitConfigMapper({ legacyConfig: mockLegacyConfig });
36+
37+
expect(result).toHaveProperty('groupChannel.enableOgtag');
38+
expect(result).toHaveProperty('groupChannel.enableTypingIndicator');
39+
expect(result).toHaveProperty('groupChannel.threadReplySelectType');
40+
expect(result).toHaveProperty('groupChannel.input.enableDocument');
41+
42+
expect(result).toHaveProperty('openChannel.enableOgtag');
43+
expect(result).toHaveProperty('openChannel.input.enableDocument');
44+
});
45+
it('should return the correct result; uikitOptions takes predecence over legacy configs', () => {
46+
const legacyConfig = {
47+
isMentionEnabled: true,
48+
showSearchIcon: true,
49+
};
50+
const uikitOptions = {
51+
groupChannel: {
52+
enableMention: false,
53+
},
54+
groupChannelSettings: {
55+
enableMessageSearch: false,
56+
},
57+
} as UIKitOptions;
58+
const result = uikitConfigMapper({ legacyConfig, uikitOptions });
59+
60+
expect(result.groupChannel?.enableMention).toBe(false);
61+
expect(result.groupChannelSettings?.enableMessageSearch).toBe(false);
62+
});
63+
it('should return true <-> false flipped result for disableUserProfile when its converted into enableUsingDefaultUserProfile', () => {
64+
expect(
65+
uikitConfigMapper({ legacyConfig: { disableUserProfile: false } })
66+
.common?.enableUsingDefaultUserProfile,
67+
).toBe(true);
68+
expect(
69+
uikitConfigMapper({ legacyConfig: { disableUserProfile: undefined } })
70+
.common?.enableUsingDefaultUserProfile,
71+
).toBe(undefined);
72+
expect(
73+
uikitConfigMapper({ legacyConfig: { disableUserProfile: true } })
74+
.common?.enableUsingDefaultUserProfile,
75+
).toBe(false);
76+
});
77+
});

src/lib/utils/uikitConfigMapper.ts

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { UIKitOptions, CommonUIKitConfigProps } from '../types';
2+
import { getCaseResolvedReplyType } from './resolvedReplyType';
3+
4+
export function uikitConfigMapper({
5+
legacyConfig,
6+
uikitOptions = {},
7+
}: { legacyConfig: CommonUIKitConfigProps, uikitOptions?: UIKitOptions }): UIKitOptions {
8+
const {
9+
replyType,
10+
isMentionEnabled,
11+
isReactionEnabled,
12+
disableUserProfile,
13+
isVoiceMessageEnabled,
14+
isTypingIndicatorEnabledOnChannelList,
15+
isMessageReceiptStatusEnabledOnChannelList,
16+
showSearchIcon,
17+
} = legacyConfig;
18+
return {
19+
common: {
20+
enableUsingDefaultUserProfile: uikitOptions.common?.enableUsingDefaultUserProfile
21+
?? (typeof disableUserProfile === 'boolean'
22+
? !disableUserProfile
23+
: undefined),
24+
},
25+
groupChannel: {
26+
enableOgtag: uikitOptions.groupChannel?.enableOgtag,
27+
enableMention: uikitOptions.groupChannel?.enableMention ?? isMentionEnabled,
28+
enableReactions: uikitOptions.groupChannel?.enableReactions ?? isReactionEnabled,
29+
enableTypingIndicator: uikitOptions.groupChannel?.enableTypingIndicator,
30+
enableVoiceMessage: uikitOptions.groupChannel?.enableVoiceMessage ?? isVoiceMessageEnabled,
31+
replyType: uikitOptions.groupChannel?.replyType
32+
?? (replyType != null ? getCaseResolvedReplyType(replyType).lowerCase : undefined),
33+
threadReplySelectType: uikitOptions.groupChannel?.threadReplySelectType,
34+
input: {
35+
enableDocument: uikitOptions.groupChannel?.input?.enableDocument,
36+
},
37+
},
38+
groupChannelList: {
39+
enableTypingIndicator: uikitOptions.groupChannelList?.enableTypingIndicator ?? isTypingIndicatorEnabledOnChannelList,
40+
enableMessageReceiptStatus: uikitOptions.groupChannelList?.enableMessageReceiptStatus ?? isMessageReceiptStatusEnabledOnChannelList,
41+
},
42+
groupChannelSettings: {
43+
enableMessageSearch: uikitOptions.groupChannelSettings?.enableMessageSearch ?? showSearchIcon,
44+
},
45+
openChannel: {
46+
enableOgtag: uikitOptions.openChannel?.enableOgtag,
47+
input: {
48+
enableDocument: uikitOptions.openChannel?.input?.enableDocument,
49+
},
50+
},
51+
};
52+
}

src/modules/App/index.jsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export default function App(props) {
4545
disableAutoSelect,
4646
isTypingIndicatorEnabledOnChannelList,
4747
isMessageReceiptStatusEnabledOnChannelList,
48+
uikitOptions,
4849
} = props;
4950
const [currentChannel, setCurrentChannel] = useState(null);
5051
return (
@@ -78,13 +79,14 @@ export default function App(props) {
7879
isMessageReceiptStatusEnabledOnChannelList={isMessageReceiptStatusEnabledOnChannelList}
7980
replyType={replyType}
8081
showSearchIcon={showSearchIcon}
82+
uikitOptions={uikitOptions}
8183
>
8284
<AppLayout
8385
isReactionEnabled={isReactionEnabled}
8486
replyType={replyType}
87+
showSearchIcon={showSearchIcon}
8588
isMessageGroupingEnabled={isMessageGroupingEnabled}
8689
allowProfileEdit={allowProfileEdit}
87-
showSearchIcon={showSearchIcon}
8890
onProfileEditSuccess={onProfileEditSuccess}
8991
disableAutoSelect={disableAutoSelect}
9092
currentChannel={currentChannel}
@@ -122,6 +124,7 @@ App.propTypes = {
122124
]),
123125
isREMUnitEnabled: PropTypes.bool,
124126
}),
127+
uikitOptions: PropTypes.shape({}),
125128
isReactionEnabled: PropTypes.bool,
126129
replyType: PropTypes.oneOf(['NONE', 'QUOTE_REPLY', 'THREAD']),
127130
showSearchIcon: PropTypes.bool,
@@ -174,6 +177,7 @@ App.defaultProps = {
174177
colorSet: null,
175178
imageCompression: {},
176179
disableAutoSelect: false,
180+
uikitOptions: undefined,
177181

178182
// The below configs are duplicates of the Dashboard UIKit Configs.
179183
// Since their default values will be set in the Sendbird component,

src/utils/typeHelpers/partialDeep.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* PartialDeep enables partial deep cloning of objects or nested objects.
3+
* It recursively makes the properties of the given type optional,
4+
* allowing partial modification at any level of nesting.
5+
*
6+
* Use case:
7+
* When working with complex data structures, selectively modify properties of an object
8+
* while maintaining the original structure and values.
9+
*
10+
* Brought the simplified idea from https://github.com/sindresorhus/type-fest/blob/main/source/partial-deep.d.ts
11+
* */
12+
export type PartialDeep<T> = T extends object
13+
? T extends (...args: any[]) => any
14+
? T
15+
: {
16+
[P in keyof T]?: PartialDeep<T[P]>;
17+
}
18+
: T;

0 commit comments

Comments
 (0)