Новый тип вложений - Attachment.CALL с активными звонками

This commit is contained in:
RoyceDa
2026-03-21 21:18:09 +02:00
parent 48e0cddbaa
commit e019702dbe
10 changed files with 446 additions and 113 deletions

View File

@@ -8,6 +8,7 @@ import { ErrorBoundaryProvider } from "@/app/providers/ErrorBoundaryProvider/Err
import { AttachmentError } from "../AttachmentError/AttachmentError"; import { AttachmentError } from "../AttachmentError/AttachmentError";
import { MessageAvatar } from "./MessageAvatar"; import { MessageAvatar } from "./MessageAvatar";
import { MessageProps } from "../Messages/Message"; import { MessageProps } from "../Messages/Message";
import { MessageCall } from "./MessageCall";
export interface MessageAttachmentsProps { export interface MessageAttachmentsProps {
attachments: Attachment[]; attachments: Attachment[];
@@ -51,6 +52,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
return <MessageFile {...attachProps} key={index}></MessageFile> return <MessageFile {...attachProps} key={index}></MessageFile>
case AttachmentType.AVATAR: case AttachmentType.AVATAR:
return <MessageAvatar {...attachProps} key={index}></MessageAvatar> return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
case AttachmentType.CALL:
return <MessageCall {...attachProps} key={index}></MessageCall>
default: default:
return <AttachmentError key={index}></AttachmentError>; return <AttachmentError key={index}></AttachmentError>;
} }

View File

@@ -0,0 +1,62 @@
import { useAttachment } from "@/app/providers/AttachmentProvider/useAttachment";
import { AttachmentProps } from "./MessageAttachments";
import { Avatar, Box, Flex, Text } from "@mantine/core";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { IconPhoneOutgoing, IconX } from "@tabler/icons-react";
import { translateDurationToTime } from "@/app/providers/CallProvider/translateDurationTime";
export function MessageCall(props: AttachmentProps) {
const {
getPreview,
} =
useAttachment(
props.attachment,
props.parent,
);
const preview = getPreview();
const callerRole = preview.split("::")[0];
const duration = parseInt(preview.split("::")[1]);
const colors = useRosettaColors();
const error = duration == 0;
return (
<Box p={'sm'} style={{
background: colors.mainColor,
border: '1px solid ' + colors.borderColor,
borderRadius: 8,
minWidth: 200,
minHeight: 60
}}>
<Flex gap={'sm'} direction={'row'}>
<Avatar bg={error ? colors.error : colors.brandColor} size={40}>
{!error && <>
{callerRole == "0" && (
<IconPhoneOutgoing color={'white'} size={22}></IconPhoneOutgoing>
)}
{callerRole == "1" && (
<IconPhoneOutgoing color={'white'} size={22} style={{
transform: 'rotate(180deg)'
}}></IconPhoneOutgoing>
)}
</>}
{error && <>
<IconX color={'white'} size={22}></IconX>
</>}
</Avatar>
<Flex direction={'column'} gap={5}>
<Text size={'sm'}>{
error ? (callerRole == "0" ? "Missed call" : "Rejected call") : (callerRole == "0" ? "Incoming call" : "Outgoing call")
}</Text>
{!error &&
<Text size={'xs'} c={colors.chevrons.active}>
{translateDurationToTime(duration)}
</Text>
}
{error && <Text size={'xs'} c={colors.error}>
Call was not answered or was rejected
</Text>}
</Flex>
</Flex>
</Box>
);
}

View File

