Raw html character entities exposed when copy&paste messages

When I copy and paste the message in chat, html character entities should be shown as the expected form(‘<’) but displayed like ‘<’. Some customers using my company’s product reported this problem so this must be fixed but I can’t find any reason why this happen or guide to look at. One interesting thing is when I paste the message on the other text inputs, it displayed well as I expected.

1 Like

Hi @kwak,
Could you please let me know the SDK version you are using in your application and the detailed steps to reproduce this issue?

1 Like

Hi @Suhirtha_Ayyappan ,

// package.json
"@sendbird/uikit-react": "^3.10.0",
// pnpm-lock.yaml
importers:
  .:
    dependencies:
      '@sendbird/uikit-react':
        specifier: ^3.10.0
        version: 3.15.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)

packages:

  '@sendbird/chat@4.14.5':
    resolution: **
    peerDependencies:
      '@react-native-async-storage/async-storage': ^1.17.6
      react-native-mmkv: ^2.0.0
    peerDependenciesMeta:
      '@react-native-async-storage/async-storage':
        optional: true
      react-native-mmkv:
        optional: true

  '@sendbird/react-uikit-message-template-view@0.0.2':
    resolution: **
    peerDependencies:
      '@sendbird/chat': '>=4.3.0 <5'
      react: '>=16.8.6'
      react-dom: '>=16.8.6'

  '@sendbird/uikit-message-template@0.0.2':
    resolution: **
    peerDependencies:
      react: '>=16.8.6'

  '@sendbird/uikit-react@3.15.6':
    resolution: **
    peerDependencies:
      react: ^16.8.6 || ^17.0.0 || ^18.0.0
      react-dom: ^16.8.6 || ^17.0.0 || ^18.0.0

  '@sendbird/uikit-tools@0.0.2':
    resolution: **
    peerDependencies:
      '@sendbird/chat': '>=4.10.5 <5'
      react: '>=16.8.6'

This is the package info I can get, and here is the detailed steps down below.

"use client";

import { useDebounce } from "@repo/portlogics/react-util-hooks";
import { SendBirdProvider, MessageSearch } from "@sendbird/uikit-react";
import { GroupChannel } from "@sendbird/uikit-react/GroupChannel";
import { notFound } from "next/navigation";
import { useState } from "react";

import { GetChatResourceResponse } from "@/api/util/type";
import { useQueryParams } from "@/hooks/useQueryParams";

import "@sendbird/uikit-react/dist/index.css";
import * as styles from "./style";

const colorSet = {
  "--sendbird-light-primary-500": "#125fcc",
  "--sendbird-light-primary-400": "#146be6",
  "--sendbird-light-primary-300": "#1677ff",
  "--sendbird-light-primary-200": "#5ca0ff",
  "--sendbird-light-primary-100": "#b9d6ff",
};

type SendbirdProps = GetChatResourceResponse["data"];

export default function Sendbird({
  channelUrl,
  userId,
  accessToken,
}: SendbirdProps) {
  const [openSearch, setOpenSearch] = useState(false);
  const [searchString] = useState("");
  const [selectedMessageId, setSelectedMessageId] = useState<number>();
  const debouncedSearchString = useDebounce(searchString, 500);

  const { searchParams } = useQueryParams();
  const token = searchParams.get("token") || undefined;

  if (token === undefined) {
    notFound();
  }

  return (
    <SendBirdProvider
      appId={process.env.NEXT_PUBLIC_SENDBIRD_APP_ID as string}
      userId={userId}
      accessToken={accessToken}
      colorSet={colorSet}
      uikitOptions={{ groupChannel: { enableMention: true } }}
    >
      <div style={styles.Container}>
        <GroupChannel
          channelUrl={channelUrl}
          showSearchIcon={true}
          animatedMessageId={selectedMessageId}
          onSearchClick={() => setOpenSearch((value) => !value)}
        />
        {openSearch && (
          <MessageSearch
            channelUrl={channelUrl}
            searchString={debouncedSearchString}
            onResultClick={(message) => setSelectedMessageId(message.messageId)}
            onCloseClick={() => setOpenSearch(false)}
          />
        )}
      </div>
    </SendBirdProvider>
  );
}

