Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@sendbird/chat": "^4.2.3",
"css-vars-ponyfill": "^2.3.2",
"date-fns": "^2.16.1",
"dompurify": "^3.0.1",
"lamejs": "^1.2.1",
"prop-types": "^15.7.2"
},
Expand Down
1 change: 1 addition & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ module.exports = ({
'react-dom',
'css-vars-ponyfill',
'date-fns',
'dompurify',
],
plugins: [
postcss({
Expand Down
3 changes: 2 additions & 1 deletion scripts/package.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"@sendbird/chat": "^4.1.1",
"css-vars-ponyfill": "^2.3.2",
"date-fns": "^2.16.1",
"prop-types": "^15.7.2"
"prop-types": "^15.7.2",
"dompurify": "^3.0.1"
},
"bugs": {
"url": "https://community.sendbird.com"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const MessageInputWrapper = (

const { stringSet } = useContext(LocalizationContext);
const [mentionNickname, setMentionNickname] = useState('');
// todo: set type
const [mentionedUsers, setMentionedUsers] = useState([]);
const [mentionedUserIds, setMentionedUserIds] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
Expand Down Expand Up @@ -173,6 +174,8 @@ const MessageInputWrapper = (
onVoiceMessageIconClick={() => {
setShowVoiceMessageInput(true);
}}
setMentionedUsers={setMentionedUsers}
channel={channel}
placeholder={
(quoteMessage && stringSet.MESSAGE_INPUT__QUOTE_REPLY__PLACE_HOLDER)
|| (utils.isDisabledBecauseFrozen(channel) && stringSet.MESSAGE_INPUT__PLACE_HOLDER__DISABLED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ const ThreadMessageInput = (
className="sendbird-thread-message-input__message-input"
messageFieldId="sendbird-message-input-text-field--thread"
disabled={disabled}
channel={currentChannel}
setMentionedUsers={setMentionedUsers}
channelUrl={currentChannel?.url}
mentionSelectedUser={selectedUser}
isMentionEnabled={isMentionEnabled}
Expand Down
2 changes: 2 additions & 0 deletions src/ui/MentionLabel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export default function MentionLabel(props: MentionLabelProps): JSX.Element {
`}
onClick={() => fetchUser(toggleDropdown)}
ref={mentionRef}
data-userid={mentionedUserId}
data-nickname={mentionedUserNickname}
>
<Label
type={LabelTypography.CAPTION_1}
Expand Down
5 changes: 5 additions & 0 deletions src/ui/MessageInput/hooks/usePaste/consts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const PASTE_NODE = 'sendbird-uikit__paste-node';
export const TEXT_MESSAGE_CLASS = 'sendbird-word';
export const MENTION_CLASS = 'sendbird-word__mention';
export const MENTION_CLASS_IN_INPUT = 'sendbird-mention-user-label';
export const MENTION_CLASS_COMBINED_QUERY = `.${MENTION_CLASS}, .${MENTION_CLASS_IN_INPUT}`;
80 changes: 80 additions & 0 deletions src/ui/MessageInput/hooks/usePaste/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useCallback } from 'react';
import DOMPurify from 'dompurify';

import { inserTemplateToDOM } from './insertTemplate';
import { sanitizeString } from '../../utils';
import { DynamicProps } from './types';
import { MENTION_CLASS_COMBINED_QUERY, MENTION_CLASS_IN_INPUT, TEXT_MESSAGE_CLASS } from './consts';
import {
createPasteNode,
hasMention,
domToMessageTemplate,
getUsersFromWords,
extractTextFromNodes,
} from './utils';

// conditions to test:
// 1. paste simple text
// 2. paste text with mention
// 3. paste text with mention and text
// 4. paste text with mention and text and paste again before and after
// 5. copy message with mention(only one mention, no other text) and paste
// 6. copy message with mention from input and paste(before and after)
export default function usePaste({
ref,
setIsInput,
setHeight,
channel,
setMentionedUsers,
}: DynamicProps): (e: React.ClipboardEvent<HTMLDivElement>) => void {
return useCallback((e) => {
e.preventDefault();
const html = e?.clipboardData.getData('text/html');
// simple text, continue as normal
if (!html) {
const text = e?.clipboardData.getData('text');
document.execCommand('insertHTML', false, sanitizeString(text));
setIsInput(true);
setHeight();
return;
}

// has html, check if there are mentions, sanitize and insert
const purifier = DOMPurify(window);
const clean = purifier.sanitize(html);
const pasteNode = createPasteNode();
pasteNode.innerHTML = clean;
// does not have mention, continue as normal
if (!hasMention(pasteNode)) {
// to preserve space between words
const text = extractTextFromNodes(Array.from(pasteNode.children) as HTMLSpanElement[]);
document.execCommand('insertHTML', false, sanitizeString(text));
pasteNode.remove();
setIsInput(true);
setHeight();
return;
}

// has mention, sanitize and insert
let childNodes = pasteNode.querySelectorAll(`.${TEXT_MESSAGE_CLASS}`) as NodeListOf<HTMLSpanElement>;
if (pasteNode.querySelectorAll(`.${MENTION_CLASS_IN_INPUT}`).length > 0) {
// @ts-ignore
childNodes = pasteNode.children;
}
let nodeArray = Array.from(childNodes);
// handle paste when there is only one child
if (pasteNode.children.length === 1 && pasteNode.querySelectorAll(MENTION_CLASS_COMBINED_QUERY).length === 1) {
nodeArray = Array.from(pasteNode.children) as HTMLSpanElement[];
}
const words = domToMessageTemplate(nodeArray);

const mentionedUsers = getUsersFromWords(words, channel);
setMentionedUsers(mentionedUsers);
inserTemplateToDOM(words);
pasteNode.remove();
setIsInput(true);
setHeight();
return;

}, [ref, setIsInput, setHeight, channel, setMentionedUsers]);
}
28 changes: 28 additions & 0 deletions src/ui/MessageInput/hooks/usePaste/insertTemplate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import { renderToString } from 'react-dom/server';

import { Word } from './types';
import { sanitizeString } from '../../utils';
import MentionUserLabel from '../../../MentionUserLabel';

export function inserTemplateToDOM(templateList: Word[]): void {
const nodes = templateList.map((template) => {
const { text, userId } = template;
if (userId) {
return (
renderToString(
<>
<MentionUserLabel userId={userId}>
{text}
</MentionUserLabel>
</>
)
);
}
return sanitizeString(text);
})
.join(' ')
// add a space at the end of the mention, else cursor/caret wont work
.concat(' ');
document.execCommand('insertHTML', false, nodes);
}
16 changes: 16 additions & 0 deletions src/ui/MessageInput/hooks/usePaste/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { User } from "@sendbird/chat";
import type { GroupChannel } from "@sendbird/chat/groupChannel";

export type Word = {
text: string;
userId?: string;
};

export type DynamicProps = {
ref: React.RefObject<HTMLDivElement>;
channel: GroupChannel;
setUniqueUserIds: React.Dispatch<React.SetStateAction<string[]>>;
setMentionedUsers: React.Dispatch<React.SetStateAction<User[]>>;
setIsInput: React.Dispatch<React.SetStateAction<boolean>>;
setHeight: () => void;
};
86 changes: 86 additions & 0 deletions src/ui/MessageInput/hooks/usePaste/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { GroupChannel } from '@sendbird/chat/groupChannel';
import { User } from '@sendbird/chat';

import {
PASTE_NODE,
MENTION_CLASS,
TEXT_MESSAGE_CLASS,
MENTION_CLASS_COMBINED_QUERY,
MENTION_CLASS_IN_INPUT,
} from './consts';
import { Word } from './types';

export function createPasteNode(): HTMLDivElement | null {
const pasteNode = document.body.querySelector(`#${PASTE_NODE}`);
// remove existing paste node
if (pasteNode) {
pasteNode?.remove();
}

// create new paste node and return
const node = document.createElement('div');
node.id = PASTE_NODE;
node.style.display = 'none';
return node;
}

export function hasMention(parent: HTMLDivElement): boolean {
return parent?.querySelector(MENTION_CLASS_COMBINED_QUERY) ? true : false;
}

export const extractTextFromNodes = (nodes: HTMLSpanElement[]): string => {
let text = '';
nodes.forEach((node) => {
// to preserve space between words
const textNodes = node.querySelectorAll(`.${TEXT_MESSAGE_CLASS}`);
if (textNodes.length > 0) {
text += ((extractTextFromNodes(Array.from(textNodes) as HTMLSpanElement[])) + ' ');
}
text += (node.innerText + ' ');
});
return text;
}

export function domToMessageTemplate(nodeArray: HTMLSpanElement[]): Word[] {
const templates: Word[] = nodeArray?.reduce((accumulator, currentValue) => {
let mentionNode = currentValue.querySelector(MENTION_CLASS_COMBINED_QUERY) as HTMLSpanElement;
// sometimes the mention node is the parent node
// in this case querySelector will return null
if (!mentionNode) {
mentionNode = (
currentValue.classList.contains(MENTION_CLASS) // for nodes copied from message
|| currentValue.classList.contains(MENTION_CLASS_IN_INPUT) // for nodes copied from input
) ? currentValue : null;
}
const text = currentValue.innerText;
if (mentionNode) {
const userId = mentionNode.dataset?.userid;
return [
...accumulator,
{
text,
userId,
},
];
}

return [
...accumulator,
{
text,
},
];
}, [] as Word[]);
return templates;
}

export function getUsersFromWords(templates: Word[], channel: GroupChannel): User[] {
const userMap = {};
const users = channel.members;
templates.forEach((template) => {
if (template.userId) {
userMap[template.userId] = users.find((user) => user.userId === template.userId);
}
});
return Object.values(userMap);
}
17 changes: 13 additions & 4 deletions src/ui/MessageInput/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
StringObjType,
convertWordToStringObj,
} from '../../utils';
import usePaste from './hooks/usePaste';

const TEXT_FIELD_ID = 'sendbird-message-input-text-field';
const LINE_HEIGHT = 76;
Expand Down Expand Up @@ -81,6 +82,7 @@ const MessageInput = React.forwardRef((props, ref) => {
onCancelEdit,
onStartTyping,
channelUrl,
channel,
mentionSelectedUser,
onUserMentioned,
onMentionStringChange,
Expand All @@ -91,6 +93,7 @@ const MessageInput = React.forwardRef((props, ref) => {
renderFileUploadIcon,
renderVoiceMessageIcon,
renderSendMessageIcon,
setMentionedUsers,
} = props;
const textFieldId = messageFieldId || TEXT_FIELD_ID;
const { stringSet } = useContext(LocalizationContext);
Expand Down Expand Up @@ -373,6 +376,14 @@ const MessageInput = React.forwardRef((props, ref) => {
resetInput(ref);
}
};
const onPaste = usePaste({
ref,
setMentionedUserIds,
setMentionedUsers,
channel,
setIsInput,
setHeight,
});

return (
<form
Expand Down Expand Up @@ -432,10 +443,7 @@ const MessageInput = React.forwardRef((props, ref) => {
setIsInput(ref?.current?.innerText?.length > 0);
useMentionedLabelDetection();
}}
onPaste={(e) => {
e.preventDefault();
document.execCommand("insertHTML", false, sanitizeString(e?.clipboardData.getData('text')));
}}
onPaste={onPaste}
/>
{/* placeholder */}
{!isInput && (
Expand Down Expand Up @@ -580,6 +588,7 @@ MessageInput.propTypes = {
userId: PropTypes.string,
nickname: PropTypes.string,
}),
setMentionedUsers: PropTypes.func,
onUserMentioned: PropTypes.func,
onMentionStringChange: PropTypes.func,
onMentionedUserIdsUpdated: PropTypes.func,
Expand Down
2 changes: 2 additions & 0 deletions src/ui/Word/__tests__/__snapshots__/Word.spec.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ exports[`ui/Word should do a snapshot test of the Word DOM 1`] = `
sendbird-word__mention

"
data-nickname="Hoon Baek"
data-userid="hoon"
>
<span
class="sendbird-label sendbird-label--caption-1 sendbird-label--color-onbackground-1"
Expand Down