Add input mentions (#126)
This commit is contained in:
parent
3be3a7726f
commit
fe609f0444
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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;
|
||||||
`;
|
`;
|
||||||
|
|
Loading…
Reference in New Issue