So this is the Chat module code which my team imports to somewhere chat feature needed. There isn’t special process we added, and we just followed what official docs said to do.

Hi @kwak,
Could you please clarify what you mean by " When I copy and paste the message in chat, HTML character entities should be shown as the expected form(‘<’) but displayed like ‘<’. "

A screenshot of the issue would be helpful for troubleshooting.

1 Like

Hi @Suhirtha_Ayyappan ,

Sorry for late reply. Here’s a screenshot shows what happened.

When copy the existing message and paste it on the input box, I still can represent this issue that some of characters are displayed somewhat naked.

Ok I solved this issue on the other way.
Just implementing own customed MessageInput with native HTML elements(i.e. textarea) and direct message sending APIs from useGroupChannelContext() instead of using basic APIs like MesageInputWrapper only solved the issue.

Here’s the example how I did. Hope this helps those who are tired of the poor docs.

const PastedImage = ({
  image,
  index,
  handleRemoveImage,
}: {
  image: File;
  index: number;
  handleRemoveImage: (index: number) => void;
}) => {
  const [isPastedImageHovered, setIsPastedImageHovered] = useState(false);
  const [isCloseBtnHovered, setIsCloseBtnHovered] = useState(false);

  return (
    <div
      style={{ position: "relative", width: "min-content" }}
      onMouseEnter={() => setIsPastedImageHovered(true)}
      onMouseLeave={() => setIsPastedImageHovered(false)}
    >
      <Image
        src={URL.createObjectURL(image)}
        alt={`Pasted Image ${index}`}
        width={60}
        height={60}
        style={{ borderRadius: 8, objectFit: "cover" }}
      />

      {isPastedImageHovered && (
        <CloseCircleFilled
          onClick={() => handleRemoveImage(index)}
          onMouseEnter={() => setIsCloseBtnHovered(true)}
          onMouseLeave={() => setIsCloseBtnHovered(false)}
          style={{
            display: isPastedImageHovered ? "block" : "none",
            position: "absolute",
            top: -8,
            right: -8,
            color: isCloseBtnHovered ? Color["gray-12"] : Color["gray-7"],
            background: Color["gray-1"],
            borderRadius: "50%",
            cursor: "pointer",
            fontSize: 20,
            border: `3px solid ${Color["gray-1"]}`,
          }}
        />
      )}
    </div>
  );
};

