From fe609f04449e26d4716a9bac184ce9c61f983d6c Mon Sep 17 00:00:00 2001 From: Szymon Szlachtowicz <38212223+Szymx95@users.noreply.github.com> Date: Mon, 15 Nov 2021 10:14:04 +0100 Subject: [PATCH] Add input mentions (#126) --- .../src/components/Chat/ChatCreation.tsx | 27 +-- .../src/components/Chat/ChatInput.tsx | 175 +++++++++++++++--- .../src/components/Chat/ChatMessages.tsx | 12 +- .../react-chat/src/components/SearchBlock.tsx | 34 ++-- 4 files changed, 194 insertions(+), 54 deletions(-) diff --git a/packages/react-chat/src/components/Chat/ChatCreation.tsx b/packages/react-chat/src/components/Chat/ChatCreation.tsx index 306ab21..4ea53ae 100644 --- a/packages/react-chat/src/components/Chat/ChatCreation.tsx +++ b/packages/react-chat/src/components/Chat/ChatCreation.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { bufToHex } from "status-communities/dist/cjs/utils"; import styled from "styled-components"; @@ -28,15 +28,18 @@ export function ChatCreation({ const { contacts, setActiveChannel } = useMessengerContext(); - const addMember = (member: string) => { - setStyledGroup((prevMembers: string[]) => { - if (prevMembers.find((mem) => mem === member)) { - return prevMembers; - } else { - return [...prevMembers, member]; - } - }); - }; + const addMember = useCallback( + (member: string) => { + setStyledGroup((prevMembers: string[]) => { + if (prevMembers.find((mem) => mem === member)) { + return prevMembers; + } else { + return [...prevMembers, member]; + } + }); + }, + [setStyledGroup, styledGroup] + ); const removeMember = (member: string) => { setStyledGroup((prev) => prev.filter((e) => e != member)); @@ -96,8 +99,8 @@ export function ChatCreation({ {query && ( )} diff --git a/packages/react-chat/src/components/Chat/ChatInput.tsx b/packages/react-chat/src/components/Chat/ChatInput.tsx index d2fb81b..23959c0 100644 --- a/packages/react-chat/src/components/Chat/ChatInput.tsx +++ b/packages/react-chat/src/components/Chat/ChatInput.tsx @@ -1,5 +1,11 @@ import { Picker } from "emoji-mart"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import styled, { useTheme } from "styled-components"; import { useMessengerContext } from "../../contexts/messengerProvider"; @@ -13,15 +19,25 @@ import { PictureIcon } from "../Icons/PictureIcon"; import { StickerIcon } from "../Icons/StickerIcon"; import "emoji-mart/css/emoji-mart.css"; import { SizeLimitModal, SizeLimitModalName } from "../Modals/SizeLimitModal"; +import { SearchBlock } from "../SearchBlock"; export function ChatInput() { const { sendMessage } = useMessengerContext(); const theme = useTheme() as Theme; const [content, setContent] = useState(""); + const [clearComponent, setClearComponent] = useState(""); const [showEmoji, setShowEmoji] = useState(false); const [inputHeight, setInputHeight] = useState(40); const [imageUint, setImageUint] = useState(undefined); + const low = useLow(); + + const { setModal } = useModal(SizeLimitModalName); + + const [query, setQuery] = useState(""); + + const inputRef = useRef(null); + useEffect(() => { window.addEventListener("click", () => setShowEmoji(false)); return () => { @@ -39,37 +55,139 @@ export function ChatInput() { const codesArray: any[] = []; sym.forEach((el: string) => codesArray.push("0x" + el)); const emoji = String.fromCodePoint(...codesArray); + if (inputRef.current) { + inputRef.current.appendChild(document.createTextNode(emoji)); + } setContent((p) => p + emoji); }, []); - const onInputChange = useCallback( - (e: React.ChangeEvent) => { - const target = e.target; - target.style.height = "40px"; - target.style.height = `${Math.min(target.scrollHeight, 438)}px`; - setInputHeight(target.scrollHeight); - setContent(target.value); - }, - [] - ); + const resizeTextArea = useCallback((target: HTMLDivElement) => { + target.style.height = "40px"; + target.style.height = `${Math.min(target.scrollHeight, 438)}px`; + setInputHeight(target.scrollHeight); + }, []); + + const onInputChange = useCallback((e: React.ChangeEvent) => { + const element = document.getSelection(); + const inputElement = inputRef.current; + if (inputElement && element && element.rangeCount > 0) { + const selection = element?.getRangeAt(0)?.startOffset; + const parentElement = element.anchorNode?.parentElement; + if (parentElement && parentElement.tagName === "B") { + parentElement.outerHTML = parentElement.innerText; + const range = document.createRange(); + const sel = window.getSelection(); + if (element.anchorNode.firstChild) { + const childNumber = + element.focusOffset === 0 ? 0 : element.focusOffset - 1; + range.setStart(element.anchorNode.childNodes[childNumber], selection); + } + range.collapse(true); + + sel?.removeAllRanges(); + sel?.addRange(range); + } + } + const target = e.target; + resizeTextArea(target); + setContent(target.textContent ?? ""); + }, []); const onInputKeyPress = useCallback( - (e: React.KeyboardEvent) => { + (e: React.KeyboardEvent) => { if (e.key == "Enter" && !e.getModifierState("Shift")) { e.preventDefault(); - (e.target as HTMLTextAreaElement).style.height = "40px"; + (e.target as HTMLDivElement).style.height = "40px"; setInputHeight(40); sendMessage(content, imageUint); setImageUint(undefined); + setClearComponent(""); + if (inputRef.current) { + inputRef.current.innerHTML = ""; + } setContent(""); } }, [content, imageUint] ); - const low = useLow(); + const [selectedElement, setSelectedElement] = useState<{ + element: Selection | null; + start: number; + end: number; + text: string; + node: Node | null; + }>({ element: null, start: 0, end: 0, text: "", node: null }); - const { setModal } = useModal(SizeLimitModalName); + const handleCursorChange = useCallback(() => { + const element = document.getSelection(); + if (element && element.rangeCount > 0) { + const selection = element?.getRangeAt(0)?.startOffset; + const text = element?.anchorNode?.textContent; + if (selection && text) { + const end = text.indexOf(" ", selection); + const start = text.lastIndexOf(" ", selection - 1); + setSelectedElement({ + element, + start, + end, + text, + node: element.anchorNode, + }); + const substring = text.substring( + start > -1 ? start + 1 : 0, + end > -1 ? end : undefined + ); + if (substring.startsWith("@")) { + setQuery(substring.slice(1)); + } else { + setQuery(""); + } + } + } + }, []); + + useEffect(handleCursorChange, [content]); + + const addMention = useCallback( + (contact: string) => { + if (inputRef?.current) { + const { element, start, end, text, node } = selectedElement; + if (element && text && node) { + const firstSlice = text.slice(0, start > -1 ? start : 0); + const secondSlice = text.slice(end > -1 ? end : content.length); + const replaceContent = `${firstSlice} @${contact}${secondSlice}`; + const spaceElement = document.createTextNode(" "); + const contactElement = document.createElement("b"); + contactElement.innerText = `@${contact}`; + + if (contactElement && element.rangeCount > 0) { + const range = element.getRangeAt(0); + range.setStart(node, start > -1 ? start : 0); + if (end === -1 || end > text.length) { + range.setEnd(node, text.length); + } else { + range.setEnd(node, end); + } + range.deleteContents(); + if (end === -1) { + range.insertNode(spaceElement.cloneNode()); + } + range.insertNode(contactElement); + if (start > -1) { + range.insertNode(spaceElement.cloneNode()); + } + range.collapse(); + } + inputRef.current.focus(); + setQuery(""); + setContent(replaceContent); + resizeTextArea(inputRef.current); + } + } + }, + [inputRef, inputRef?.current, content, selectedElement] + ); return ( @@ -126,11 +244,22 @@ export function ChatInput() { setImageUint(undefined)} /> )} + {query && ( + + )} theme.inputColor}; border: 1px solid ${({ theme }) => theme.inputColor}; color: ${({ theme }) => theme.primary}; border-radius: 16px 16px 4px 16px; outline: none; - resize: none; font-family: Inter; font-style: normal; font-weight: normal; diff --git a/packages/react-chat/src/components/Chat/ChatMessages.tsx b/packages/react-chat/src/components/Chat/ChatMessages.tsx index b07905b..5f70e05 100644 --- a/packages/react-chat/src/components/Chat/ChatMessages.tsx +++ b/packages/react-chat/src/components/Chat/ChatMessages.tsx @@ -86,12 +86,20 @@ export function ChatMessages() { const [image, setImage] = useState(""); const [link, setLink] = useState(""); - const { setModal: setPictureModal } = useModal(PictureModalName); - const { setModal: setLinkModal } = useModal(LinkModalName); + const { setModal: setPictureModal, isVisible: showPictureModal } = + useModal(PictureModalName); + const { setModal: setLinkModal, isVisible: showLinkModal } = + useModal(LinkModalName); useEffect(() => (!image ? undefined : setPictureModal(true)), [image]); useEffect(() => (!link ? undefined : setLinkModal(true)), [link]); + useEffect( + () => (!showPictureModal ? setImage("") : undefined), + [showPictureModal] + ); + useEffect(() => (!showLinkModal ? setLink("") : undefined), [showLinkModal]); + return ( diff --git a/packages/react-chat/src/components/SearchBlock.tsx b/packages/react-chat/src/components/SearchBlock.tsx index c5936a2..288810b 100644 --- a/packages/react-chat/src/components/SearchBlock.tsx +++ b/packages/react-chat/src/components/SearchBlock.tsx @@ -8,41 +8,36 @@ import { ContactsList } from "./Chat/ChatCreation"; interface SearchBlockProps { query: string; - styledGroup: string[]; - setStyledGroup: React.Dispatch>; + dsicludeList: string[]; + onClick: (member: string) => void; + onBotttom?: boolean; } export const SearchBlock = ({ query, - styledGroup, - setStyledGroup, + dsicludeList, + onClick, + onBotttom, }: SearchBlockProps) => { const { contacts } = useMessengerContext(); const searchList = useMemo(() => { return contacts .filter((member) => member.id.includes(query)) - .filter((member) => !styledGroup.includes(member.id)); - }, [query, styledGroup, contacts]); + .filter((member) => !dsicludeList.includes(member.id)); + }, [query, dsicludeList, contacts]); - const addMember = (member: string) => { - setStyledGroup((prevMembers: string[]) => { - if (prevMembers.find((mem) => mem === member)) { - return prevMembers; - } else { - return [...prevMembers, member]; - } - }); - }; if (searchList.length === 0) { return null; } return ( - + {contacts .filter((member) => member.id.includes(query)) - .filter((member) => !styledGroup.includes(member.id)) + .filter((member) => !dsicludeList.includes(member.id)) .map((member) => ( addMember(member.id)} + onClick={() => onClick(member.id)} /> ))} @@ -72,5 +67,6 @@ const SearchContacts = styled.div` border-radius: 8px; position: absolute; left: 0; - top: calc(100% + 24px); + max-height: 200px; + overflow: auto; `;