@@ -0,0 +1,148 @@
import { encodeWithPassword } from "@/app/workers/crypto/crypto";
import { MessageReply } from "../DialogProvider/useReplyMessages";
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { base64ImageToBlurhash } from "@/app/workers/image/image";
import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "@/app/constants";
import { useContext, useRef } from "react";
import { useTransport } from "../TransportProvider/useTransport";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
import { DialogContext } from "../DialogProvider/DialogProvider";
export function usePrepareAttachment() {
const intervalsRef = useRef<NodeJS.Timeout>(null);
const {uploadFile} = useTransport();
const {updateDialog} = useDialogsList();
const {runQuery} = useDatabase();
const {info} = useConsoleLogger('usePrepareAttachment');
const {getDialogCache} = useDialogsCache();
const context = useContext(DialogContext);
const updateTimestampInDialogCache = (dialog : string, message_id: string) => {
const dialogCache = getDialogCache(dialog);
if(dialogCache == null){
return;
}
for(let i = 0; i < dialogCache.length; i++){
if(dialogCache[i].message_id == message_id){
dialogCache[i].timestamp = Date.now();
break;
}
}
}
/**
* Обновляет временную метку в сообщении, пока вложения отправляются,
* потому что если этого не делать, то сообщение может быть помечено как
* не доставленное из-за таймаута доставки
* @param attachments Вложения
*/
const doTimestampUpdateImMessageWhileAttachmentsSend = (message_id: string, dialog: string) => {
if(intervalsRef.current){
clearInterval(intervalsRef.current);
}
intervalsRef.current = setInterval(async () => {
/**
* Обновляем время в левом меню
*/
await runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), message_id]);
updateDialog(dialog);
/**
* Обновляем состояние в кэше диалогов
*/
updateTimestampInDialogCache(dialog, message_id);
if(context == null || !context){
/**
* Если этот диалог сейчас не открыт
*/
return;
}
context.setMessages((prev) => {
return prev.map((value) => {
if(value.message_id != message_id){
return value;
}
return {
...value,
timestamp: Date.now()
};
})
});
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
}
/**
* Удаляет старый тег если вложения были подготовлены заново
* например при пересылке сообщений
*/
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
if(preview.indexOf("::") == -1){
return preview;
}
let parts = preview.split("::");
return parts.slice(1).join("::");
}
/**
* Подготавливает вложения для отправки. Подготовка
* состоит в загрузке файлов на транспортный сервер, мы не делаем
* это через WebSocket из-за ограничений по размеру сообщений,
* а так же из-за надежности доставки файлов через HTTP
* @param attachments Attachments to prepare for sending
*/
const prepareAttachmentsToSend = async (message_id: string, dialog: string, password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
if(attachments.length <= 0){
return [];
}
let prepared : Attachment[] = [];
try{
for(let i = 0; i < attachments.length; i++){
const attachment : Attachment = attachments[i];
if(attachment.type == AttachmentType.MESSAGES){
let reply : MessageReply[] = JSON.parse(attachment.blob)
for(let j = 0; j < reply.length; j++){
reply[j].attachments = await prepareAttachmentsToSend(message_id, dialog, password, reply[j].attachments, true);
}
prepared.push({
...attachment,
blob: await encodeWithPassword(password, JSON.stringify(reply))
});
continue;
}
if((attachment.type == AttachmentType.IMAGE
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
/**
* Загружаем превью blurhash для изображения
*/
const blurhash = await base64ImageToBlurhash(attachment.blob);
attachment.preview = blurhash;
}
doTimestampUpdateImMessageWhileAttachmentsSend(message_id, dialog);
const content = await encodeWithPassword(password, attachment.blob);
const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content);
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
prepared.push({
...attachment,
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
blob: ""
});
}
return prepared;
}catch(e){
return prepared;
}
}
return {
prepareAttachmentsToSend
}
}

View File

