Files
desktop/app/components/DialogInput/DialogInput.tsx

444 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useDialog } from "@/app/providers/DialogProvider/useDialog";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core";
import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist";
import { base64ImageToBlurhash, filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils";
import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
import { DialogAttachment } from "../DialogAttachment/DialogAttachment";
import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing";
import { usePublicKey } from "@/app/providers/AccountProvider/usePublicKey";
import { usePrivateKeyHash } from "@/app/providers/AccountProvider/usePrivateKeyHash";
import { useSender } from "@/app/providers/ProtocolProvider/useSender";
import { useReplyMessages } from "@/app/providers/DialogProvider/useReplyMessages";
import { useFileDialog, useHotkeys } from "@mantine/hooks";
import { ATTACHMENTS_NOT_ALLOWED_TO_REPLY, MAX_ATTACHMENTS_IN_MESSAGE, MAX_UPLOAD_FILESIZE_MB } from "@/app/constants";
import { useSystemAccounts } from "@/app/providers/SystemAccountsProvider/useSystemAccounts";
import { Dropzone } from '@mantine/dropzone';
import { RichTextInput } from "../RichTextInput/RichTextInput";
import EmojiPicker, { EmojiClickData, Theme } from 'emoji-picker-react';
import { useAvatars } from "@/app/providers/AvatarProvider/useAvatars";
import { useGroups } from "@/app/providers/DialogProvider/useGroups";
import { useGroupMembers } from "@/app/providers/InformationProvider/useGroupMembers";
import { AnimatedButton } from "../AnimatedButton/AnimatedButton";
import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc";
import { MentionList, Mention } from "../MentionList/MentionList";
import { useDrafts } from "@/app/providers/DialogProvider/useDrafts";
export function DialogInput() {
const colors = useRosettaColors();
const {sendMessage, dialog} = useDialog();
const {members, loading} = useGroupMembers(dialog);
const {hasGroup, leaveGroup} = useGroups();
const [message, setMessage] = useState<string>("");
const [blocked] = useBlacklist(dialog);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const publicKey = usePublicKey();
const isAdmin = hasGroup(dialog) && members[0] == publicKey;
const isBannedGroup = hasGroup(dialog) && members.length == 0 && !loading;
const privateKey = usePrivateKeyHash();
const sendPacket = useSender();
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const {replyMessages, deselectAllMessages} = useReplyMessages();
const computedTheme = useComputedColorScheme();
const systemAccounts = useSystemAccounts();
const [mentionList, setMentionList] = useState<Mention[]>([]);
const mentionHandling = useRef<string>("");
const {getDraft, saveDraft} = useDrafts(dialog);
const avatars = useAvatars(
hasGroup(dialog) ? dialog : publicKey
, false);
const editableDivRef = useRef<any>(null);
const getUserFromCache = useUserCacheFunc();
const regexp = new RegExp(/@([\w\d_]{3,})$/);
useHotkeys([
['Esc', () => {
setAttachments([]);
}]
], [], true);
const fileDialog = useFileDialog({
multiple: false,
//naccept: '*',
onChange: async (files) => {
if(!files){
return;
}
if(files.length == 0){
return;
}
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
return;
}
if(attachments.find(a => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(a.type))){
return;
}
const file = files[0];
if(((file.size / 1024) / 1024) > MAX_UPLOAD_FILESIZE_MB){
return;
}
const fileContent = await filePrapareForNetworkTransfer(file);
setAttachments([...attachments, {
blob: fileContent,
id: generateRandomKey(8),
type: AttachmentType.FILE,
preview: files[0].size + "::" + files[0].name
}]);
}
});
useEffect(() => {
const draftMessage = getDraft();
console.info("GET DRAFT", draftMessage);
if(draftMessage == "" || !editableDivRef){
return;
}
setMessage(draftMessage);
editableDivRef.current.insertHTMLInCurrentCarretPosition(draftMessage);
}, [dialog, editableDivRef]);
useEffect(() => {
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
/**
* У системных аккаунтов нельзя отвечать на сообщения
*/
return;
}
if(replyMessages.inDialogInput && replyMessages.inDialogInput == dialog){
setAttachments([{
type: AttachmentType.MESSAGES,
id: generateRandomKey(8),
blob: JSON.stringify([...replyMessages.messages]),
preview: ""
}]);
if(editableDivRef.current){
editableDivRef.current.focus();
}
}
}, [dialog, replyMessages]);
useEffect(() => {
saveDraft(message);
if(regexp.test(message) && hasGroup(dialog)){
const username = regexp.exec(message);
if(!username){
return;
}
if(username[1].length > 2){
handleMention(username[1]);
}
}else{
setMentionList([]);
}
}, [message]);
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
return <></>;
}
const handleMention = async (username: string) => {
const regexpToFindAllMentionedUsernames = new RegExp(`@([\\w\\d_]{2,})`, 'g');
const mentionedUsernamesInMessage = message.match(regexpToFindAllMentionedUsernames);
const mentionsList : Mention[] = [];
if(!isAdmin && username.startsWith('adm') && (mentionedUsernamesInMessage && !mentionedUsernamesInMessage.includes('@admin'))){
mentionsList.push({
username: 'admin',
title: 'Administrator',
publicKey: ''
});
}
for(let i = 0; i < members.length; i++){
const userInfo = await getUserFromCache(members[i]);
if(!userInfo){
continue;
}
if(!userInfo.username.startsWith(username)){
continue;
}
if(mentionedUsernamesInMessage && mentionedUsernamesInMessage.includes(`@${userInfo.username}`)){
continue;
}
mentionsList.push({
username: userInfo.username,
title: userInfo.title,
publicKey: userInfo.publicKey
});
}
setMentionList(mentionsList);
mentionHandling.current = username;
}
const send = () => {
if(blocked || (message.trim() == "" && attachments.length <= 0)) {
return;
}
sendMessage(message, attachments);
editableDivRef.current.clear();
setAttachments([]);
deselectAllMessages();
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if(mentionList.length <= 0){
send();
}
}
if (!event.shiftKey && event.key.length === 1 && !blocked) {
if (!typingTimeoutRef.current) {
sendTypeingPacket();
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = null;
}, 3000);
}
}
};
const onRemoveAttachment = (attachment: Attachment) => {
setAttachments(attachments.filter(a => a.id != attachment.id));
editableDivRef.current.focus();
}
const onClickPaperclip = () => {
fileDialog.open();
}
const onClickCamera = async () => {
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
/**
* У системных аккаунтов нельзя вызывать вложения
*/
return;
}
if(avatars.length == 0){
return;
}
setAttachments([{
blob: avatars[0].avatar,
id: generateRandomKey(8),
type: AttachmentType.AVATAR,
preview: await base64ImageToBlurhash(avatars[0].avatar)
}]);
if(editableDivRef.current){
editableDivRef.current.focus();
}
}
const sendTypeingPacket = () => {
let packet = new PacketTyping();
packet.setToPublicKey(dialog);
packet.setFromPublicKey(publicKey);
packet.setPrivateKey(privateKey);
sendPacket(packet);
}
const onPaste = async (event: React.ClipboardEvent) => {
if(systemAccounts.find((acc) => acc.publicKey == dialog)){
/**
* У системных аккаунтов нельзя вызывать вложения
*/
return;
}
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
return;
}
if(attachments.find(a => ATTACHMENTS_NOT_ALLOWED_TO_REPLY.includes(a.type))){
return;
}
const items = event.clipboardData.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
const base64Image = await imagePrepareForNetworkTransfer(file);
setAttachments([...attachments, {
blob: base64Image,
id: generateRandomKey(8),
type: AttachmentType.IMAGE,
preview: await base64ImageToBlurhash(base64Image)
}]);
}
if(editableDivRef.current){
editableDivRef.current.focus();
}
break;
}
}
}
const onDragAndDrop = async (files : File[]) => {
if(!files){
return;
}
if(files.length == 0){
return;
}
if(attachments.length >= MAX_ATTACHMENTS_IN_MESSAGE){
return;
}
if(files.length > 1){
return;
}
const file = files[0];
if(((file.size / 1024) / 1024) > MAX_UPLOAD_FILESIZE_MB){
return;
}
let fileContent = await filePrapareForNetworkTransfer(file);
setAttachments([...attachments, {
blob: fileContent,
id: generateRandomKey(8),
type: AttachmentType.FILE,
preview: files[0].size + "::" + files[0].name
}]);
}
const onEmojiClick = (emojiData : EmojiClickData) => {
editableDivRef.current.insertHTML(
`<img alt=":emoji_${emojiData.unified}:" height="20" width="20" data=":emoji_${emojiData.unified}:" src="${emojiData.imageUrl}" />`
);
}
const onSelectMention = (mention : Mention) => {
editableDivRef.current.insertHTMLInCurrentCarretPosition(mention.username.substring(mentionHandling.current.length));
mentionHandling.current = "";
}
return (
<>
<Transition
transition={'fade-down'}
mounted={mentionList.length > 0}>
{(styles) => (
<MentionList onSelectMention={onSelectMention} style={{...styles}} mentions={mentionList}></MentionList>
)}
</Transition>
{attachments.length > 0 &&
<Flex direction={'row'} wrap={'wrap'} p={'sm'} gap={'sm'}>
{attachments.map((m) => (
<DialogAttachment onRemove={onRemoveAttachment} attach={m} key={m.id}></DialogAttachment>
))}
</Flex>}
<Dropzone.FullScreen active={true} onDrop={onDragAndDrop}>
<Flex justify={'center'} align={'center'} mih={document.body.clientHeight - 60} style={{ pointerEvents: 'none' }}>
<Text ta={'center'} size={'md'}>Drop files here to attach without compression</Text>
</Flex>
</Dropzone.FullScreen>
{!isBannedGroup && (
<Box bg={colors.boxColor}>
<Divider color={colors.borderColor}></Divider>
{!blocked &&
<Flex h={'100%'} p={'xs'} direction={'row'} bg={colors.boxColor}>
<Flex w={25} mt={10} justify={'center'}>
<Menu width={150} withArrow>
<Menu.Target>
<IconPaperclip stroke={1.5} style={{
cursor: 'pointer'
}} size={25} color={colors.chevrons.active}></IconPaperclip>
</Menu.Target>
<Menu.Dropdown style={{
userSelect: 'none'
}}>
<Menu.Label fz={'xs'} fw={500} c={'dimmed'}>Attach</Menu.Label>
<Menu.Item fz={'xs'} fw={500} leftSection={
<IconFile size={14}></IconFile>
} onClick={onClickPaperclip}>File</Menu.Item>
{((avatars.length > 0 && !hasGroup(dialog))
|| (avatars.length > 0 && hasGroup(dialog) && isAdmin))
&& <Menu.Item fz={'xs'} fw={500} leftSection={
<IconCamera size={14}></IconCamera>
} onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}</Menu.Item>}
</Menu.Dropdown>
</Menu>
</Flex>
<Flex
w={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
maw={'calc(100% - (25px + 50px + var(--mantine-spacing-xs)))'}
align={'center'}
>
<RichTextInput
ref={editableDivRef}
style={{
border: 0,
minHeight: 45,
fontSize: 14,
background: 'transparent',
width: '100%',
paddingLeft: 10,
paddingRight: 10,
outline: 'none',
paddingTop: 10,
paddingBottom: 8
}}
placeholder="Type message..."
autoFocus
//ref={textareaRef}
//onPaste={onPaste}
//maxLength={2500}
//w={'100%'}
//h={'100%'}
onKeyDown={handleKeyDown}
onChange={setMessage}
onPaste={onPaste}
//dangerouslySetInnerHTML={{__html: message}}
></RichTextInput>
</Flex>
<Flex mt={10} w={'calc(50px + var(--mantine-spacing-xs))'} gap={'xs'}>
<Popover withArrow>
<Popover.Target>
<IconMoodSmile color={colors.chevrons.active} stroke={1.5} size={25} style={{
cursor: 'pointer'
}}></IconMoodSmile>
</Popover.Target>
<Popover.Dropdown p={0}>
<EmojiPicker
onEmojiClick={onEmojiClick}
searchDisabled
skinTonesDisabled
theme={computedTheme == 'dark' ? Theme.DARK : Theme.LIGHT}
/>
</Popover.Dropdown>
</Popover>
<IconSend stroke={1.5} color={message.trim() == "" && attachments.length <= 0 ? colors.chevrons.active : colors.brandColor} onClick={send} style={{
cursor: 'pointer'
}} size={25}></IconSend>
</Flex>
</Flex>}
{blocked && <Box mih={62} bg={colors.boxColor}>
<Flex align={'center'} justify={'center'} h={62}>
<Flex gap={'sm'} align={'center'} justify={'center'}>
<IconBarrierBlock size={24} color={colors.error} stroke={1.2}></IconBarrierBlock>
<Text ta={'center'} c={'dimmed'} size={'xs'}>You need unblock user for send messages.</Text>
</Flex>
</Flex>
</Box>}
</Box>
)}
{isBannedGroup && (
<Flex align={'center'} bg={colors.boxColor} justify={'center'}>
<AnimatedButton onClick={() => {
leaveGroup(dialog)
}} animated={[
'#ff5656',
'#e03131'
]} color="red" animationDurationMs={1000} w={'80%'} size={'sm'} radius={'xl'} leftSection={
<IconDoorExit size={15} />
} mb={'md'}>Leave</AnimatedButton>
</Flex>
)}
</>
)
}