'init'
This commit is contained in:
836
app/providers/DialogProvider/DialogProvider.tsx
Normal file
836
app/providers/DialogProvider/DialogProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
app/providers/DialogProvider/useDialog.ts
Normal file
242
app/providers/DialogProvider/useDialog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
499
app/providers/DialogProvider/useDialogFiber.ts
Normal file
499
app/providers/DialogProvider/useDialogFiber.ts
Normal 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]);
|
||||
}
|
||||
69
app/providers/DialogProvider/useDialogsCache.ts
Normal file
69
app/providers/DialogProvider/useDialogsCache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
32
app/providers/DialogProvider/useDrafts.ts
Normal file
32
app/providers/DialogProvider/useDrafts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
57
app/providers/DialogProvider/useGroupInviteStatus.ts
Normal file
57
app/providers/DialogProvider/useGroupInviteStatus.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
278
app/providers/DialogProvider/useGroups.ts
Normal file
278
app/providers/DialogProvider/useGroups.ts
Normal 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
|
||||
}
|
||||
}
|
||||
128
app/providers/DialogProvider/useReplyMessages.ts
Normal file
128
app/providers/DialogProvider/useReplyMessages.ts
Normal 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}
|
||||
}
|
||||
Reference in New Issue
Block a user