@@ -13,6 +13,9 @@ import { Button, Flex, Text } from "@mantine/core";
import { useSound } from "@/app/hooks/useSound"; import { useSound } from "@/app/hooks/useSound";
import useWindow from "@/app/hooks/useWindow"; import useWindow from "@/app/hooks/useWindow";
import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE"; import { attachReceiverE2EE, attachSenderE2EE } from "./audioE2EE";
import { useDeattachedSender } from "../DialogProvider/useDeattachedSender";
import { AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
import { generateRandomKey } from "@/app/utils/utils";
export interface CallContextValue { export interface CallContextValue {
call: (callable: string) => void; call: (callable: string) => void;
@@ -80,6 +83,7 @@ export function CallProvider(props : CallProviderProps) {
const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]); const iceCandidatesBufferRef = useRef<RTCIceCandidate[]>([]);
const mutedRef = useRef<boolean>(false); const mutedRef = useRef<boolean>(false);
const soundRef = useRef<boolean>(true); const soundRef = useRef<boolean>(true);
const {sendMessage} = useDeattachedSender();
const {playSound, stopSound, stopLoopSound} = useSound(); const {playSound, stopSound, stopLoopSound} = useSound();
const {setWindowPriority} = useWindow(); const {setWindowPriority} = useWindow();
@@ -434,6 +438,7 @@ export function CallProvider(props : CallProviderProps) {
remoteAudioRef.current.pause(); remoteAudioRef.current.pause();
remoteAudioRef.current.srcObject = null; remoteAudioRef.current.srcObject = null;
} }
generateCallAttachment();
setDuration(0); setDuration(0);
durationIntervalRef.current && clearInterval(durationIntervalRef.current); durationIntervalRef.current && clearInterval(durationIntervalRef.current);
setWindowPriority(false); setWindowPriority(false);
@@ -453,6 +458,27 @@ export function CallProvider(props : CallProviderProps) {
roleRef.current = null; roleRef.current = null;
} }
/**
* Отправляет сообщение в диалог с звонящим с информацией о звонке
*/
const generateCallAttachment = () => {
let preview = "";
if(roleRef.current == CallRole.CALLER){
preview += "1::";
}
if(roleRef.current == CallRole.CALLEE){
preview += "0::";
}
preview += duration.toString();
sendMessage(activeCall, "", [{
id: generateRandomKey(16),
preview: preview,
type: AttachmentType.CALL,
blob: ""
}], false);
}
const accept = () => { const accept = () => {
if(callState != CallState.INCOMING){ if(callState != CallState.INCOMING){
/** /**

View File

@@ -1,4 +1,4 @@
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/workers/crypto/crypto'; import { chacha20Decrypt, decodeWithPassword, decrypt, generateMd5 } from '@/app/workers/crypto/crypto';
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase'; import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
import { createContext, useEffect, useRef, useState } from 'react'; import { createContext, useEffect, useRef, useState } from 'react';
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message'; import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
@@ -11,21 +11,18 @@ import { useBlacklist } from '../BlacklistProvider/useBlacklist';
import { useLogger } from '@/app/hooks/useLogger'; import { useLogger } from '@/app/hooks/useLogger';
import { useSender } from '../ProtocolProvider/useSender'; import { useSender } from '../ProtocolProvider/useSender';
import { usePacket } from '../ProtocolProvider/usePacket'; import { usePacket } from '../ProtocolProvider/usePacket';
import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, MESSAGE_MAX_TIME_TO_DELEVERED_S, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants'; import { MAX_MESSAGES_LOAD, MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from '@/app/constants';
import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery'; import { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
import { useIdle } from '@mantine/hooks'; import { useIdle } from '@mantine/hooks';
import { useWindowFocus } from '@/app/hooks/useWindowFocus'; import { useWindowFocus } from '@/app/hooks/useWindowFocus';
import { useDialogsCache } from './useDialogsCache'; import { useDialogsCache } from './useDialogsCache';
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger'; import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState'; import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
import { MessageReply } from './useReplyMessages';
import { useTransport } from '../TransportProvider/useTransport';
import { useFileStorage } from '@/app/hooks/useFileStorage'; import { useFileStorage } from '@/app/hooks/useFileStorage';
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts'; import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
import { useDialogsList } from '../DialogListProvider/useDialogsList'; import { useDialogsList } from '../DialogListProvider/useDialogsList';
import { useGroups } from './useGroups'; import { useGroups } from './useGroups';
import { useMentions } from '../DialogStateProvider.tsx/useMentions'; import { useMentions } from '../DialogStateProvider.tsx/useMentions';
import { base64ImageToBlurhash } from '@/app/workers/image/image';
export interface DialogContextValue { export interface DialogContextValue {
loading: boolean; loading: boolean;
@@ -33,7 +30,6 @@ export interface DialogContextValue {
setMessages: (messages: React.SetStateAction<Message[]>) => void; setMessages: (messages: React.SetStateAction<Message[]>) => void;
dialog: string; dialog: string;
clearDialogCache: () => void; clearDialogCache: () => void;
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
loadMessagesToTop: () => Promise<void>; loadMessagesToTop: () => Promise<void>;
loadMessagesToMessageId: (messageId: string) => Promise<void>; loadMessagesToMessageId: (messageId: string) => Promise<void>;
} }
@@ -71,6 +67,23 @@ interface DialogProviderProps {
dialog: string; dialog: string;
} }
type DialogMessageEvent = {
dialogId: string;
message: Message;
};
const bus = new EventTarget();
export const emitDialogMessage = (payload: DialogMessageEvent) => {
bus.dispatchEvent(new CustomEvent<DialogMessageEvent>("dialog:message", { detail: payload }));
};
export const onDialogMessage = (handler: (payload: DialogMessageEvent) => void) => {
const listener = (e: Event) => handler((e as CustomEvent<DialogMessageEvent>).detail);
bus.addEventListener("dialog:message", listener);
return () => bus.removeEventListener("dialog:message", listener);
};
export function DialogProvider(props: DialogProviderProps) { export function DialogProvider(props: DialogProviderProps) {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const {allQuery, runQuery} = useDatabase(); const {allQuery, runQuery} = useDatabase();
@@ -88,15 +101,21 @@ export function DialogProvider(props: DialogProviderProps) {
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache(); const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
const {info, warn, error} = useConsoleLogger('DialogProvider'); const {info, warn, error} = useConsoleLogger('DialogProvider');
const [viewState] = useViewPanelsState(); const [viewState] = useViewPanelsState();
const {uploadFile} = useTransport();
const {readFile} = useFileStorage(); const {readFile} = useFileStorage();
const intervalsRef = useRef<NodeJS.Timeout>(null);
const systemAccounts = useSystemAccounts(); const systemAccounts = useSystemAccounts();
const {updateDialog} = useDialogsList(); const {updateDialog} = useDialogsList();
const {hasGroup, getGroupKey} = useGroups(); const {hasGroup, getGroupKey} = useGroups();
const {popMention, isMentioned} = useMentions(); const {popMention, isMentioned} = useMentions();
useEffect(() => {
const unsub = onDialogMessage(({ dialogId, message }) => {
if (dialogId !== props.dialog) return;
setMessages((prev) => [...prev, message]);
});
return unsub;
}, [props.dialog]);
useEffect(() => { useEffect(() => {
setCurrentDialogPublicKeyView(props.dialog); setCurrentDialogPublicKeyView(props.dialog);
return () => { return () => {
@@ -919,6 +938,16 @@ export function DialogProvider(props: DialogProviderProps) {
}); });
continue; continue;
} }
if(meta.type == AttachmentType.CALL){
/**
* Если это звонок
*/
attachments.push({
...meta,
blob: ""
});
continue;
}
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`); const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
if(!fileData) { if(!fileData) {
attachments.push({ attachments.push({
@@ -940,110 +969,12 @@ export function DialogProvider(props: DialogProviderProps) {
} }
return attachments; return attachments;
}catch(e) { }catch(e) {
console.info(e);
error("Failed to parse attachments"); error("Failed to parse attachments");
} }
return []; return [];
} }
/**
* Обновляет временную метку в сообщении, пока вложения отправляются,
* потому что если этого не делать, то сообщение может быть помечено как
* не доставленное из-за таймаута доставки
* @param attachments Вложения
*/
const doTimestampUpdateImMessageWhileAttachmentsSend = (attachments : Attachment[]) => {
if(intervalsRef.current){
clearInterval(intervalsRef.current);
}
intervalsRef.current = setInterval(() => {
//update timestamp in message to keep message marked as error
updateDialog(props.dialog);
setMessages((prev) => {
return prev.map((value) => {
if(value.attachments.length <= 0){
return value;
}
if(value.attachments[0].id != attachments[0].id){
return value;
}
runQuery("UPDATE messages SET timestamp = ? WHERE message_id = ?", [Date.now(), value.message_id]);
return {
...value,
timestamp: Date.now()
};
})
});
}, (MESSAGE_MAX_TIME_TO_DELEVERED_S / 2) * 1000);
}
/**
* Удаляет старый тег если вложения были подготовлены заново
* например при пересылке сообщений
*/
const removeOldTagIfAttachemtnsRePreapred = (preview : string) => {
if(preview.indexOf("::") == -1){
return preview;
}
let parts = preview.split("::");
return parts.slice(1).join("::");
}
/**
* Подготавливает вложения для отправки. Подготовка
* состоит в загрузке файлов на транспортный сервер, мы не делаем
* это через WebSocket из-за ограничений по размеру сообщений,
* а так же из-за надежности доставки файлов через HTTP
* @param attachments Attachments to prepare for sending
*/
const prepareAttachmentsToSend = async (password: string, attachments : Attachment[], rePrepared : boolean = false) : Promise<Attachment[]> => {
if(attachments.length <= 0){
return [];
}
let prepared : Attachment[] = [];
try{
for(let i = 0; i < attachments.length; i++){
const attachment : Attachment = attachments[i];
if(attachment.type == AttachmentType.MESSAGES){
let reply : MessageReply[] = JSON.parse(attachment.blob)
for(let j = 0; j < reply.length; j++){
reply[j].attachments = await prepareAttachmentsToSend(password, reply[j].attachments, true);
}
prepared.push({
...attachment,
blob: await encodeWithPassword(password, JSON.stringify(reply))
});
continue;
}
if((attachment.type == AttachmentType.IMAGE
|| attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){
/**
* Загружаем превью blurhash для изображения
*/
const blurhash = await base64ImageToBlurhash(attachment.blob);
attachment.preview = blurhash;
}
doTimestampUpdateImMessageWhileAttachmentsSend(attachments);
const content = await encodeWithPassword(password, attachment.blob);
const upid = attachment.id;
info(`Uploading attachment with upid: ${upid}`);
info(`Attachment content length: ${content.length}`);
let tag = await uploadFile(upid, content);
info(`Uploaded attachment with upid: ${upid}, received tag: ${tag}`);
if(intervalsRef.current != null){
clearInterval(intervalsRef.current);
}
prepared.push({
...attachment,
preview: tag + "::" + (rePrepared ? removeOldTagIfAttachemtnsRePreapred(attachment.preview) : attachment.preview),
blob: ""
});
}
return prepared;
}catch(e){
return prepared;
}
}
/** /**
* Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение * Дедубликация сообщений по message_id, так как может возникать ситуация, что одно и то же сообщение
* может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации * может загрузиться несколько раз при накладках сети, отставании часов, при синхронизации
@@ -1071,7 +1002,6 @@ export function DialogProvider(props: DialogProviderProps) {
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog)); setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
}, },
dialog: props.dialog, dialog: props.dialog,
prepareAttachmentsToSend,
loadMessagesToTop, loadMessagesToTop,
loadMessagesToMessageId loadMessagesToMessageId
}}> }}>

View File

@@ -0,0 +1,160 @@
import { generateRandomKey } from "@/app/utils/utils";
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
import { useGroups } from "./useGroups";
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5 } from "@/app/workers/crypto/crypto";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { AttachmentMeta, DeliveredMessageState, emitDialogMessage, Message } from "./DialogProvider";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
import { useSender } from "../ProtocolProvider/useSender";
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
/**
* Используется для отправки сообщений не внутри DialogProvider, а например в CallProvider,
* когда нам нужно отправить сообщение от своего имени что мы совершли звонок (Attachment.CALL)
*/
export function useDeattachedSender() {
const {hasGroup, getGroupKey} = useGroups();
const privatePlain = usePrivatePlain();
const {warn} = useConsoleLogger('useDeattachedSender');
const {runQuery} = useDatabase();
const {writeFile} = useFileStorage();
const publicKey = usePublicKey();
const {updateDialog} = useDialogsList();
const [protocolState] = useProtocolState();
const privateKey = usePrivateKeyHash();
const send = useSender();
const {prepareAttachmentsToSend} = usePrepareAttachment();
/**
* Отправка сообщения в диалог
* @param dialog ID диалога, может быть как публичным ключом собеседника, так и ID группового диалога
* @param message Сообщение
* @param attachemnts Вложения
*/
const sendMessage = async (dialog: string, message: string, attachemnts : Attachment[], serverSent: boolean = false) => {
const messageId = generateRandomKey(16);
let cahchaEncrypted = {ciphertext: "", key: "", nonce: ""} as any;
let key = Buffer.from("");
let encryptedKey = "";
let plainMessage = "";
let content = "";
if(!hasGroup(dialog)){
cahchaEncrypted = (await chacha20Encrypt(message.trim()) as any);
key = Buffer.concat([
Buffer.from(cahchaEncrypted.key, "hex"),
Buffer.from(cahchaEncrypted.nonce, "hex")]);
encryptedKey = await encrypt(key.toString('binary'), dialog);
plainMessage = await encodeWithPassword(privatePlain, message.trim());
content = cahchaEncrypted.ciphertext;
}else{
/**
* Это группа, там шифрование устроено иначе
* для групп используется один общий ключ, который
* есть только у участников группы, сам ключ при этом никак
* не отправляется по сети (ведь ID у группы общий и у каждого
* и так есть этот ключ)
*/
const groupKey = await getGroupKey(dialog);
if(!groupKey){
warn("Group key not found for dialog " + dialog);
return;
}
content = await encodeWithPassword(groupKey, message.trim());
plainMessage = await encodeWithPassword(privatePlain, message.trim());
encryptedKey = ""; // В группах не нужен зашифрованный ключ
key = Buffer.from(groupKey);
}
/**
* Нужно зашифровать ключ еще и нашим ключом,
* чтобы в последствии мы могли расшифровать этот ключ у своих
* же сообщений (смотреть problem_sync.md)
*/
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
emitDialogMessage({
dialogId: dialog,
message: {
from_public_key: publicKey,
to_public_key: dialog,
content: content,
timestamp: Date.now(),
readed: publicKey == dialog ? 1 : 0,
chacha_key: "",
from_me: 1,
plain_message: message,
delivered: serverSent ? (publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: attachemnts
} as Message
})
let attachmentsMeta : AttachmentMeta[] = [];
for(let i = 0; i < attachemnts.length; i++) {
const attachment = attachemnts[i];
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
});
if(attachment.type == AttachmentType.FILE){
/**
* Обычно вложения дублируются на диск. Так происходит со всем.
* Кроме файлов. Если дублировать файл весом в 2гб на диск отправка будет
* занимать очень много времени.
* К тому же, это приведет к созданию ненужной копии у отправителя
*/
continue;
}
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`, Buffer.from(await encodeWithPassword(privatePlain, attachment.blob)).toString('binary'));
}
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
(serverSent ? (protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING) : DeliveredMessageState.DELIVERED)
), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog);
if(publicKey == ""
|| dialog == ""
|| publicKey == dialog) {
return;
}
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog);
return;
}
if(!serverSent){
return;
}
const packet = new PacketMessage();
packet.setFromPublicKey(publicKey);
packet.setToPublicKey(dialog);
packet.setContent(content);
packet.setChachaKey(encryptedKey);
packet.setPrivateKey(privateKey);
packet.setMessageId(messageId);
packet.setTimestamp(Date.now());
packet.setAttachments(preparedToNetworkSendAttachements);
packet.setAesChachaKey(aesChachaKey);
send(packet);
}
return {sendMessage};
}

