This commit is contained in:
rosetta
2026-01-30 05:01:05 +02:00
commit 83f38dc63f
327 changed files with 18725 additions and 0 deletions

View File

@@ -0,0 +1,836 @@
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from '@/app/crypto/crypto';
import { useDatabase } from '@/app/providers/DatabaseProvider/useDatabase';
import { createContext, useEffect, useRef, useState } from 'react';
import { Attachment, AttachmentType, PacketMessage } from '@/app/providers/ProtocolProvider/protocol/packets/packet.message';
import { usePrivatePlain } from '../AccountProvider/usePrivatePlain';
import { usePublicKey } from '../AccountProvider/usePublicKey';
import { PacketRead } from '@/app/providers/ProtocolProvider/protocol/packets/packet.read';
import { usePrivateKeyHash } from '../AccountProvider/usePrivateKeyHash';
import { useMemory } from '../MemoryProvider/useMemory';
import { useBlacklist } from '../BlacklistProvider/useBlacklist';
import { useLogger } from '@/app/hooks/useLogger';
import { useSender } from '../ProtocolProvider/useSender';
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 { PacketDelivery } from '@/app/providers/ProtocolProvider/protocol/packets/packet.delivery';
import { useIdle } from '@mantine/hooks';
import { useWindowFocus } from '@/app/hooks/useWindowFocus';
import { useDialogsCache } from './useDialogsCache';
import { useConsoleLogger } from '@/app/hooks/useConsoleLogger';
import { useViewPanelsState, ViewPanelsState } from '@/app/hooks/useViewPanelsState';
import { generateRandomKeyFormSeed } from '@/app/utils/utils';
import { MessageReply } from './useReplyMessages';
import { useTransport } from '../TransportProvider/useTransport';
import { useFileStorage } from '@/app/hooks/useFileStorage';
import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts';
import { useDialogsList } from '../DialogListProvider/useDialogsList';
import { useGroups } from './useGroups';
import { useMentions } from '../DialogStateProvider.tsx/useMentions';
export interface DialogContextValue {
loading: boolean;
messages: Message[];
setMessages: (messages: React.SetStateAction<Message[]>) => void;
dialog: string;
clearDialogCache: () => void;
prepareAttachmentsToSend: (password: string, attachments: Attachment[]) => Promise<Attachment[]>;
loadMessagesToTop: () => Promise<void>;
loadMessagesToMessageId: (messageId: string) => Promise<void>;
}
export const DialogContext = createContext<DialogContextValue | null>(null);
export enum DeliveredMessageState {
WAITING,
DELIVERED,
ERROR
}
export interface AttachmentMeta {
id: string;
type: AttachmentType;
preview: string;
}
export interface Message {
from_public_key: string;
to_public_key: string;
content: string;
timestamp: number;
readed: number;
chacha_key: string;
from_me: number;
plain_message: string;
delivered: DeliveredMessageState;
message_id: string;
attachments: Attachment[];
}
interface DialogProviderProps {
children: React.ReactNode;
dialog: string;
}
export function DialogProvider(props: DialogProviderProps) {
const [messages, setMessages] = useState<Message[]>([]);
const {allQuery, runQuery} = useDatabase();
const privatePlain = usePrivatePlain();
const publicKey = usePublicKey();
const privateKey = usePrivateKeyHash();
const send = useSender();
const [__, setCurrentDialogPublicKeyView] = useMemory("current-dialog-public-key-view", "", true);
const log = useLogger('DialogProvider');
const [blocked] = useBlacklist(props.dialog)
const lastMessageTimeRef = useRef(0);
const idle = useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000);
const focus = useWindowFocus();
const [loading, setLoading] = useState(false);
const {getDialogCache, addOrUpdateDialogCache, dialogsCache, setDialogsCache} = useDialogsCache();
const {info, warn, error} = useConsoleLogger('DialogProvider');
const [viewState] = useViewPanelsState();
const {uploadFile} = useTransport();
const {readFile} = useFileStorage();
const intervalsRef = useRef<NodeJS.Timeout>(null);
const systemAccounts = useSystemAccounts();
const {updateDialog} = useDialogsList();
const {hasGroup, getGroupKey} = useGroups();
const {popMention, isMentioned} = useMentions();
useEffect(() => {
setCurrentDialogPublicKeyView(props.dialog);
return () => {
setCurrentDialogPublicKeyView("");
}
}, [props.dialog]);
useEffect(() => {
if(props.dialog == "demo"){
return;
}
if(idle){
return;
}
(async () => {
if(hasGroup(props.dialog)){
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND account = ? AND read != 1 AND from_public_key != ?`, [props.dialog, publicKey, publicKey]);
}else{
await runQuery(`UPDATE messages SET read = 1 WHERE ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?)) AND account = ? AND read != 1 AND from_me = 0`, [props.dialog, publicKey, publicKey, props.dialog, publicKey]);
}
updateDialog(props.dialog);
})();
}, [idle, props.dialog]);
useEffect(() => {
if(props.dialog == "demo"){
return;
}
setMessages([]);
if(props.dialog == ""
|| privatePlain == "") {
return;
}
(async () => {
let dialogCacheEntry = getDialogCache(props.dialog);
if(dialogCacheEntry.length > 0){
const messagesToLoadFromCache = dialogCacheEntry.slice(-MESSAGE_MAX_LOADED);
setMessages(messagesToLoadFromCache);
info("Loading messages for " + props.dialog + " from cache");
setLoading(false);
if(hasGroup(props.dialog)){
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND account = ? AND read != 1 AND from_public_key != ?`, [props.dialog, publicKey, publicKey]);
}else{
await runQuery(`UPDATE messages SET read = 1 WHERE ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?)) AND account = ? AND read != 1 AND from_me = 0`, [props.dialog, publicKey, publicKey, props.dialog, publicKey]);
}
if(isMentioned(props.dialog)){
/**
* Удаляем упоминания потому что мы только что загрузили
* диалог из кэша, может быть в нем есть упоминания
*/
for(let i = 0; i < messagesToLoadFromCache.length; i++){
const message = messagesToLoadFromCache[i];
popMention({
dialog_id: props.dialog,
message_id: message.message_id
});
}
}
updateDialog(props.dialog);
return;
}
info("Loading messages for " + props.dialog + " from database");
setLoading(true);
let result: any[] = [];
if (props.dialog != publicKey) {
if(hasGroup(props.dialog)){
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE (to_public_key = ?) AND account = ? ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [props.dialog, publicKey, MAX_MESSAGES_LOAD]);
}else{
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE (from_public_key = ? OR to_public_key = ?) AND (from_public_key = ? OR to_public_key = ?) AND account = ? ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [props.dialog, props.dialog, publicKey, publicKey, publicKey, MAX_MESSAGES_LOAD]);
}
} else {
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE from_public_key = ? AND to_public_key = ? AND account = ? ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [publicKey, publicKey, publicKey, MAX_MESSAGES_LOAD]);
}
await runQuery(`UPDATE messages SET read = 1 WHERE ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?)) AND account = ? AND read != 1 AND from_me = 0`, [props.dialog, publicKey, publicKey, props.dialog, publicKey]);
const finalMessages : Message[] = [];
let readUpdated = false;
for(let i = 0; i < result.length; i++){
const message = result[i];
if(message.read != 1 && !readUpdated){
readUpdated = true;
}
let decryptKey = '';
if(message.from_me){
/**
* Если сообщение от меня, то ключ расшифровки для вложений
* не нужен, передаем пустую строку, так как под капотом
* в MessageAttachment.tsx при расшифровке вложений используется
* локальный ключ, а не тот что в сообщении, так как файл и так находится
* у нас локально
*/
decryptKey = '';
}
if(hasGroup(props.dialog)){
/**
* Если это групповое сообщение, то получаем ключ группы
*/
decryptKey = await getGroupKey(props.dialog);
}
if(!message.from_me && !hasGroup(props.dialog)){
/**
* Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом
*/
console.info("Decrypting chacha key for message");
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
}
finalMessages.push({
from_public_key: message.from_public_key,
to_public_key: message.to_public_key,
content: "__ENCRYPTED__",
timestamp: message.timestamp,
readed: message.read || message.from_public_key == message.to_public_key,
chacha_key: decryptKey,
from_me: message.from_me,
plain_message: await loadMessage(message.plain_message),
delivered: message.delivered,
message_id: message.message_id,
attachments: await loadAttachments(message.attachments)
});
if(isMentioned(props.dialog)){
/**
* Если мы были упомянуты в этом диалоге, то убираем упоминание,
* так как мы только что загрузили это сообщение
*/
popMention({
dialog_id: props.dialog,
message_id: message.message_id
});
}
}
if(readUpdated){
updateDialog(props.dialog);
}
setMessages(finalMessages);
setLoading(false);
})();
}, [props.dialog]);
useEffect(() => {
if(props.dialog == "demo"){
return;
}
if(!messages || messages.length == 0){
return;
}
addOrUpdateDialogCache(props.dialog, messages);
}, [props.dialog, messages])
useEffect(() => {
if(props.dialog == publicKey || messages.length == 0
|| blocked
|| idle
|| lastMessageTimeRef.current == messages[messages.length - 1].timestamp
|| !focus){
return;
}
if(viewState == ViewPanelsState.DIALOGS_PANEL_ONLY){
/**
* Если мы сейчас видим только диалоги
* то сообщение мы не читаем
*/
return;
}
if(systemAccounts.find(acc => acc.publicKey == props.dialog)){
/**
* Системные аккаунты не отмечаем как прочитанные
*/
return;
}
const readPacket = new PacketRead();
readPacket.setFromPublicKey(publicKey);
readPacket.setToPublicKey(props.dialog);
readPacket.setPrivateKey(privateKey);
send(readPacket);
log("Send read packet to " + props.dialog);
info("Send read packet");
lastMessageTimeRef.current = messages[messages.length - 1].timestamp;
}, [props.dialog, viewState, focus, messages, blocked, idle]);
usePacket(0x07, async (packet : PacketRead) => {
info("Read packet received in dialog provider");
const fromPublicKey = packet.getFromPublicKey();
if(hasGroup(props.dialog)){
/**
* Для групп обработка чтения есть ниже
*/
return;
}
if(fromPublicKey != props.dialog && !idle){
return;
}
setMessages((prev) => prev.map((msg) => {
if(msg.from_public_key == publicKey && !msg.readed){
return {
...msg,
readed: 1
}
}
return msg;
}));
//updateDialog(props.dialog);
}, [idle, props.dialog]);
usePacket(0x07, async (packet : PacketRead) => {
info("Read packet received in dialog provider");
//const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if(!hasGroup(props.dialog)){
/**
* Для личных сообщений обработка чтения выше
*/
return;
}
if(toPublicKey != props.dialog && !idle){
return;
}
setMessages((prev) => prev.map((msg) => {
if(msg.from_public_key == publicKey && !msg.readed){
return {
...msg,
readed: 1
}
}
return msg;
}));
//updateDialog(props.dialog);
}, [idle, props.dialog]);
usePacket(0x08, async (packet : PacketDelivery) => {
info("Delivery packet received in dialog provider");
const fromPublicKey = packet.getToPublicKey();
const messageId = packet.getMessageId();
if(fromPublicKey != props.dialog){
return;
}
setMessages((prev) => prev.map((msg) => {
if(msg.message_id == messageId && msg.delivered != DeliveredMessageState.DELIVERED){
return {
...msg,
delivered: DeliveredMessageState.DELIVERED,
timestamp: Date.now()
}
}
return msg;
}));
}, [props.dialog]);
/**
* Обработчик для личных сообщений
*/
usePacket(0x06, async (packet: PacketMessage) => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if(hasGroup(props.dialog)){
/**
* Если это групповое сообщение, то для него есть
* другой обработчик ниже
*/
return;
}
if(fromPublicKey != props.dialog || toPublicKey != publicKey){
console.info("From " + fromPublicKey + " to " + props.dialog + " ignore");
return;
}
if(blocked){
warn("Message from blocked user, ignore " + fromPublicKey);
log("Message from blocked user, ignore " + fromPublicKey);
return;
}
const content = packet.getContent();
const chachaKey = packet.getChachaKey();
const timestamp = packet.getTimestamp();
/**
* Генерация рандомного ID сообщения по SEED нужна для того,
* чтобы сообщение записанное здесь в стек сообщений совпадало
* с тем что записывается в БД в файле useDialogFiber.ts
*/
const messageId = generateRandomKeyFormSeed(16, fromPublicKey + toPublicKey + timestamp.toString());
const chachaDecryptedKey = Buffer.from(
await decrypt(chachaKey, privatePlain),
"binary");
const key = chachaDecryptedKey.slice(0, 32);
const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
let attachments: Attachment[] = [];
for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
attachments.push({
id: attachment.id,
preview: attachment.preview,
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob) : ""
});
}
const newMessage : Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: attachments
};
setMessages((prev) => ([...prev, newMessage]));
}, [blocked, messages, idle, props.dialog]);
/**
* Обработчик для групповых сообщений
*/
usePacket(0x06, async (packet: PacketMessage) => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
if(toPublicKey != props.dialog){
/**
* Исправление кросс диалогового сообщения
*/
return;
}
if(!hasGroup(props.dialog)){
/**
* Если это не групповое сообщение, то для него есть
* другой обработчик выше
*/
return;
}
const content = packet.getContent();
const timestamp = packet.getTimestamp();
/**
* Генерация рандомного ID сообщения по SEED нужна для того,
* чтобы сообщение записанное здесь в стек сообщений совпадало
* с тем что записывается в БД в файле useDialogFiber.ts
*/
const messageId = generateRandomKeyFormSeed(16, fromPublicKey + toPublicKey + timestamp.toString());
const groupKey = await getGroupKey(toPublicKey);
if(!groupKey){
log("Group key not found for group " + toPublicKey);
error("Message dropped because group key not found for group " + toPublicKey);
return;
}
info("New group message packet received from " + fromPublicKey);
let decryptedContent = '';
try{
decryptedContent = await decodeWithPassword(groupKey, content);
}catch(e) {
decryptedContent = '';
}
let attachments: Attachment[] = [];
for(let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
attachments.push({
id: attachment.id,
preview: attachment.preview,
type: attachment.type,
blob: attachment.type == AttachmentType.MESSAGES ? await decodeWithPassword(groupKey, attachment.blob) : ""
});
}
const newMessage : Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: groupKey,
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: attachments
};
setMessages((prev) => ([...prev, newMessage]));
}, [messages, idle, props.dialog]);
/**
* Расшифровывает сообщение
* @param message Зашифрованное сообщение
* @returns Расшифрованное сообщение
*/
const loadMessage = async (message : string) => {
try{
return await decodeWithPassword(privatePlain, message);
}catch(e){
return "";
}
}
/**
* Загружает часть диалога где есть определенный message_id
*/
const loadMessagesToMessageId = async (messageId: string) => {
warn("Load messages to message ID " + messageId + " for " + props.dialog);
if(props.dialog == "DELETED"
|| privatePlain == "") {
return;
}
let result : any[] = [];
if(props.dialog != publicKey){
if(hasGroup(props.dialog)){
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE (to_public_key = ?) AND account = ? AND timestamp <= (SELECT timestamp FROM messages WHERE message_id = ? AND account = ?) ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [props.dialog, publicKey, messageId, publicKey, MAX_MESSAGES_LOAD]);
}else{
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE (from_public_key = ? OR to_public_key = ?) AND (from_public_key = ? OR to_public_key = ?) AND account = ? AND timestamp <= (SELECT timestamp FROM messages WHERE message_id = ? AND account = ?) ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [props.dialog, props.dialog, publicKey, publicKey, publicKey, messageId, publicKey, MAX_MESSAGES_LOAD]);
}
}else{
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE from_public_key = ? AND to_public_key = ? AND account = ? AND timestamp <= (SELECT timestamp FROM messages WHERE message_id = ? AND account = ?) ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [publicKey, publicKey, publicKey, messageId, publicKey, MAX_MESSAGES_LOAD]);
}
const finalMessages : Message[] = [];
for(let i = 0; i < result.length; i++){
const message = result[i];
let decryptKey = '';
if(message.from_me){
/**
* Если сообщение от меня, то ключ расшифровки для вложений
* не нужен, передаем пустую строку, так как под капотом
* в MessageAttachment.tsx при расшифровке вложений используется
* локальный ключ, а не тот что в сообщении, так как файл и так находится
* у нас локально
*/
decryptKey = '';
}
if(hasGroup(props.dialog)){
/**
* Если это групповое сообщение, то получаем ключ группы
*/
decryptKey = await getGroupKey(props.dialog);
}
if(!message.from_me && !hasGroup(props.dialog)){
/**
* Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом
*/
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
}
finalMessages.push({
from_public_key: message.from_public_key,
to_public_key: message.to_public_key,
content: "__ENCRYPTED__",
timestamp: message.timestamp,
readed: message.read || message.from_public_key == message.to_public_key || !message.from_me,
chacha_key: decryptKey,
from_me: message.from_me,
plain_message: await loadMessage(message.plain_message),
delivered: message.delivered,
message_id: message.message_id,
attachments: await loadAttachments(message.attachments)
});
if(isMentioned(props.dialog)){
/**
* Если мы были упомянуты в этом диалоге, то убираем упоминание,
* так как мы только что загрузили это сообщение
*/
popMention({
dialog_id: props.dialog,
message_id: message.message_id
});
}
}
if(finalMessages.length == 0) {
return;
}
setMessages([...finalMessages]);
}
/**
* Загружает сообщения в верх диалога, когда пользователь
* скроллит вверх и доскроллил до конца
* @returns
*/
const loadMessagesToTop = async () => {
warn("Load messages to top for " + props.dialog);
if(props.dialog == "DELETED"
|| privatePlain == "") {
return;
}
let result : any[] = [];
if(props.dialog != publicKey){
if(hasGroup(props.dialog)){
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE (to_public_key = ?) AND account = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [props.dialog, publicKey, messages.length > 0 ? messages[0].timestamp : Math.floor(Date.now() / 1000), MAX_MESSAGES_LOAD]);
}else{
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE (from_public_key = ? OR to_public_key = ?) AND (from_public_key = ? OR to_public_key = ?) AND account = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [props.dialog, props.dialog, publicKey, publicKey, publicKey, messages.length > 0 ? messages[0].timestamp : Math.floor(Date.now() / 1000), MAX_MESSAGES_LOAD]);
}
}else{
result = await allQuery(`
SELECT * FROM (SELECT * FROM messages WHERE from_public_key = ? AND to_public_key = ? AND account = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?) ORDER BY timestamp ASC
`, [publicKey, publicKey, publicKey, messages.length > 0 ? messages[0].timestamp : Math.floor(Date.now() / 1000), MAX_MESSAGES_LOAD]);
}
const finalMessages : Message[] = [];
for(let i = 0; i < result.length; i++){
const message = result[i];
let decryptKey = '';
if(message.from_me){
/**
* Если сообщение от меня, то ключ расшифровки для вложений
* не нужен, передаем пустую строку, так как под капотом
* в MessageAttachment.tsx при расшифровке вложений используется
* локальный ключ, а не тот что в сообщении, так как файл и так находится
* у нас локально
*/
decryptKey = '';
}
if(hasGroup(props.dialog)){
/**
* Если это групповое сообщение, то получаем ключ группы
*/
decryptKey = await getGroupKey(props.dialog);
}
if(!message.from_me && !hasGroup(props.dialog)){
/**
* Если сообщение не от меня и не групповое,
* расшифровываем ключ чачи своим приватным ключом
*/
decryptKey = Buffer.from(await decrypt(message.chacha_key, privatePlain), "binary").toString('utf-8');
}
finalMessages.push({
from_public_key: message.from_public_key,
to_public_key: message.to_public_key,
content: "__ENCRYPTED__",
timestamp: message.timestamp,
readed: message.read || message.from_public_key == message.to_public_key || !message.from_me,
chacha_key: decryptKey,
from_me: message.from_me,
plain_message: await loadMessage(message.plain_message),
delivered: message.delivered,
message_id: message.message_id,
attachments: await loadAttachments(message.attachments)
});
if(isMentioned(props.dialog)){
/**
* Если мы были упомянуты в этом диалоге, то убираем упоминание,
* так как мы только что загрузили это сообщение
*/
popMention({
dialog_id: props.dialog,
message_id: message.message_id
});
}
}
if(finalMessages.length == 0) {
return;
}
setMessages([...finalMessages, ...messages]);
}
/**
* Загружает вложения из JSON строки.
* Если вложение не было загружено (то есть его нет на диске),
* то в blob кладется пустая строка, далее в useAttachment
* это отработается (загрузится превью вложение и появится возможность скачать и тд)
* @param jsonAttachments JSON вложений AttachmentMeta формат
* @returns Вложения
*/
const loadAttachments = async (jsonAttachments : string) : Promise<Attachment[]> => {
if(jsonAttachments == "[]") {
return [];
}
try {
const attachmentsMeta : AttachmentMeta[] = JSON.parse(jsonAttachments);
const attachments : Attachment[] = [];
for(const meta of attachmentsMeta) {
if(meta.type == AttachmentType.FILE){
/**
* Все кроме файлов декодируем заранее
*/
attachments.push({
...meta,
blob: ""
});
continue;
}
const fileData = await readFile(`m/${await generateMd5(meta.id + publicKey)}`);
if(!fileData) {
attachments.push({
...meta,
blob: ""
});
continue;
}
const decrypted = await decodeWithPassword(privatePlain, Buffer.from(fileData, 'binary').toString());
attachments.push({
id: meta.id,
blob: decrypted,
type: meta.type,
preview: meta.preview
});
}
return attachments;
}catch(e) {
error("Failed to parse attachments");
}
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;
}
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;
}
}
return (
<DialogContext.Provider value={{
loading,
messages,
setMessages,
clearDialogCache: () => {
setDialogsCache(dialogsCache.filter((cache) => cache.publicKey != props.dialog));
},
dialog: props.dialog,
prepareAttachmentsToSend,
loadMessagesToTop,
loadMessagesToMessageId
}}>
{props.children}
</DialogContext.Provider>
)
}

