Skip to content

Commit e1f4669

Browse files
authored
feat: Apply markdowns to text messages (#1120)
Fixes: [AC-2156](https://sendbird.atlassian.net/browse/AC-2156) Added `enableMarkdownForUserMessage` to `UIKitOptions`. When enabled, user messages that include markdowns syntaxes for bold and link are now being applied to the original text.
1 parent 5d24102 commit e1f4669

File tree

19 files changed

+297
-26
lines changed

19 files changed

+297
-26
lines changed

apps/testing/src/utils/paramsBuilder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export const paramKeys = [
9494
'groupChannel_enableFeedback',
9595
'groupChannel_enableSuggestedReplies',
9696
'groupChannel_showSuggestedRepliesFor',
97+
'groupChannel_enableMarkdownForUserMessage',
9798
'groupChannelList_enableTypingIndicator',
9899
'groupChannelList_enableMessageReceiptStatus',
99100
'groupChannelSettings_enableMessageSearch',

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@
7070
},
7171
"dependencies": {
7272
"@sendbird/chat": "^4.12.3",
73-
"@sendbird/react-uikit-message-template-view": "0.0.1-alpha.73",
74-
"@sendbird/uikit-tools": "0.0.1-alpha.73",
73+
"@sendbird/react-uikit-message-template-view": "0.0.1-alpha.76",
74+
"@sendbird/uikit-tools": "0.0.1-alpha.76",
7575
"css-vars-ponyfill": "^2.3.2",
7676
"date-fns": "^2.16.1",
7777
"dompurify": "^3.0.1"

src/lib/Sendbird.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ const SendbirdSDK = ({
185185
const [appInfoStore, appInfoDispatcher] = useReducer(appInfoReducers, appInfoInitialState);
186186

187187
const { configs, configsWithAppAttr, initDashboardConfigs } = useUIKitConfig();
188+
188189
const sdkInitialized = sdkStore.initialized;
189190
const sdk = sdkStore?.sdk;
190191
const { uploadSizeLimit, multipleFilesMessageFileCountLimit } = sdk?.appInfo ?? {};
@@ -370,6 +371,7 @@ const SendbirdSDK = ({
370371
enableSuggestedReplies: configs.groupChannel.channel.enableSuggestedReplies,
371372
showSuggestedRepliesFor: configs.groupChannel.channel.showSuggestedRepliesFor,
372373
suggestedRepliesDirection: configs.groupChannel.channel.suggestedRepliesDirection,
374+
enableMarkdownForUserMessage: configs.groupChannel.channel.enableMarkdownForUserMessage,
373375
},
374376
groupChannelList: {
375377
enableTypingIndicator: configs.groupChannel.channelList.enableTypingIndicator,

src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export interface SendBirdStateConfig {
109109
enableSuggestedReplies: SBUConfig['groupChannel']['channel']['enableSuggestedReplies'];
110110
showSuggestedRepliesFor: SBUConfig['groupChannel']['channel']['showSuggestedRepliesFor'];
111111
suggestedRepliesDirection: SBUConfig['groupChannel']['channel']['suggestedRepliesDirection'];
112+
enableMarkdownForUserMessage: SBUConfig['groupChannel']['channel']['enableMarkdownForUserMessage'];
112113
},
113114
groupChannelList: {
114115
enableTypingIndicator: SBUConfig['groupChannel']['channelList']['enableTypingIndicator'];

src/lib/utils/uikitConfigMapper.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function uikitConfigMapper({
1515
isMessageReceiptStatusEnabledOnChannelList,
1616
showSearchIcon,
1717
} = legacyConfig;
18+
1819
return {
1920
common: {
2021
enableUsingDefaultUserProfile: uikitOptions.common?.enableUsingDefaultUserProfile
@@ -38,6 +39,7 @@ export function uikitConfigMapper({
3839
enableSuggestedReplies: uikitOptions.groupChannel?.enableSuggestedReplies,
3940
showSuggestedRepliesFor: uikitOptions.groupChannel?.showSuggestedRepliesFor,
4041
suggestedRepliesDirection: uikitOptions.groupChannel?.suggestedRepliesDirection,
42+
enableMarkdownForUserMessage: uikitOptions.groupChannel?.enableMarkdownForUserMessage,
4143
},
4244
groupChannelList: {
4345
enableTypingIndicator: uikitOptions.groupChannelList?.enableTypingIndicator ?? isTypingIndicatorEnabledOnChannelList,

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import React from 'react';
22
import { UserMessage } from '@sendbird/chat/message';
33
import { match } from 'ts-pattern';
44

5-
import { TOKEN_TYPES, Token } from '../../utils/tokens/types';
5+
import { TOKEN_TYPES, Token, MarkdownToken } from '../../utils/tokens/types';
66
import { useMessageContext } from '../../context/MessageProvider';
77
import { keyGenerator } from '../../utils/tokens/keyGenerator';
88
import MentionLabel from '../../../../ui/MentionLabel';
99
import { USER_MENTION_PREFIX } from '../../consts';
1010
import LinkLabel from '../../../../ui/LinkLabel';
11-
import { LabelTypography } from '../../../../ui/Label';
12-
import { getWhiteSpacePreservedText } from '../../utils/tokens/tokenize';
11+
import { LabelColors, LabelTypography } from '../../../../ui/Label';
12+
import { getWhiteSpacePreservedText, tokenizeMarkdown } from '../../utils/tokens/tokenize';
13+
import { asSafeURL } from '../../utils/tokens/asSafeURL';
1314

1415
export type TextFragmentProps = {
1516
tokens: Token[];
@@ -29,6 +30,35 @@ export default function TextFragment({
2930
{tokens?.map((token, idx) => {
3031
const key = keyGenerator(createdAt, updatedAt, idx);
3132
return match(token.type)
33+
.with(TOKEN_TYPES.markdown, () => {
34+
const markdownToken = token as MarkdownToken;
35+
const groups = markdownToken.groups;
36+
return <span className="sendbird-word" key={key} data-testid="sendbird-ui-word">
37+
{
38+
match(markdownToken.markdownType)
39+
.with('bold', () => (
40+
<span style={{ fontWeight: 'bold' }}>
41+
<TextFragment tokens={tokenizeMarkdown({ messageText: groups[1] })}/>
42+
</span>
43+
))
44+
.with('url', () => {
45+
return (
46+
<a
47+
className={
48+
isByMe
49+
? 'sendbird-label--color-oncontent-1'
50+
: 'sendbird-label--color-onbackground-1'
51+
}
52+
href={asSafeURL(groups[2])}
53+
>
54+
<TextFragment tokens={tokenizeMarkdown({ messageText: groups[1] })}/>
55+
</a>
56+
);
57+
})
58+
.otherwise(() => <></>)
59+
}
60+
</span>;
61+
})
3262
.with(TOKEN_TYPES.mention, () => (
3363
<span className="sendbird-word" key={key} data-testid="sendbird-ui-word">
3464
<MentionLabel
@@ -43,9 +73,9 @@ export default function TextFragment({
4373
.with(TOKEN_TYPES.url, () => (
4474
<span className="sendbird-word" key={key} data-testid="sendbird-ui-word">
4575
<LinkLabel
46-
className="sendbird-word__url"
4776
src={token.value}
4877
type={LabelTypography.BODY_1}
78+
color={isByMe ? LabelColors.ONCONTENT_1 : LabelColors.ONBACKGROUND_1}
4979
>
5080
{token.value}
5181
</LinkLabel>

src/modules/Message/utils/tokens/__tests__/tokenizeMessage.spec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,109 @@ describe('tokenizeMessage', () => {
5555
type: 'url',
5656
}]);
5757
});
58+
59+
it('should tokenize a string markdown strings', () => {
60+
const tokens = tokenizeMessage({
61+
messageText: '**bold** https://www.naver.com ** https://www.naver.com ** [bold] **text** **text**',
62+
includeMarkdown: true,
63+
});
64+
expect(tokens).toEqual([
65+
{ value: '', type: 'string' },
66+
{
67+
type: 'markdown',
68+
markdownType: 'bold',
69+
value: '**bold**',
70+
groups: ['**bold**', 'bold'],
71+
},
72+
{ value: ' ', type: 'string' },
73+
{ value: 'https://www.naver.com', type: 'url' },
74+
{ value: ' ', type: 'string' },
75+
{
76+
type: 'markdown',
77+
markdownType: 'bold',
78+
value: '** https://www.naver.com **',
79+
groups: ['** https://www.naver.com **', ' https://www.naver.com '],
80+
},
81+
{ value: ' [bold] ', type: 'string' },
82+
{
83+
type: 'markdown',
84+
markdownType: 'bold',
85+
value: '**text**',
86+
groups: ['**text**', 'text'],
87+
},
88+
{ value: ' ', type: 'string' },
89+
{
90+
type: 'markdown',
91+
markdownType: 'bold',
92+
value: '**text**',
93+
groups: ['**text**', 'text'],
94+
},
95+
]);
96+
});
97+
98+
it('should tokenize a string with mention, markdown strings', () => {
99+
const tokens = tokenizeMessage({
100+
messageText: 'Hello @{userA}! This is a test for **tokenizeMessage**. Followings are urls with different '
101+
+ 'syntaxes: https://example.com, and [here](https://example.com). Finally, the followings are nested '
102+
+ 'markdown cases: [**this one**](https://example.com) and **[this one](https://example.com)**',
103+
mentionedUsers: [
104+
({ userId: 'userA', nickname: 'User A' } as User),
105+
],
106+
includeMarkdown: true,
107+
});
108+
expect(tokens).toEqual([
109+
{ value: 'Hello ', type: 'string' },
110+
{ value: 'User A', type: 'mention', userId: 'userA' },
111+
{ value: '! This is a test for ', type: 'string' },
112+
{
113+
type: 'markdown',
114+
markdownType: 'bold',
115+
value: '**tokenizeMessage**',
116+
groups: ['**tokenizeMessage**', 'tokenizeMessage'],
117+
},
118+
{
119+
value: '. Followings are urls with different syntaxes: ',
120+
type: 'string',
121+
},
122+
{ value: 'https://example.com', type: 'url' },
123+
{ value: ', and ', type: 'string' },
124+
{
125+
type: 'markdown',
126+
markdownType: 'url',
127+
value: '[here](https://example.com)',
128+
groups: [
129+
'[here](https://example.com)',
130+
'here',
131+
'https://example.com',
132+
],
133+
},
134+
{
135+
value: '. Finally, the followings are nested markdown cases: ',
136+
type: 'string',
137+
},
138+
{
139+
groups: [
140+
'[**this one**](https://example.com)',
141+
'**this one**',
142+
'https://example.com',
143+
],
144+
markdownType: 'url',
145+
type: 'markdown',
146+
value: '[**this one**](https://example.com)',
147+
},
148+
{
149+
type: 'string',
150+
value: ' and ',
151+
},
152+
{
153+
groups: [
154+
'**[this one](https://example.com)**',
155+
'[this one](https://example.com)',
156+
],
157+
markdownType: 'bold',
158+
type: 'markdown',
159+
value: '**[this one](https://example.com)**',
160+
},
161+
]);
162+
});
58163
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function asSafeURL(url: string) {
2+
let safeURL = decodeURIComponent(url);
3+
4+
try {
5+
const { protocol } = new URL(safeURL);
6+
if (['https:', 'http:'].some((it) => it === protocol.toLowerCase())) {
7+
return safeURL;
8+
} else {
9+
return '#';
10+
}
11+
} catch (error) {
12+
if (!safeURL.startsWith('http://') && !safeURL.startsWith('https://')) {
13+
safeURL = 'https://' + safeURL;
14+
}
15+
}
16+
17+
return safeURL;
18+
}

src/modules/Message/utils/tokens/tokenize.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
import { User } from '@sendbird/chat';
22
import { USER_MENTION_PREFIX } from '../../consts';
3-
import { IdentifyMentionsType, MentionToken, Token, TOKEN_TYPES, TokenParams, UndeterminedToken } from './types';
3+
import {
4+
IdentifyMentionsType,
5+
MarkdownToken,
6+
MentionToken,
7+
Token,
8+
TOKEN_TYPES,
9+
TokenParams,
10+
UndeterminedToken,
11+
} from './types';
12+
13+
/**
14+
* /\[(.*?)\]\((.*?)\) is for url.
15+
* /\*\*(.*?)\*\* for bold.
16+
*/
17+
const MarkdownRegex = /\[(.*?)\]\((.*?)\)|\*\*(.*?)\*\*/g;
418

519
export function getUserMentionRegex(mentionedUsers: User[], templatePrefix_: string): RegExp {
620
const templatePrefix = templatePrefix_ || USER_MENTION_PREFIX;
@@ -86,6 +100,64 @@ export function identifyUrlsAndStrings(token: Token[]): Token[] {
86100
return results;
87101
}
88102

103+
/**
104+
* For every string token in the given list of tokens, if the token contains markdowns, the token is split into
105+
* string tokens and markdown tokens in the original order.
106+
* Returns a new array tokens.
107+
* @param tokens
108+
*/
109+
export function splitTokensWithMarkdowns(tokens: Token[]): Token[] {
110+
const prevTokens = tokens;
111+
const newTokens = [];
112+
prevTokens.forEach((token) => {
113+
if (token.type === TOKEN_TYPES.mention || token.type === TOKEN_TYPES.markdown) {
114+
newTokens.push(token);
115+
return;
116+
}
117+
const rawStr = token.value;
118+
// @ts-ignore
119+
const matches = [...rawStr.matchAll(MarkdownRegex)];
120+
const allMatches = matches.map((value) => {
121+
const text = value[0];
122+
const start = value.index ?? 0;
123+
const end = start + text.length;
124+
return { text, start, end, groups: value.filter((val: any) => typeof val === 'string') };
125+
});
126+
let restText = rawStr;
127+
let cursor = 0;
128+
allMatches.forEach(({ text, start, end, groups }) => {
129+
const left: Token = {
130+
type: TOKEN_TYPES.undetermined,
131+
value: restText.slice(0, start - cursor),
132+
};
133+
newTokens.push(left);
134+
let markdownType;
135+
if (text.startsWith('[')) {
136+
markdownType = 'url';
137+
} else if (text.startsWith('**')) {
138+
markdownType = 'bold';
139+
}
140+
const mid: MarkdownToken = {
141+
type: TOKEN_TYPES.markdown,
142+
markdownType,
143+
value: text,
144+
groups,
145+
};
146+
newTokens.push(mid);
147+
restText = rawStr.slice(end);
148+
cursor = end;
149+
});
150+
if (restText) {
151+
const right: Token = {
152+
type: TOKEN_TYPES.undetermined,
153+
value: restText,
154+
};
155+
newTokens.push(right);
156+
}
157+
});
158+
return newTokens;
159+
}
160+
89161
export function combineNearbyStrings(tokens: Token[]): Token[] {
90162
const results: Token[] = tokens.reduce((acc, token) => {
91163
const lastToken = acc[acc.length - 1];
@@ -105,6 +177,7 @@ export function tokenizeMessage({
105177
messageText,
106178
mentionedUsers = [],
107179
templatePrefix = USER_MENTION_PREFIX,
180+
includeMarkdown = false,
108181
}: TokenParams): Token[] {
109182
// mention can be squeezed-in(no-space-between) with other mentions and urls
110183
// if no users are mentioned, return the messageText as a single token
@@ -120,9 +193,23 @@ export function tokenizeMessage({
120193
mentionedUsers,
121194
templatePrefix,
122195
});
123-
const partialsWithUrlsAndMentions = identifyUrlsAndStrings(partialWithMentions);
196+
const partialsWithUrlsAndMentions = identifyUrlsAndStrings(
197+
includeMarkdown
198+
? splitTokensWithMarkdowns(partialWithMentions)
199+
: partialWithMentions,
200+
);
124201
const result = combineNearbyStrings(partialsWithUrlsAndMentions);
202+
return result;
203+
}
125204

205+
export function tokenizeMarkdown({
206+
messageText,
207+
}): Token[] {
208+
const partialResult = [{
209+
type: TOKEN_TYPES.undetermined,
210+
value: messageText,
211+
}];
212+
const result = combineNearbyStrings(splitTokensWithMarkdowns(partialResult));
126213
return result;
127214
}
128215

0 commit comments

Comments
 (0)