View File

@@ -14,6 +14,7 @@ import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider"; import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useGroups } from "./useGroups"; import { useGroups } from "./useGroups";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger"; import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { usePrepareAttachment } from "../AttachmentProvider/usePrepareAttachment";
export function useDialog() : { export function useDialog() : {
messages: Message[]; messages: Message[];
@@ -34,8 +35,7 @@ export function useDialog() : {
throw new Error("useDialog must be used within a DialogProvider"); throw new Error("useDialog must be used within a DialogProvider");
} }
const {loading, const {loading,
messages, messages,
prepareAttachmentsToSend,
clearDialogCache, clearDialogCache,
setMessages, setMessages,
dialog, loadMessagesToTop, loadMessagesToMessageId} = context; dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
@@ -47,6 +47,7 @@ export function useDialog() : {
const [protocolState] = useProtocolState(); const [protocolState] = useProtocolState();
const {hasGroup, getGroupKey} = useGroups(); const {hasGroup, getGroupKey} = useGroups();
const {warn} = useConsoleLogger('useDialog'); const {warn} = useConsoleLogger('useDialog');
const {prepareAttachmentsToSend} = usePrepareAttachment();
/** /**
* Отправка сообщения в диалог * Отправка сообщения в диалог
@@ -146,7 +147,7 @@ export function useDialog() : {
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd //98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
console.info("Sending key for message ", key.toString('hex')); console.info("Sending key for message ", key.toString('hex'));
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(key.toString('utf-8'), attachemnts); let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('utf-8'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){ if(attachemnts.length <= 0 && message.trim() == ""){
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]); runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog); updateDialog(dialog);

View File

@@ -30,7 +30,7 @@ export function useDialogsCache() {
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => { const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
/** /**
* TODO: Optimize this function to avoid full map if possible * TODO: Оптимизировать чтобы проходил снизу вверх
*/ */
let newCache = dialogsCache.map((cache) => { let newCache = dialogsCache.map((cache) => {
let newMessages = cache.messages.map((message) => { let newMessages = cache.messages.map((message) => {

View File

@@ -5,7 +5,8 @@ export enum AttachmentType {
IMAGE = 0, IMAGE = 0,
MESSAGES = 1, MESSAGES = 1,
FILE = 2, FILE = 2,
AVATAR = 3 AVATAR = 3,
CALL
} }
export interface Attachment { export interface Attachment {

View File

@@ -15,6 +15,8 @@ export const constructLastMessageTextByAttachments = (attachment: string) => {
return "$a=File"; return "$a=File";
case AttachmentType.AVATAR: case AttachmentType.AVATAR:
return "$a=Avatar"; return "$a=Avatar";
case AttachmentType.CALL:
return "$a=Call";
default: default:
return "[Unsupported attachment]"; return "[Unsupported attachment]";
} }