View File

@@ -0,0 +1,242 @@
import { useContext } from "react";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5} from "../../crypto/crypto";
import { AttachmentMeta, DeliveredMessageState, DialogContext, Message } from "./DialogProvider";
import { Attachment, AttachmentType, PacketMessage } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
import { useSender } from "../ProtocolProvider/useSender";
import { generateRandomKey } from "@/app/utils/utils";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useGroups } from "./useGroups";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
export function useDialog() : {
messages: Message[];
sendMessage: (message: string, attachemnts : Attachment[]) => Promise<void>;
deleteMessages: () => Promise<void>;
loadMessagesToTop: (count?: number) => Promise<void>;
deleteMessageById: (messageId: string) => Promise<void>;
loading: boolean;
deleteSelectedMessages: (messageIds: string[]) => Promise<void>;
dialog: string;
loadMessagesToMessageId: (messageId: string) => Promise<void>;
updateAttachmentsInMessagesByAttachmentId: (attachmentId: string, blob: string) => Promise<void>;
} {
const {runQuery} = useDatabase();
const send = useSender();
const context = useContext(DialogContext);
if(!context) {
throw new Error("useDialog must be used within a DialogProvider");
}
const {loading,
messages,
prepareAttachmentsToSend,
clearDialogCache,
setMessages,
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
const {updateDialog} = useDialogsList();
const publicKey = usePublicKey();
const privateKey = usePrivateKeyHash();
const privatePlain = usePrivatePlain();
const {writeFile} = useFileStorage();
const protocolState = useProtocolState();
const {hasGroup, getGroupKey} = useGroups();
const {warn} = useConsoleLogger('useDialog');
/**
* Отправка сообщения в диалог
* @param message Сообщение
* @param attachemnts Вложения
*/
const sendMessage = async (message: string, attachemnts : Attachment[]) => {
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'));
setMessages((prev : Message[]) => ([...prev, {
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: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
message_id: messageId,
attachments: attachemnts
}]));
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 : (
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog);
if(publicKey == ""
|| dialog == ""
|| publicKey == dialog) {
return;
}
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
console.info("Sending key for message ", key.toString('hex'));
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(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;
}
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);
}
const deleteMessages = async () => {
if(!hasGroup(dialog)){
await runQuery(`
DELETE FROM messages WHERE ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?)) AND account = ?
`, [dialog, publicKey, publicKey, dialog, publicKey]);
}else{
await runQuery(`
DELETE FROM messages WHERE to_public_key = ? AND account = ?
`, [dialog, publicKey]);
}
setMessages([]);
updateDialog(dialog);
clearDialogCache();
}
const deleteMessageById = async (messageId: string) => {
await runQuery(`
DELETE FROM messages WHERE message_id = ? AND account = ?
`, [messageId, publicKey]);
setMessages((prev) => prev.filter((msg) => msg.message_id !== messageId));
updateDialog(dialog);
}
const deleteSelectedMessages = async (messageIds: string[]) => {
if(messageIds.length == 0){
return;
}
/**
* Old messages support, ignore empty IDs
* @since 0.1.7 all messages have IDs
*/
let idsNotEmpty = messageIds.filter(v => v.trim() != "");
if(idsNotEmpty.length == 0){
return;
}
const placeholders = idsNotEmpty.map(() => '?').join(',');
await runQuery(`
DELETE FROM messages WHERE message_id IN (` +placeholders+ `) AND account = ?
`, [...idsNotEmpty, publicKey]);
setMessages((prev) => prev.filter((msg) => !messageIds.includes(msg.message_id)));
updateDialog(dialog);
}
const updateAttachmentsInMessagesByAttachmentId = async (attachmentId: string, blob: string) => {
setMessages((prevMessages) => {
return prevMessages.map((msg) => {
let updated = false;
const updatedAttachments = msg.attachments.map((attachment) => {
if (attachment.id === attachmentId) {
updated = true;
return {
...attachment,
blob: blob
};
}
return attachment;
});
if (updated) {
return {
...msg,
attachments: updatedAttachments,
};
}
return msg;
});
});
}
return {
messages,
sendMessage, updateAttachmentsInMessagesByAttachmentId, deleteMessages, loadMessagesToTop, loadMessagesToMessageId, deleteMessageById, loading, deleteSelectedMessages,
dialog,
};
}