const CustomInput = () => {
  const { t, K } = useI18nText();
  const { sendUserMessage, sendFileMessage, sendMultipleFilesMessage } =
    useGroupChannelContext();
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const [images, setImages] = useState<File[]>([]);
  const [textareaValue, setTextareaValue] = useState("");
  const [inputHeight, setInputHeight] = useState(DEFAULT_TEXTAREA_HEIGHT);
  const [isMessageSending, setIsMessageSending] = useState(false);
  const sendBtnActive = textareaValue.trim() || images.length > 0;

  const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
    const items = event.clipboardData?.items;
    if (!items) return;

    const newImages: File[] = [];

    for (let i = 0; i < items.length; i++) {
      if (items[i]?.type.indexOf("image") !== -1) {
        const file = items[i]?.getAsFile();
        if (file) {
          newImages.push(file);
        }
      }
    }

    if (images.length + newImages.length > 3) {
      alert("You can only paste up to 3 images at once");
      return;
    } else {
      setImages((prev) => [...prev, ...newImages]);
    }
  };

  const handleRemoveImage = (index: number) => {
    setImages(images.filter((_, i) => i !== index));
  };

  const handleSendMessage = useCallback(async () => {
    if (textareaValue.trim() || images.length) {
      try {
        setIsMessageSending(true);

        const sanitizedImages = images.map((image) => ({
          file: image,
          fileUrl: URL.createObjectURL(image),
          fileName: image.name,
          fileSize: image.size,
          mimeType: image.type,
        }));

        /** 이미지가 1개면 Invalid Parameters 에러가 발생해 부득이하게 메소드를 나눠 send 로직 수행 */
        if (sanitizedImages.length > 1) {
          const params = {
            fileInfoList: sanitizedImages,
          };
          await sendMultipleFilesMessage(params);
        } else if (sanitizedImages.length === 1) {
          await sendFileMessage(
            sanitizedImages[0] as (typeof sanitizedImages)[0],
          );
        }

        if (textareaValue.trim()) {
          await sendUserMessage({ message: textareaValue.trim() });
        }

        setImages([]);
        setTextareaValue("");
        setInputHeight(DEFAULT_TEXTAREA_HEIGHT);
      } catch (error) {
        message.error(t(K["메시지 전송에 실패했습니다."]));
        console.error(error);
      } finally {
        setIsMessageSending(false);
        setTimeout(() => {
          textareaRef.current?.focus();
        }, 0);
      }
    }
  }, [
    textareaValue,
    images,
    sendMultipleFilesMessage,
    sendFileMessage,
    sendUserMessage,
    t,
    K,
  ]);

  const handleResize: FormEventHandler<HTMLTextAreaElement> = (event) => {
    const textarea = event.target as HTMLTextAreaElement;

    if (!textarea) return;

    setInputHeight(() => {
      if (!textarea.value) {
        return DEFAULT_TEXTAREA_HEIGHT;
      }

      if (textarea.scrollHeight <= DEFAULT_TEXTAREA_HEIGHT) {
        return DEFAULT_TEXTAREA_HEIGHT;
      } else if (textarea.scrollHeight > TEXTAREA_MAX_HEIGHT) {
        return TEXTAREA_MAX_HEIGHT;
      } else {
        return textarea.scrollHeight;
      }
    });
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
    if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
      handleSendMessage(); // 메시지 전송 함수 호출
      event.preventDefault(); // 기본 Enter 동작 방지 (줄바꿈 방지)
    }
  };

  return (
    <>
      <Flex
        align="center"
        gap={4}
        style={{ paddingInline: 8, position: "relative" }}
      >
        <Flex
          justify="center"
          gap={4}
          style={{
            background: Color["gray-4"],
            padding: 12,
            borderRadius: 16,
            width: "100%",
          }}
          vertical
        >
          <Flex gap={8}>
            {images.map((image, index) => (
              <PastedImage
                key={index}
                image={image}
                index={index}
                handleRemoveImage={handleRemoveImage}
              />
            ))}
          </Flex>
          <textarea
            ref={textareaRef}
            style={{
              height: inputHeight,
              resize: "none",
              border: "none",
              outline: "none",
              background: "transparent",
              width: "100%",
              lineHeight: 1.5,
            }}
            value={textareaValue}
            placeholder="Type a message"
            onPaste={handlePaste}
            onInput={handleResize}
            onKeyDown={handleKeyDown}
            onChange={(e) => setTextareaValue(e.target.value)}
            disabled={isMessageSending}
          />
        </Flex>
        <button
          style={{
            color: sendBtnActive
              ? `var(--sendbird-light-primary-300)`
              : Color["gray-5"],
            width: 32,
            height: 32,
            background: "transparent",
            border: "none",
          }}
          onClick={handleSendMessage}
          disabled={!sendBtnActive || isMessageSending}
        >
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
            <path
              fill="currentColor"
              fillRule="evenodd"
              d="M59.795 29.43 7.329 2.979C4.691 1.802 1.76 4.153 2.932 6.798l6.925 18.609a2 2 0 0 0 1.544 1.275l32.273 5.394L11.4 37.47a1.998 1.998 0 0 0-1.544 1.275L2.932 57.353c-.879 2.645 1.76 4.997 4.397 3.527l52.466-26.453c2.051-.882 2.051-3.82 0-4.996z"
              className="fill"
            ></path>
          </svg>
        </button>

        {isMessageSending && (
          <div
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              right: 0,
              bottom: 0,
              background: "rgba(255, 255, 255, 0.7)",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              borderRadius: 16,
            }}
          >
            <div
              style={{
                width: 24,
                height: 24,
                border: "3px solid #f3f3f3",
                borderTop: `3px solid var(--sendbird-light-primary-300)`,
                borderRadius: "50%",
                animation: "spin 1s linear infinite",
              }}
            />
          </div>
        )}
      </Flex>
    </>
  );
};

P.S. I hope your docs to handle more detailed code examples and implementation instead of listing useless methods. So confused when I encounter the issues because there isn’t any solutions or a guidance at least to figure out what is wrong.