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 { bufToHex } from "status-communities/dist/cjs/utils";
import styled from "styled-components"; import styled from "styled-components";
@ -28,15 +28,18 @@ export function ChatCreation({
const { contacts, setActiveChannel } = useMessengerContext(); const { contacts, setActiveChannel } = useMessengerContext();
const addMember = (member: string) => { const addMember = useCallback(
setStyledGroup((prevMembers: string[]) => { (member: string) => {
if (prevMembers.find((mem) => mem === member)) { setStyledGroup((prevMembers: string[]) => {
return prevMembers; if (prevMembers.find((mem) => mem === member)) {
} else { return prevMembers;
return [...prevMembers, member]; } else {
} return [...prevMembers, member];
}); }
}; });
},
[setStyledGroup, styledGroup]
);
const removeMember = (member: string) => { const removeMember = (member: string) => {
setStyledGroup((prev) => prev.filter((e) => e != member)); setStyledGroup((prev) => prev.filter((e) => e != member));
@ -96,8 +99,8 @@ export function ChatCreation({
{query && ( {query && (
<SearchBlock <SearchBlock
query={query} query={query}
styledGroup={styledGroup} dsicludeList={styledGroup}
setStyledGroup={setStyledGroup} onClick={addMember}
/> />
)} )}
</> </>

View File

@ -1,5 +1,11 @@
import { Picker } from "emoji-mart"; 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 styled, { useTheme } from "styled-components";
import { useMessengerContext } from "../../contexts/messengerProvider"; import { useMessengerContext } from "../../contexts/messengerProvider";
@ -13,15 +19,25 @@ import { PictureIcon } from "../Icons/PictureIcon";
import { StickerIcon } from "../Icons/StickerIcon"; import { StickerIcon } from "../Icons/StickerIcon";
import "emoji-mart/css/emoji-mart.css"; import "emoji-mart/css/emoji-mart.css";
import { SizeLimitModal, SizeLimitModalName } from "../Modals/SizeLimitModal"; import { SizeLimitModal, SizeLimitModalName } from "../Modals/SizeLimitModal";
import { SearchBlock } from "../SearchBlock";
export function ChatInput() { export function ChatInput() {
const { sendMessage } = useMessengerContext(); const { sendMessage } = useMessengerContext();
const theme = useTheme() as Theme; const theme = useTheme() as Theme;
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [clearComponent, setClearComponent] = useState("");
const [showEmoji, setShowEmoji] = useState(false); const [showEmoji, setShowEmoji] = useState(false);
const [inputHeight, setInputHeight] = useState(40); const [inputHeight, setInputHeight] = useState(40);
const [imageUint, setImageUint] = useState<undefined | Uint8Array>(undefined); const [imageUint, setImageUint] = useState<undefined | Uint8Array>(undefined);
const low = useLow();
const { setModal } = useModal(SizeLimitModalName);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
window.addEventListener("click", () => setShowEmoji(false)); window.addEventListener("click", () => setShowEmoji(false));
return () => { return () => {
@ -39,37 +55,139 @@ export function ChatInput() {
const codesArray: any[] = []; const codesArray: any[] = [];
sym.forEach((el: string) => codesArray.push("0x" + el)); sym.forEach((el: string) => codesArray.push("0x" + el));
const emoji = String.fromCodePoint(...codesArray); const emoji = String.fromCodePoint(...codesArray);
if (inputRef.current) {
inputRef.current.appendChild(document.createTextNode(emoji));
}
setContent((p) => p + emoji); setContent((p) => p + emoji);
}, []); }, []);
const onInputChange = useCallback( const resizeTextArea = useCallback((target: HTMLDivElement) => {
(e: React.ChangeEvent<HTMLTextAreaElement>) => { target.style.height = "40px";
const target = e.target; target.style.height = `${Math.min(target.scrollHeight, 438)}px`;
target.style.height = "40px"; setInputHeight(target.scrollHeight);
target.style.height = `${Math.min(target.scrollHeight, 438)}px`; }, []);
setInputHeight(target.scrollHeight);
setContent(target.value); 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( const onInputKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key == "Enter" && !e.getModifierState("Shift")) { if (e.key == "Enter" && !e.getModifierState("Shift")) {
e.preventDefault(); e.preventDefault();
(e.target as HTMLTextAreaElement).style.height = "40px"; (e.target as HTMLDivElement).style.height = "40px";
setInputHeight(40); setInputHeight(40);
sendMessage(content, imageUint); sendMessage(content, imageUint);
setImageUint(undefined); setImageUint(undefined);
setClearComponent("");
if (inputRef.current) {
inputRef.current.innerHTML = "";
}
setContent(""); setContent("");
} }
}, },
[content, imageUint] [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 ( return (
<View> <View>
@ -126,11 +244,22 @@ export function ChatInput() {
<ImagePreview src={image} onClick={() => setImageUint(undefined)} /> <ImagePreview src={image} onClick={() => setImageUint(undefined)} />
)} )}
<Input <Input
placeholder="Message" contentEditable
value={content} onInput={onInputChange}
onChange={onInputChange} onKeyDown={onInputKeyPress}
onKeyPress={onInputKeyPress} onKeyUp={handleCursorChange}
ref={inputRef}
onClick={handleCursorChange}
dangerouslySetInnerHTML={{ __html: clearComponent }}
/> />
{query && (
<SearchBlock
query={query}
dsicludeList={[]}
onClick={addMention}
onBotttom
/>
)}
</InputWrapper> </InputWrapper>
<InputButtons> <InputButtons>
<ChatButton <ChatButton
@ -157,6 +286,7 @@ const InputWrapper = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
position: relative;
`; `;
const View = styled.div` const View = styled.div`
@ -194,17 +324,20 @@ const ImagePreview = styled.img`
margin-top: 9px; margin-top: 9px;
`; `;
const Input = styled.textarea` const Input = styled.div`
display: block;
width: 100%; width: 100%;
height: 40px; height: 40px;
max-height: 438px; max-height: 438px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 8px 0 8px 12px; padding: 8px 0 8px 12px;
background: ${({ theme }) => theme.inputColor}; background: ${({ theme }) => theme.inputColor};
border: 1px solid ${({ theme }) => theme.inputColor}; border: 1px solid ${({ theme }) => theme.inputColor};
color: ${({ theme }) => theme.primary}; color: ${({ theme }) => theme.primary};
border-radius: 16px 16px 4px 16px; border-radius: 16px 16px 4px 16px;
outline: none; outline: none;
resize: none;
font-family: Inter; font-family: Inter;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;

View File

@ -86,12 +86,20 @@ export function ChatMessages() {
const [image, setImage] = useState(""); const [image, setImage] = useState("");
const [link, setLink] = useState(""); const [link, setLink] = useState("");
const { setModal: setPictureModal } = useModal(PictureModalName); const { setModal: setPictureModal, isVisible: showPictureModal } =
const { setModal: setLinkModal } = useModal(LinkModalName); useModal(PictureModalName);
const { setModal: setLinkModal, isVisible: showLinkModal } =
useModal(LinkModalName);
useEffect(() => (!image ? undefined : setPictureModal(true)), [image]); useEffect(() => (!image ? undefined : setPictureModal(true)), [image]);
useEffect(() => (!link ? undefined : setLinkModal(true)), [link]); useEffect(() => (!link ? undefined : setLinkModal(true)), [link]);
useEffect(
() => (!showPictureModal ? setImage("") : undefined),
[showPictureModal]
);
useEffect(() => (!showLinkModal ? setLink("") : undefined), [showLinkModal]);
return ( return (
<MessagesWrapper ref={ref}> <MessagesWrapper ref={ref}>
<PictureModal image={image} /> <PictureModal image={image} />

View File

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