View File

@@ -0,0 +1,499 @@
import { useContext, useEffect } from "react";
import { Attachment, AttachmentType, PacketMessage } from "../ProtocolProvider/protocol/packets/packet.message";
import { usePacket } from "../ProtocolProvider/usePacket";
import { BlacklistContext } from "../BlacklistProvider/BlacklistProvider";
import { useLogger } from "@/app/hooks/useLogger";
import { useMemory } from "../MemoryProvider/useMemory";
import { useIdle } from "@mantine/hooks";
import { useNotification } from "@/app/hooks/useNotification";
import { useWindowFocus } from "@/app/hooks/useWindowFocus";
import { MESSAGE_MAX_LOADED, TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD } from "@/app/constants";
import { useDialogsCache } from "./useDialogsCache";
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { chacha20Decrypt, decodeWithPassword, decrypt, encodeWithPassword, generateMd5 } from "@/app/crypto/crypto";
import { DeliveredMessageState, Message } from "./DialogProvider";
import { PacketRead } from "../ProtocolProvider/protocol/packets/packet.read";
import { PacketDelivery } from "../ProtocolProvider/protocol/packets/packet.delivery";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { useViewPanelsState, ViewPanelsState } from "@/app/hooks/useViewPanelsState";
import { generateRandomKeyFormSeed } from "@/app/utils/utils";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useGroups } from "./useGroups";
import { useDialogState } from "../DialogStateProvider.tsx/useDialogState";
import { useUserInformation } from "../InformationProvider/useUserInformation";
import { useMentions } from "../DialogStateProvider.tsx/useMentions";
/**
* При вызове будет запущен "фоновый" обработчик
* входящих пакетов сообщений, который будет обрабатывать их и сохранять
* в базу данных в кэше или в базе данных
*/
export function useDialogFiber() {
const { blocked } = useContext(BlacklistContext);
const { runQuery } = useDatabase();
const privatePlain = usePrivatePlain();
const publicKey = usePublicKey();
const log = useLogger('useDialogFiber');
const [currentDialogPublicKeyView, _] = useMemory("current-dialog-public-key-view", "", true);
const idle = useIdle(TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD * 1000);
const notify = useNotification();
const focused = useWindowFocus();
const { getDialogCache, addOrUpdateDialogCache } = useDialogsCache();
const {info, error} = useConsoleLogger('useDialogFiber');
const [viewState] = useViewPanelsState();
const {writeFile} = useFileStorage();
const {updateDialog} = useDialogsList();
const {hasGroup, getGroupKey, normalize} = useGroups();
const {muted} = useDialogState();
const [userInfo] = useUserInformation(publicKey);
const {pushMention} = useMentions();
/**
* Лог
*/
useEffect(() => {
info("Starting passive fiber for dialog packets");
}, []);
/**
* Нам приходят сообщения от себя самих же при синхронизации
* нужно обрабатывать их особым образом соотвественно
*/
usePacket(0x06, async (packet: PacketMessage) => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
const aesChachaKey = packet.getAesChachaKey();
const content = packet.getContent();
const timestamp = packet.getTimestamp();
const messageId = packet.getMessageId();
if(fromPublicKey != publicKey){
/**
* Игнорируем если это не сообщение от нас
*/
return;
}
const chachaDecryptedKey = Buffer.from(await decodeWithPassword(privatePlain, aesChachaKey), "binary");
const key = chachaDecryptedKey.slice(0, 32);
const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
let attachmentsMeta: any[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
log("Attachment received id " + attachment.id + " type " + attachment.type);
let nextLength = messageAttachments.push({
...attachment,
blob: ""
});
if(attachment.type == AttachmentType.MESSAGES){
/**
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
}
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
});
}
const newMessage: Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: messageAttachments
};
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [fromPublicKey,
toPublicKey,
content,
timestamp,
(currentDialogPublicKeyView == fromPublicKey && !idle && viewState != ViewPanelsState.DIALOGS_PANEL_ONLY) ? 1 : 0,
'',
0,
await encodeWithPassword(privatePlain, decryptedContent),
publicKey,
messageId,
DeliveredMessageState.DELIVERED,
JSON.stringify(attachmentsMeta)]);
updateDialog(fromPublicKey);
});
/**
* Обработчик сообщений для группы
*/
usePacket(0x06, async (packet: PacketMessage) => {
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
const content = packet.getContent();
const timestamp = packet.getTimestamp();
const messageId = packet.getMessageId();
if(!hasGroup(toPublicKey)){
/**
* Если это личное сообщение, то игнорируем его здесь
* для него есть отдельный слушатель usePacket (снизу)
*/
return;
}
if(fromPublicKey == publicKey){
/**
* Игнорируем свои же сообщения,
* такое получается при пакете синхронизации
*/
return;
}
const groupKey = await getGroupKey(toPublicKey);
if(!groupKey){
log("Group key not found for group " + toPublicKey);
error("Message dropped because group key not found for group " + toPublicKey);
return;
}
info("New group message packet received from " + fromPublicKey);
let decryptedContent = '';
try{
decryptedContent = await decodeWithPassword(groupKey, content);
}catch(e) {
decryptedContent = '';
}
let attachmentsMeta: any[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
log("Attachment received id " + attachment.id + " type " + attachment.type);
let nextLength = messageAttachments.push({
...attachment,
blob: ""
});
if(attachment.type == AttachmentType.MESSAGES){
/**
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(groupKey, attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
}
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
});
}
const newMessage: Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: groupKey,
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: decryptedContent,
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: messageAttachments
};
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [fromPublicKey,
toPublicKey,
content,
timestamp,
/**если текущий открытый диалог == беседе (которая приходит в toPublicKey) */
(currentDialogPublicKeyView == toPublicKey && !idle && viewState != ViewPanelsState.DIALOGS_PANEL_ONLY) ? 1 : 0,
'',
0,
await encodeWithPassword(privatePlain, decryptedContent),
publicKey,
messageId,
DeliveredMessageState.DELIVERED,
JSON.stringify(attachmentsMeta)]);
/**
* Так как у нас в toPublicKey приходит ID группы,
* то обновляем диалог по этому ID, а не по fromPublicKey
* как это сделано в личных сообщениях
*/
updateDialog(toPublicKey);
if (((normalize(currentDialogPublicKeyView) !== normalize(toPublicKey) || viewState == ViewPanelsState.DIALOGS_PANEL_ONLY) &&
(timestamp + TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD) > (Date.now() / 1000)) || !focused) {
/**
* Условие со временем нужно для того,
* чтобы когда приходит пачка сообщений с сервера в момент того как
* пользователь был неактивен, не слать уведомления по всем этим сообщениям
*/
let mentionFlag = false;
if((newMessage.from_public_key != publicKey) && (decryptedContent.includes(`@${userInfo.username}`) || decryptedContent.includes(`@all`))){
/**
* Если в сообщении есть упоминание текущего пользователя или @all,
* при этом сообщение отправляли не мы,
* то добавляем упоминание в состояние диалога.
*
* TODO: сделать чтобы all работал только для админов группы
*/
mentionFlag = true;
}
if(!muted.includes(toPublicKey) || mentionFlag){
/**
* Если группа не в мутие или есть упоминание - отправляем уведомление
*/
notify("New message", "You have a new message");
}
if(mentionFlag){
/**
* Если в сообщении есть упоминание текущего пользователя или @all,
* то добавляем упоминание в состояние диалога
*
* TODO: сделать чтобы all работал только для админов группы
*/
pushMention({
dialog_id: toPublicKey,
message_id: messageId
});
}
}
let dialogCache = getDialogCache(toPublicKey);
if (currentDialogPublicKeyView !== toPublicKey && dialogCache.length > 0) {
addOrUpdateDialogCache(toPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
}
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]);
/**
* Обработчик личных сообщений
*/
usePacket(0x06, async (packet: PacketMessage) => {
const fromPublicKey = packet.getFromPublicKey();
if(fromPublicKey == publicKey){
/**
* Игнорируем свои же сообщения,
* такое получается при пакете синхронизации
*/
return;
}
const toPublicKey = packet.getToPublicKey();
const content = packet.getContent();
const chachaKey = packet.getChachaKey();
const timestamp = packet.getTimestamp();
const messageId = generateRandomKeyFormSeed(16, fromPublicKey + toPublicKey + timestamp.toString());
if(hasGroup(toPublicKey)){
/**
* Если это групповое сообщение, то игнорируем его здесь
* для него есть отдельный слушатель usePacket
*/
return;
}
info("New message packet received from " + fromPublicKey);
if (blocked.includes(fromPublicKey)) {
/**
* Если пользователь заблокирован и это не групповое сообщение,
* то игнорируем сообщение
*/
log("Message from blocked user, ignore " + fromPublicKey);
return;
}
if (privatePlain == "") {
return;
}
const chachaDecryptedKey = Buffer.from(await decrypt(chachaKey, privatePlain), "binary");
const key = chachaDecryptedKey.slice(0, 32);
const nonce = chachaDecryptedKey.slice(32);
const decryptedContent = await chacha20Decrypt(content, nonce.toString('hex'), key.toString('hex'));
let attachmentsMeta: any[] = [];
let messageAttachments: Attachment[] = [];
for (let i = 0; i < packet.getAttachments().length; i++) {
const attachment = packet.getAttachments()[i];
log("Attachment received id " + attachment.id + " type " + attachment.type);
let nextLength = messageAttachments.push({
...attachment,
blob: ""
});
if(attachment.type == AttachmentType.MESSAGES){
/**
* Этот тип вложения приходит сразу в blob и не нуждается
* в последующем скачивании
*/
const decryptedBlob = await decodeWithPassword(chachaDecryptedKey.toString('utf-8'), attachment.blob);
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
Buffer.from(await encodeWithPassword(privatePlain, decryptedBlob)).toString('binary'));
messageAttachments[nextLength - 1].blob = decryptedBlob;
}
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
});
}
const newMessage: Message = {
from_public_key: fromPublicKey,
to_public_key: toPublicKey,
content: content,
timestamp: timestamp,
readed: idle ? 0 : 1,
chacha_key: chachaDecryptedKey.toString('utf-8'),
from_me: fromPublicKey == publicKey ? 1 : 0,
plain_message: (decryptedContent as string),
delivered: DeliveredMessageState.DELIVERED,
message_id: messageId,
attachments: messageAttachments
};
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [fromPublicKey,
toPublicKey,
content,
timestamp,
(currentDialogPublicKeyView == fromPublicKey && !idle && viewState != ViewPanelsState.DIALOGS_PANEL_ONLY) ? 1 : 0,
chachaKey,
0,
await encodeWithPassword(privatePlain, decryptedContent),
publicKey,
messageId,
DeliveredMessageState.DELIVERED,
JSON.stringify(attachmentsMeta)]);
log("New message received from " + fromPublicKey);
updateDialog(fromPublicKey);
if (((currentDialogPublicKeyView !== fromPublicKey || viewState == ViewPanelsState.DIALOGS_PANEL_ONLY) &&
(timestamp + TIME_TO_INACTIVE_FOR_MESSAGES_UNREAD) > (Date.now() / 1000)) || !focused) {
/**
* Условие со временем нужно для того,
* чтобы когда приходит пачка сообщений с сервера в момент того как
* пользователь был неактивен, не слать уведомления по всем этим сообщениям
*/
if(!muted.includes(fromPublicKey)){
/**
* Если пользователь в муте - не отправляем уведомление
*/
notify("New message", "You have a new message");
}
}
let dialogCache = getDialogCache(fromPublicKey);
if (currentDialogPublicKeyView !== fromPublicKey && dialogCache.length > 0) {
addOrUpdateDialogCache(fromPublicKey, [...dialogCache, newMessage].slice(-MESSAGE_MAX_LOADED));
}
}, [blocked, muted, updateDialog, focused, currentDialogPublicKeyView, viewState, idle]);
/**
* Обработчик прочтения личных сообщений
*/
usePacket(0x07, async (packet: PacketRead) => {
if(hasGroup(packet.getToPublicKey())){
/**
* Если это относится к группам, то игнорируем здесь,
* для этого есть отдельный слушатель usePacket ниже
*/
return;
}
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
await runQuery(`UPDATE messages SET read = 1 WHERE from_public_key = ? AND to_public_key = ? AND account = ?`, [toPublicKey, fromPublicKey, publicKey]);
updateDialog(fromPublicKey);
log("Read packet received from " + fromPublicKey + " for " + toPublicKey);
addOrUpdateDialogCache(fromPublicKey, getDialogCache(fromPublicKey).map((message) => {
if (message.from_public_key == toPublicKey && !message.readed) {
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
console.info({fromPublicKey, toPublicKey});
return {
...message,
readed: 1
}
}
return message;
}));
}, [updateDialog]);
/**
* Обработчик прочтения групповых сообщений
*/
usePacket(0x07, async (packet: PacketRead) => {
if(!hasGroup(packet.getToPublicKey())){
/**
* Если это не относится к группам, то игнорируем здесь,
* для этого есть отдельный слушатель usePacket выше
*/
return;
}
const fromPublicKey = packet.getFromPublicKey();
const toPublicKey = packet.getToPublicKey();
await runQuery(`UPDATE messages SET read = 1 WHERE to_public_key = ? AND from_public_key = ? AND account = ?`, [toPublicKey, publicKey, publicKey]);
updateDialog(toPublicKey);
addOrUpdateDialogCache(toPublicKey, getDialogCache(toPublicKey).map((message) => {
if (!message.readed) {
console.info("Marking message as read in cache for dialog with " + fromPublicKey);
console.info({fromPublicKey, toPublicKey});
return {
...message,
readed: 1
}
}
return message;
}));
}, [updateDialog]);
/**
* Обработчик доставки сообщений
*/
usePacket(0x08, async (packet: PacketDelivery) => {
const messageId = packet.getMessageId();
await runQuery(`UPDATE messages SET delivered = ?, timestamp = ? WHERE message_id = ? AND account = ?`, [DeliveredMessageState.DELIVERED, Date.now(), messageId, publicKey]);
updateDialog(packet.getToPublicKey());
log("Delivery packet received msg id " + messageId);
addOrUpdateDialogCache(packet.getToPublicKey(), getDialogCache(packet.getToPublicKey()).map((message) => {
if (message.message_id == messageId) {
return {
...message,
delivered: DeliveredMessageState.DELIVERED,
timestamp: Date.now()
}
}
return message;
}));
}, [updateDialog]);
}

