Add input mentions (#126)

This commit is contained in:
Szymon Szlachtowicz 2021-11-15 10:14:04 +01:00 committed by GitHub
parent 3be3a7726f
commit fe609f0444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 194 additions and 54 deletions

View File

@ -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 && (
<SearchBlock
query={query}
styledGroup={styledGroup}
setStyledGroup={setStyledGroup}
dsicludeList={styledGroup}
onClick={addMember}
/>
)}
</>

View File

@ -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 | Uint8Array>(undefined);
const low = useLow();
const { setModal } = useModal(SizeLimitModalName);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
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<HTMLTextAreaElement>) => {
(e: React.KeyboardEvent<HTMLDivElement>) => {
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 (
<View>
@ -126,11 +244,22 @@ export function ChatInput() {
<ImagePreview src={image} onClick={() => setImageUint(undefined)} />
)}
<Input
placeholder="Message"
value={content}
onChange={onInputChange}
onKeyPress={onInputKeyPress}
contentEditable
onInput={onInputChange}
onKeyDown={onInputKeyPress}
onKeyUp={handleCursorChange}
ref={inputRef}
onClick={handleCursorChange}
dangerouslySetInnerHTML={{ __html: clearComponent }}
/>
{query && (
<SearchBlock
query={query}
dsicludeList={[]}
onClick={addMention}
onBotttom
/>
)}
</InputWrapper>
<InputButtons>
<ChatButton
@ -157,6 +286,7 @@ const InputWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
position: relative;
`;
const View = styled.div`
@ -194,17 +324,20 @@ const ImagePreview = styled.img`
margin-top: 9px;
`;
const Input = styled.textarea`
const Input = styled.div`
display: block;
width: 100%;
height: 40px;
max-height: 438px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 8px 0 8px 12px;
background: ${({ theme }) => 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;

View File

@ -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 (
<MessagesWrapper ref={ref}>
<PictureModal image={image} />

View File

@ -8,41 +8,36 @@ import { ContactsList } from "./Chat/ChatCreation";
interface SearchBlockProps {
query: string;
styledGroup: string[];
setStyledGroup: React.Dispatch<React.SetStateAction<string[]>>;
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 (
<SearchContacts>
<SearchContacts
style={{ [onBotttom ? "bottom" : "top"]: "calc(100% + 24px)" }}
>
<ContactsList>
{contacts
.filter((member) => member.id.includes(query))
.filter((member) => !styledGroup.includes(member.id))
.filter((member) => !dsicludeList.includes(member.id))
.map((member) => (
<Channel
key={member.id}
@ -53,7 +48,7 @@ export const SearchBlock = ({
}}
isActive={false}
isMuted={false}
onClick={() => addMember(member.id)}
onClick={() => onClick(member.id)}
/>
))}
</ContactsList>
@ -72,5 +67,6 @@ const SearchContacts = styled.div`
border-radius: 8px;
position: absolute;
left: 0;
top: calc(100% + 24px);
max-height: 200px;
overflow: auto;
`;