View File

@@ -0,0 +1,69 @@
import { useMemory } from "../MemoryProvider/useMemory";
import { Message } from "./DialogProvider";
export interface DialogCache {
publicKey: string;
messages: Message[];
}
export function useDialogsCache() {
const [dialogsCache, setDialogsCache] = useMemory<DialogCache[]>("dialogs-cache", [], true);
const getDialogCache = (publicKey: string) => {
const found = dialogsCache.find((cache) => cache.publicKey == publicKey);
if(!found){
return [];
}
return found.messages;
}
const addOrUpdateDialogCache = (publicKey: string, messages: Message[]) => {
const existingIndex = dialogsCache.findIndex((cache) => cache.publicKey == publicKey);
let newCache = [...dialogsCache];
if(existingIndex !== -1){
newCache[existingIndex].messages = messages;
}else{
newCache.push({publicKey, messages});
}
setDialogsCache(newCache);
}
const updateAttachmentInDialogCache = (attachment_id: string, blob: string) => {
/**
* TODO: Optimize this function to avoid full map if possible
*/
let newCache = dialogsCache.map((cache) => {
let newMessages = cache.messages.map((message) => {
if(message.attachments){
let newAttachments = message.attachments.map((attachment) => {
if(attachment.id == attachment_id){
return {
...attachment,
blob: blob
}
}
return attachment;
});
return {
...message,
attachments: newAttachments
}
}
return message;
});
return {
...cache,
messages: newMessages
}
});
setDialogsCache(newCache);
}
return {
getDialogCache,
addOrUpdateDialogCache,
dialogsCache,
updateAttachmentInDialogCache,
setDialogsCache
}
}

View File

@@ -0,0 +1,32 @@
import { useMemory } from "../MemoryProvider/useMemory";
export interface Draft {
dialog: string;
message: string;
}
export function useDrafts(dialog: string) {
const [drafts, setDrafts] = useMemory<Draft[]>("drafts", [], true);
const getDraft = (): string => {
const draft = drafts.find(d => d.dialog === dialog);
return draft ? draft.message : "";
};
const saveDraft = (message: string) => {
setDrafts(prevDrafts => {
const otherDrafts = prevDrafts.filter(d => d.dialog !== dialog);
return [...otherDrafts, { dialog, message }];
});
};
const deleteDraft = () => {
setDrafts(prevDrafts => prevDrafts.filter(d => d.dialog !== dialog));
};
return {
getDraft,
saveDraft,
deleteDraft,
};
}

View File

@@ -0,0 +1,57 @@
import { useEffect } from "react";
import { GroupStatus, PacketGroupInviteInfo } from "../ProtocolProvider/protocol/packets/packet.group.invite.info";
import { useSender } from "../ProtocolProvider/useSender";
import { usePacket } from "../ProtocolProvider/usePacket";
import { useMemory } from "../MemoryProvider/useMemory";
export function useGroupInviteStatus(groupId: string) : {
inviteStatus: GroupStatus;
setInviteStatus: (status: GroupStatus) => void;
setInviteStatusByGroupId: (groupIdParam: string, status: GroupStatus) => void;
} {
const [invitesCache, setInvitesCache] = useMemory("groups_invites_cache", [], true);
const send = useSender();
useEffect(() => {
(async () => {
if(groupId == ''){
return;
}
const packet = new PacketGroupInviteInfo();
packet.setGroupId(groupId);
send(packet);
})();
}, [groupId]);
usePacket(0x13, (packet: PacketGroupInviteInfo) => {
if(packet.getGroupId() != groupId){
return;
}
setInvitesCache((prev) => ({
...prev,
[groupId]: packet.getGroupStatus(),
}));
}, [groupId]);
const setInviteStatus = (status: GroupStatus) => {
setInvitesCache((prev) => ({
...prev,
[groupId]: status,
}));
}
const setInviteStatusByGroupId = (groupIdParam: string, status: GroupStatus) => {
setInvitesCache((prev) => ({
...prev,
[groupIdParam]: status,
}));
}
return {
inviteStatus: invitesCache[groupId] ?? GroupStatus.NOT_JOINED,
setInviteStatus,
setInviteStatusByGroupId,
};
}

View File

@@ -0,0 +1,278 @@
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { decodeWithPassword, encodeWithPassword } from "@/app/crypto/crypto";
import { generateRandomKey } from "@/app/utils/utils";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { DeliveredMessageState } from "./DialogProvider";
import { useSender } from "../ProtocolProvider/useSender";
import { useState } from "react";
import { PacketCreateGroup } from "../ProtocolProvider/protocol/packets/packet.create.group";
import { useProtocol } from "../ProtocolProvider/useProtocol";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
import { GroupStatus, PacketGroupJoin } from "../ProtocolProvider/protocol/packets/packet.group.join";
import { useGroupInviteStatus } from "./useGroupInviteStatus";
import { useNavigate } from "react-router-dom";
import { useUpdateGroupInformation } from "../InformationProvider/useUpdateGroupInformation";
import { PacketGroupLeave } from "../ProtocolProvider/protocol/packets/packet.group.leave";
import { PacketGroupBan } from "../ProtocolProvider/protocol/packets/packet.group.ban";
export function useGroups() : {
/**
* Получаем ключ шифрования из базы данных по ид группы
* @param groupId ид группы
* @returns ключ шифрования
*/
getGroupKey: (groupId: string) => Promise<string>;
/**
* Получает строку для приглашения в группу
* @param groupId ид группы
* @param title заголовок
* @param encryptKey ключ шифрования
* @param description описание
* @returns строка, которая нужна для приглашения в группу
*/
constructGroupString: (groupId: string, title: string, encryptKey: string, description?: string) => Promise<string>;
/**
* Функция, обратная constructGroupString, парсит строку приглашения в группу
* @param groupString строка приглашения в группу
* @returns объект с информацией о группе или null, если строка некорректна
*/
parseGroupString: (groupString: string) => Promise<{
groupId: string;
title: string;
encryptKey: string;
description: string;
} | null>;
/**
* Проверяет, является ли диалог группой
* @param dialog ид диалога
* @returns вернет true, если это группа и false если это пользователь
*/
hasGroup: (dialog: string) => boolean;
/**
* Возвращает подготовленный для роута groupId
* @param groupId подготавливает groupId для роута
* @returns заменяет символы которые может не обрабатывать роутер
*/
prepareForRoute: (groupId: string) => string;
/**
* Создает группу
* @param title заголовок
* @param description описание
* @returns
*/
createGroup: (title: string, description: string) => Promise<void>;
/**
* Зайдет в группу по строке приглашения
* @param groupString строка приглашение
* @returns
*/
joinGroup: (groupString: string) => Promise<void>;
/**
* Покидает группу
* @param groupId ид группы
* @returns
*/
leaveGroup: (groupId: string) => Promise<void>;
/**
*
* @param str
* @returns
*/
normalize: (str: string) => string;
banUserOnGroup: (userPublicKey: string, groupId: string) => void;
getPrefix: () => string;
loading: boolean;
} {
const {allQuery, runQuery} = useDatabase();
const privatePlain = usePrivatePlain();
const {updateDialog} = useDialogsList();
const publicKey = usePublicKey();
const [loading, setLoading] = useState<boolean>(false);
const send = useSender();
const {protocol} = useProtocol();
const {info} = useConsoleLogger('useGroups');
const {setInviteStatusByGroupId} = useGroupInviteStatus('');
const navigate = useNavigate();
const updateGroupInformation = useUpdateGroupInformation();
const constructGroupString = async (groupId: string, title: string, encryptKey: string, description?: string) => {
let groupString = `${groupId}:${title}:${encryptKey}`;
if (description && description.trim().length > 0) {
groupString += `:${description}`;
}
let encodedPayload = await encodeWithPassword('rosetta_group', groupString);
return `#group:${encodedPayload}`;
}
const hasGroup = (dialog: string) => {
return dialog.startsWith('#group:');
}
const getPrefix = () => {
return '#group:';
}
const parseGroupString = async (groupString: string) => {
try{
if (!groupString.startsWith('#group:')) {
return null;
}
let encodedPayload = groupString.substring(7);
let decodedPayload = await decodeWithPassword('rosetta_group', encodedPayload);
let parts = decodedPayload.split(':');
return {
groupId: parts[0],
title: parts[1],
encryptKey: parts[2],
description: parts[3] || ''
}
}catch(e) {
return null;
}
}
const getGroupKey = async (groupId: string) => {
const query = `SELECT key FROM groups WHERE group_id = ? AND account = ? LIMIT 1`;
const result = await allQuery(query, [normalize(groupId), publicKey]);
if(result.length > 0) {
let key = result[0].key;
return await decodeWithPassword(privatePlain, key);
}
return "";
};
const prepareForRoute = (groupId: string) => {
return `#group:${groupId}`.replace('#', '%23');
}
const normalize = (str: string) => {
return str.replace('#group:', '').trim();
}
const createGroup = async (title: string, description: string) => {
if(title.trim().length === 0){
return;
}
setLoading(true);
const packet = new PacketCreateGroup();
send(packet);
protocol.waitPacketOnce(0x11, async (packet : PacketCreateGroup) => {
const groupId = packet.getGroupId();
info(`Creating group with id ${groupId}`);
const encryptKey = generateRandomKey(64);
const secureKey = await encodeWithPassword(privatePlain, encryptKey);
let content = await encodeWithPassword(encryptKey, `$a=Group created`);
let plainMessage = await encodeWithPassword(privatePlain, `$a=Group created`);
await runQuery(`
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
`, [publicKey, groupId, title, description, secureKey]);
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, "#group:" + groupId, content, Date.now(), 1, "", 1, plainMessage, publicKey, generateRandomKey(16),
DeliveredMessageState.DELIVERED
, '[]']);
updateDialog("#group:" + groupId);
updateGroupInformation({
groupId: groupId,
title: title,
description: description
});
setLoading(false);
navigate(`/main/chat/${prepareForRoute(groupId)}`);
});
}
const banUserOnGroup = (userPublicKey: string, groupId: string) => {
const packet = new PacketGroupBan();
packet.setGroupId(groupId);
packet.setPublicKey(userPublicKey);
send(packet);
}
const joinGroup = async (groupString: string) => {
const parsed = await parseGroupString(groupString);
if (!parsed) {
return;
}
const encryptKey = parsed.encryptKey;
const groupId = parsed.groupId;
const title = parsed.title;
const description = parsed.description;
const packet = new PacketGroupJoin();
packet.setGroupId(parsed.groupId);
send(packet);
setLoading(true);
protocol.waitPacketOnce(0x14, async (packet: PacketGroupJoin) => {
console.info(`Received group join response for group ${parsed.groupId}`);
const groupStatus = packet.getGroupStatus();
if(groupStatus != GroupStatus.JOINED){
info(`Cannot join group ${parsed.groupId}, banned`);
setInviteStatusByGroupId(parsed.groupId, groupStatus);
setLoading(false);
return;
}
const secureKey = await encodeWithPassword(privatePlain, encryptKey);
let content = await encodeWithPassword(encryptKey, `$a=Group joined`);
let plainMessage = await encodeWithPassword(privatePlain, `$a=Group joined`);
await runQuery(`
INSERT INTO groups (account, group_id, title, description, key) VALUES (?, ?, ?, ?, ?)
`, [publicKey, groupId, title, description, secureKey]);
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, "#group:" + groupId, content, Date.now(), 1, "", 1, plainMessage, publicKey, generateRandomKey(16),
DeliveredMessageState.DELIVERED
, '[]']);
updateDialog("#group:" + groupId);
setInviteStatusByGroupId(groupId, GroupStatus.JOINED);
setLoading(false);
updateGroupInformation({
groupId: groupId,
title: title,
description: description
});
navigate(`/main/chat/${prepareForRoute(groupId)}`);
});
}
const leaveGroup = async (groupId: string) => {
const packet = new PacketGroupLeave();
packet.setGroupId(groupId);
send(packet);
setLoading(true);
protocol.waitPacketOnce(0x15, async (packet: PacketGroupLeave) => {
if(packet.getGroupId() != groupId){
return;
}
await runQuery(`
DELETE FROM groups WHERE group_id = ? AND account = ?
`, [groupId, publicKey]);
await runQuery(`
DELETE FROM messages WHERE to_public_key = ? AND account = ?
`, ["#group:" + normalize(groupId), publicKey]);
updateDialog("#group:" + normalize(groupId));
setLoading(false);
navigate(`/main`);
});
}
return {
getGroupKey,
constructGroupString,
parseGroupString,
hasGroup,
prepareForRoute,
createGroup,
joinGroup,
leaveGroup,
getPrefix,
banUserOnGroup,
normalize,
loading
}
}

View File

@@ -0,0 +1,128 @@
import { useContext, useEffect } from "react";
import { useMemory } from "../MemoryProvider/useMemory";
import { Attachment } from "../ProtocolProvider/protocol/packets/packet.message";
import { DialogContext } from "./DialogProvider";
export interface Reply {
publicKey: string;
messages: MessageReply[];
/**
* Флаг, указывающи, что выбранные сообщения уже перемещены в
* поле ввода диалога
*/
inDialogInput?: string;
}
export interface MessageReply {
timestamp: number;
publicKey: string;
message: string;
attachments: Attachment[];
message_id: string;
}
export function useReplyMessages() {
const [replyMessages, setReplyMessages] = useMemory<Reply>("replyMessages", {
publicKey: "",
messages: [],
inDialogInput: ""
}, true);
const context = useContext(DialogContext);
if(!context){
throw new Error("useReplyMessages must be used within a DialogProvider");
}
const {dialog} = context;
const selectMessage = (message : MessageReply) => {
console.info(message);
if(replyMessages.publicKey != dialog){
/**
* Сброс выбора сообщений из другого диалога
*/
setReplyMessages({
publicKey: dialog,
messages: [message]
});
return;
}
if(replyMessages.messages.find(m => m.timestamp == message.timestamp)){
/**
* Уже выбранное сообщение
*/
return;
}
replyMessages.messages.push(message);
const sortedByTime = replyMessages.messages.sort((a, b) => a.timestamp - b.timestamp);
setReplyMessages({
publicKey: dialog,
messages: sortedByTime
});
}
const deselectMessage = (message : MessageReply) => {
const filtered = replyMessages.messages.filter(m => m.timestamp != message.timestamp);
setReplyMessages({
publicKey: dialog,
messages: filtered
});
}
const deselectAllMessages = () => {
setReplyMessages({
publicKey: "",
messages: []
});
}
const isSelectionStarted = () => {
if(replyMessages.inDialogInput){
return false;
}
return replyMessages.publicKey == dialog && replyMessages.messages.length > 0;
}
const isSelectionInCurrentDialog = () => {
if(replyMessages.inDialogInput){
return false;
}
return replyMessages.publicKey == dialog;
}
const isMessageSelected = (message : MessageReply) => {
if(replyMessages.publicKey != dialog ||
replyMessages.inDialogInput
){
return false;
}
return replyMessages.messages.find(m => m.timestamp == message.timestamp) != undefined;
}
const translateMessagesToDialogInput = (publicKey: string) => {
setReplyMessages((prev) => ({
...prev,
inDialogInput: publicKey
}));
}
useEffect(() => {
if(replyMessages.publicKey != dialog
&& replyMessages.inDialogInput != dialog){
/**
* Сброс выбора сообщений при смене диалога
*/
deselectAllMessages();
}
}, [dialog]);
return {replyMessages,
translateMessagesToDialogInput,
isSelectionInCurrentDialog,
isSelectionStarted,
selectMessage,
deselectMessage,
dialog,
deselectAllMessages,
isMessageSelected}
}