'init'
This commit is contained in:
BIN
app/providers/.DS_Store
vendored
Normal file
BIN
app/providers/.DS_Store
vendored
Normal file
Binary file not shown.
148
app/providers/AccountProvider/AccountProvider.tsx
Normal file
148
app/providers/AccountProvider/AccountProvider.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
export interface AccountBase {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
seedPhraseEncrypted: string;
|
||||
}
|
||||
|
||||
export interface Account extends AccountBase {
|
||||
privatePlain: string;
|
||||
privateHash: string;
|
||||
}
|
||||
|
||||
export interface AccountContextValue {
|
||||
allAccounts: AccountBase[];
|
||||
loginedAccount: Account;
|
||||
loginAccount: (account : Account) => void;
|
||||
createAccount: (account : Account) => void;
|
||||
loginDiceAccount: AccountBase;
|
||||
accountProviderLoaded: boolean;
|
||||
removeAccountFromLoginDice: () => void;
|
||||
removeAccountsFromArrayOfAccounts: (accountBase : AccountBase) => void;
|
||||
selectAccountToLoginDice: (accountBase : AccountBase) => void;
|
||||
setAccounts: (accounts: AccountBase[]) => void;
|
||||
}
|
||||
|
||||
export const AccountContext = createContext<any>({});
|
||||
|
||||
interface AccountProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AccountProvder(props : AccountProviderProps){
|
||||
const {allQuery, runQuery} = useDatabase();
|
||||
const [accounts, setAccounts] = useState<AccountBase[]>([]);
|
||||
const [loginDice, setLoginDice] = useState<AccountBase>({
|
||||
publicKey: "",
|
||||
privateKey: "",
|
||||
seedPhraseEncrypted: ""
|
||||
});
|
||||
const [account, setAccount] = useState<Account>({
|
||||
publicKey: "",
|
||||
privatePlain: "",
|
||||
privateKey: "",
|
||||
privateHash: "",
|
||||
seedPhraseEncrypted: ""
|
||||
});
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const {info} = useConsoleLogger("AccountProvider");
|
||||
|
||||
useEffect(() => {
|
||||
loadAllAccountFromDb();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if(!loaded){
|
||||
return;
|
||||
}
|
||||
loadLastLoginDice();
|
||||
}, [accounts, loaded]);
|
||||
|
||||
const loadLastLoginDice = () => {
|
||||
console.info("2");
|
||||
let publicKey = localStorage.getItem("last_logined_account");
|
||||
if(!publicKey && accounts.length <= 0){
|
||||
console.info("1");
|
||||
return;
|
||||
}
|
||||
if(!publicKey && accounts.length > 0){
|
||||
console.info(accounts);
|
||||
setLoginDice(accounts[0]);
|
||||
return;
|
||||
}
|
||||
for(let i = 0; i < accounts.length; i++){
|
||||
let accountBase = accounts[i];
|
||||
if(accountBase.publicKey == publicKey){
|
||||
setLoginDice(accountBase);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Если last_logined_account плохой - то убираем этот аккаунт
|
||||
*/
|
||||
removeAccountFromLoginDice();
|
||||
}
|
||||
|
||||
const removeAccountsFromArrayOfAccounts = (accountBase : AccountBase) => {
|
||||
setAccounts((prev) => prev.filter((v) => v.publicKey !== accountBase.publicKey));
|
||||
}
|
||||
|
||||
const loadAllAccountFromDb = async () => {
|
||||
const result = await allQuery("SELECT * FROM `accounts`");
|
||||
let resultSet : AccountBase[] = [];
|
||||
for(let i = 0; i < result.length; i++){
|
||||
let acc = result[i];
|
||||
resultSet.push({
|
||||
publicKey: acc.public_key,
|
||||
privateKey: acc.private_key,
|
||||
seedPhraseEncrypted: acc.sfen
|
||||
});
|
||||
}
|
||||
setAccounts(resultSet);
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
const loginAccount = (account : Account) => {
|
||||
info("Logging in account with public key: " + account.publicKey);
|
||||
setAccount(account);
|
||||
}
|
||||
|
||||
const createAccount = (account : Account) => {
|
||||
runQuery("INSERT INTO accounts (public_key, private_key, sfen) VALUES (?, ?, ?)", [account.publicKey, account.privateKey, account.seedPhraseEncrypted]);
|
||||
//maybe set state accounts
|
||||
}
|
||||
|
||||
const selectAccountToLoginDice = (accountBase: AccountBase) => {
|
||||
setLoginDice(accountBase);
|
||||
localStorage.setItem("last_logined_account", accountBase.publicKey);
|
||||
}
|
||||
|
||||
const removeAccountFromLoginDice = () => {
|
||||
setLoginDice({
|
||||
publicKey: "",
|
||||
privateKey: "",
|
||||
seedPhraseEncrypted: ""
|
||||
});
|
||||
localStorage.removeItem("last_logined_account");
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountContext.Provider value={{
|
||||
allAccounts: accounts,
|
||||
loginedAccount: account,
|
||||
loginAccount: loginAccount,
|
||||
createAccount: createAccount,
|
||||
loginDiceAccount: loginDice,
|
||||
accountProviderLoaded: loaded,
|
||||
removeAccountsFromArrayOfAccounts: removeAccountsFromArrayOfAccounts,
|
||||
removeAccountFromLoginDice: removeAccountFromLoginDice,
|
||||
selectAccountToLoginDice: selectAccountToLoginDice,
|
||||
setAccounts
|
||||
}}>
|
||||
{loaded && (props.children)}
|
||||
</AccountContext.Provider>
|
||||
)
|
||||
}
|
||||
28
app/providers/AccountProvider/useAccount.ts
Normal file
28
app/providers/AccountProvider/useAccount.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useContext } from "react";
|
||||
import { Account, AccountContext, AccountContextValue } from "./AccountProvider";
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
|
||||
export function useAccount() : [
|
||||
Account, () => void
|
||||
] {
|
||||
const {runQuery} = useDatabase();
|
||||
const context : AccountContextValue = useContext(AccountContext);
|
||||
if(!context){
|
||||
throw new Error("useAccount must be used within a AccountProvider");
|
||||
}
|
||||
|
||||
const deleteAccount = () => {
|
||||
runQuery("DELETE FROM `accounts` WHERE `public_key` = ?", [context.loginedAccount.publicKey])
|
||||
context.removeAccountsFromArrayOfAccounts(context.loginedAccount);
|
||||
context.loginAccount({
|
||||
privateHash: "",
|
||||
privatePlain: "",
|
||||
publicKey: "",
|
||||
privateKey: "",
|
||||
seedPhraseEncrypted: ""
|
||||
});
|
||||
localStorage.removeItem("last_logined_account");
|
||||
}
|
||||
|
||||
return [context.loginedAccount, deleteAccount];
|
||||
}
|
||||
11
app/providers/AccountProvider/useAccountProvider.tsx
Normal file
11
app/providers/AccountProvider/useAccountProvider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import { AccountContext, AccountContextValue } from "./AccountProvider";
|
||||
|
||||
export function useAccountProvider() : AccountContextValue {
|
||||
const context : AccountContextValue = useContext(AccountContext);
|
||||
if(!context){
|
||||
throw new Error("useAccountProvider must be used within a AccountProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
25
app/providers/AccountProvider/useLastLoginedAccount.ts
Normal file
25
app/providers/AccountProvider/useLastLoginedAccount.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLastLoginedAccount() : [
|
||||
string,
|
||||
(publicKey: string) => void
|
||||
] {
|
||||
const [lastLoginedAccount, setLastLogginedAccount] =
|
||||
useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let publicKey =
|
||||
localStorage.getItem("last_logined_account");
|
||||
if(!publicKey){
|
||||
return;
|
||||
}
|
||||
setLastLogginedAccount(publicKey);
|
||||
}, []);
|
||||
|
||||
const setLastLogin = (publicKey: string) => {
|
||||
localStorage.setItem("last_logined_account", publicKey);
|
||||
setLastLogginedAccount(publicKey);
|
||||
}
|
||||
|
||||
return [lastLoginedAccount, setLastLogin];
|
||||
}
|
||||
29
app/providers/AccountProvider/useLogout.ts
Normal file
29
app/providers/AccountProvider/useLogout.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useMemoryClean } from "../MemoryProvider/useMemoryClean";
|
||||
import { useAccountProvider } from "./useAccountProvider";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
import { useProtocol } from "../ProtocolProvider/useProtocol";
|
||||
|
||||
export function useLogout() {
|
||||
const {loginAccount} = useAccountProvider();
|
||||
const {info} = useConsoleLogger('useLogout');
|
||||
const memClean = useMemoryClean();
|
||||
const {setDialogs} = useDialogsList();
|
||||
const {protocol} = useProtocol();
|
||||
|
||||
const logout = () => {
|
||||
info("Logging out from account");
|
||||
memClean();
|
||||
loginAccount({
|
||||
publicKey: "",
|
||||
privateKey: "",
|
||||
seedPhraseEncrypted: "",
|
||||
privatePlain: "",
|
||||
privateHash: ""
|
||||
});
|
||||
setDialogs([]);
|
||||
protocol.close();
|
||||
}
|
||||
|
||||
return logout;
|
||||
}
|
||||
13
app/providers/AccountProvider/usePrivateKeyHash.ts
Normal file
13
app/providers/AccountProvider/usePrivateKeyHash.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
import { AccountContext, AccountContextValue } from "./AccountProvider";
|
||||
|
||||
export function usePrivateKeyHash() {
|
||||
const context : AccountContextValue = useContext(AccountContext);
|
||||
if(!context){
|
||||
throw new Error("useAccount must be used within a AccountProvider");
|
||||
}
|
||||
if(!context.loginedAccount){
|
||||
return "";
|
||||
}
|
||||
return context.loginedAccount.privateHash;
|
||||
}
|
||||
19
app/providers/AccountProvider/usePrivatePlain.ts
Normal file
19
app/providers/AccountProvider/usePrivatePlain.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useContext } from "react";
|
||||
import { AccountContext, AccountContextValue } from "./AccountProvider";
|
||||
|
||||
/**
|
||||
* This hook provides access to the private plain text of the logged-in account.
|
||||
* Needs only for decrypting messages or attachments.
|
||||
* Not send to server.
|
||||
* @returns Private plain text for the logged-in account
|
||||
*/
|
||||
export function usePrivatePlain() {
|
||||
const context : AccountContextValue = useContext(AccountContext);
|
||||
if(!context){
|
||||
throw new Error("useAccount must be used within a AccountProvider");
|
||||
}
|
||||
if(!context.loginedAccount){
|
||||
return "";
|
||||
}
|
||||
return context.loginedAccount.privatePlain;
|
||||
}
|
||||
13
app/providers/AccountProvider/usePublicKey.ts
Normal file
13
app/providers/AccountProvider/usePublicKey.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
import { AccountContext, AccountContextValue } from "./AccountProvider";
|
||||
|
||||
export function usePublicKey() {
|
||||
const context : AccountContextValue = useContext(AccountContext);
|
||||
if(!context){
|
||||
throw new Error("useAccount must be used within a AccountProvider");
|
||||
}
|
||||
if(!context.loginedAccount){
|
||||
return "";
|
||||
}
|
||||
return context.loginedAccount.publicKey;
|
||||
}
|
||||
198
app/providers/AttachmentProvider/useAttachment.ts
Normal file
198
app/providers/AttachmentProvider/useAttachment.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useDownloadStatus } from "../TransportProvider/useDownloadStatus";
|
||||
import { useUploadStatus } from "../TransportProvider/useUploadStatus";
|
||||
import { useFileStorage } from "../../hooks/useFileStorage";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { decodeWithPassword, encodeWithPassword, generateMd5 } from "../../crypto/crypto";
|
||||
import { useTransport } from "../TransportProvider/useTransport";
|
||||
import { useDialogsCache } from "../DialogProvider/useDialogsCache";
|
||||
import { useConsoleLogger } from "../../hooks/useConsoleLogger";
|
||||
import { Attachment, AttachmentType } from "../ProtocolProvider/protocol/packets/packet.message";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { DialogContext } from "../DialogProvider/DialogProvider";
|
||||
import { useSaveAvatar } from "../AvatarProvider/useSaveAvatar";
|
||||
import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
|
||||
import { useDialog } from "../DialogProvider/useDialog";
|
||||
|
||||
export enum DownloadStatus {
|
||||
DOWNLOADED,
|
||||
NOT_DOWNLOADED,
|
||||
PENDING,
|
||||
DECRYPTING,
|
||||
DOWNLOADING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
export function useAttachment(attachment: Attachment, keyPlain: string) {
|
||||
const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
const uploadedPercentage = useUploadStatus(attachment.id);
|
||||
const downloadPercentage = useDownloadStatus(attachment.id);
|
||||
const [downloadStatus, setDownloadStatus] = useMemory("attachment-downloaded-status-" + attachment.id, DownloadStatus.PENDING, true);
|
||||
const [downloadTag, setDownloadTag] = useState("");
|
||||
const {readFile, writeFile} = useFileStorage();
|
||||
const { downloadFile } = useTransport();
|
||||
const publicKey = usePublicKey();
|
||||
const privatePlain = usePrivatePlain();
|
||||
const {updateAttachmentInDialogCache} = useDialogsCache();
|
||||
const {info} = useConsoleLogger('useAttachment');
|
||||
const {updateAttachmentsInMessagesByAttachmentId} = useDialog();
|
||||
|
||||
|
||||
const context = useContext(DialogContext);
|
||||
if(!context) {
|
||||
throw new Error("useAttachment must be used within a DialogProvider");
|
||||
}
|
||||
const {dialog} = context;
|
||||
const saveAvatar = useSaveAvatar();
|
||||
|
||||
useEffect(() => {
|
||||
calcDownloadStatus();
|
||||
}, []);
|
||||
|
||||
const getPreview = () => {
|
||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
||||
/**
|
||||
* Это тег загрузки
|
||||
*/
|
||||
return attachment.preview.split("::").splice(1).join("::");
|
||||
}
|
||||
return attachment.preview;
|
||||
}
|
||||
|
||||
const calcDownloadStatus = async () => {
|
||||
if(attachment.preview.split("::")[0].match(uuidRegex)){
|
||||
/**
|
||||
* Это тег загрузки
|
||||
*/
|
||||
setDownloadTag(attachment.preview.split("::")[0]);
|
||||
}
|
||||
if(!attachment.preview.split("::")[0].match(uuidRegex)){
|
||||
/**
|
||||
* Там не тег загрузки, значит это наш файл
|
||||
*/
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
if (downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
return;
|
||||
}
|
||||
if(attachment.type == AttachmentType.FILE){
|
||||
/**
|
||||
* Если это файл, то он хранится не в папке медиа,
|
||||
* а в загрузках
|
||||
*/
|
||||
const preview = getPreview();
|
||||
const filename = preview.split("::")[1];
|
||||
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
const fileData = await readFile(pathInDownloads, false);
|
||||
if(fileData){
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
setDownloadStatus(DownloadStatus.NOT_DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
if(attachment.type == AttachmentType.AVATAR){
|
||||
/**
|
||||
* Если это аватар, то он хранится не в папке медиа,
|
||||
* а в папке аватарок
|
||||
*/
|
||||
const fileData = await readFile(`a/${await generateMd5(attachment.id + publicKey)}`);
|
||||
if(fileData){
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
setDownloadStatus(DownloadStatus.NOT_DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = await readFile(`m/${await generateMd5(attachment.id + publicKey)}`);
|
||||
if(fileData){
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
setDownloadStatus(DownloadStatus.NOT_DOWNLOADED);
|
||||
}
|
||||
|
||||
const getBlob = async () => {
|
||||
if(attachment.blob && attachment.blob != ""){
|
||||
return attachment.blob;
|
||||
}
|
||||
const folder = (attachment.type == AttachmentType.AVATAR) ? "a" : "m";
|
||||
const fileData = await readFile(`${folder}/${await generateMd5(attachment.id + publicKey)}`);
|
||||
if (!fileData) {
|
||||
return "";
|
||||
}
|
||||
const password = (attachment.type == AttachmentType.AVATAR) ? AVATAR_PASSWORD_TO_ENCODE : privatePlain;
|
||||
const decryptedData = await decodeWithPassword(password, Buffer.from(fileData, 'binary').toString());
|
||||
return decryptedData;
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
if(downloadStatus == DownloadStatus.DOWNLOADED){
|
||||
return;
|
||||
}
|
||||
if (downloadTag == "") {
|
||||
return;
|
||||
}
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADING);
|
||||
info("Downloading attachment: " + downloadTag);
|
||||
let downloadedBlob = '';
|
||||
try {
|
||||
downloadedBlob = await downloadFile(attachment.id,
|
||||
downloadTag);
|
||||
} catch (e) {
|
||||
console.info(e);
|
||||
info("Error downloading attachment: " + attachment.id);
|
||||
setDownloadStatus(DownloadStatus.ERROR);
|
||||
return;
|
||||
}
|
||||
setDownloadStatus(DownloadStatus.DECRYPTING);
|
||||
//console.info("Decrypted attachment ", Buffer.from(keyPlain, 'binary').toString('hex'));
|
||||
const decrypted = await decodeWithPassword(keyPlain, downloadedBlob);
|
||||
setDownloadTag("");
|
||||
if(attachment.type == AttachmentType.FILE) {
|
||||
/**
|
||||
* Если это файл то шифрованную копию не пишем,
|
||||
* пишем его сразу в загрузки
|
||||
*/
|
||||
const preview = getPreview();
|
||||
const filename = preview.split("::")[1];
|
||||
let buffer = Buffer.from(decrypted.split(",")[1], 'base64');
|
||||
let pathInDownloads = window.downloadsPath + "/Rosetta Downloads/" + filename;
|
||||
await writeFile(pathInDownloads, buffer, false);
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
return;
|
||||
}
|
||||
if(attachment.type == AttachmentType.AVATAR) {
|
||||
/**
|
||||
* Аватарки, пишем их в папку аватарок
|
||||
*/
|
||||
const avatarPath = `a/${await generateMd5(attachment.id + publicKey)}`;
|
||||
await writeFile(avatarPath,
|
||||
Buffer.from(await encodeWithPassword(AVATAR_PASSWORD_TO_ENCODE, decrypted)));
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
saveAvatar(dialog, avatarPath, decrypted);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Если это не файл, то обновляем состояние кэша,
|
||||
* и пишем шифрованную копию
|
||||
*/
|
||||
updateAttachmentInDialogCache(attachment.id, decrypted);
|
||||
updateAttachmentsInMessagesByAttachmentId(attachment.id, decrypted);
|
||||
await writeFile(`m/${await generateMd5(attachment.id + publicKey)}`,
|
||||
Buffer.from(await encodeWithPassword(privatePlain, decrypted)).toString('binary'));
|
||||
setDownloadStatus(DownloadStatus.DOWNLOADED);
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedPercentage,
|
||||
downloadPercentage,
|
||||
downloadStatus,
|
||||
getPreview,
|
||||
getBlob,
|
||||
download
|
||||
};
|
||||
}
|
||||
223
app/providers/AvatarProvider/AvatarProvider.tsx
Normal file
223
app/providers/AvatarProvider/AvatarProvider.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { createContext, useEffect, useRef, useState } from "react";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { decodeWithPassword, encodeWithPassword, generateMd5 } from "@/app/crypto/crypto";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts";
|
||||
import { AVATAR_PASSWORD_TO_ENCODE } from "@/app/constants";
|
||||
|
||||
export const AvatarContext = createContext({});
|
||||
|
||||
interface AvatarProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface AvatarInformation {
|
||||
avatar: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface AvatarCacheEntry {
|
||||
publicKey: string;
|
||||
avatars: AvatarInformation[];
|
||||
}
|
||||
|
||||
export function AvatarProvider(props : AvatarProviderProps) {
|
||||
const {runQuery, allQuery} = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
const [deliveredAvatars, setDeliveredAvatars] =
|
||||
useState<string[]>([]);
|
||||
const {readFile, writeFile} = useFileStorage();
|
||||
const loadCacheRunningRef = useRef<string[]>([]);
|
||||
const { error } = useConsoleLogger("AvatarProvider");
|
||||
const systemAccounts = useSystemAccounts();
|
||||
/**
|
||||
* Дополнительный кэширующий слой для декодированных аватарок,
|
||||
* чтобы не декодировать их каждый раз из базы данных.
|
||||
*/
|
||||
const [decodedAvatarsCache, setDecodedAvatarsCache] = useState<AvatarCacheEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSystemAvatars();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncAvatarDeliveryWithLocalDb();
|
||||
}, [publicKey]);
|
||||
|
||||
const saveAvatar = async (fromPublicKey: string, path : string, decryptedContent : string) => {
|
||||
const timestamp = Date.now();
|
||||
await runQuery("INSERT INTO `avatar_cache` (public_key, avatar, timestamp) VALUES (?, ?, ?)",
|
||||
[fromPublicKey, path, timestamp]);
|
||||
setDecodedAvatarsCache((prev) => {
|
||||
const existingEntry = prev.find(e => e.publicKey === fromPublicKey);
|
||||
if(existingEntry){
|
||||
return prev.map(e => {
|
||||
if(e.publicKey === fromPublicKey){
|
||||
return {
|
||||
publicKey: fromPublicKey,
|
||||
avatars: [{
|
||||
avatar: decryptedContent,
|
||||
timestamp,
|
||||
}, ...e.avatars]
|
||||
}
|
||||
}
|
||||
return e;
|
||||
});
|
||||
} else {
|
||||
return [...prev, {
|
||||
publicKey: fromPublicKey,
|
||||
avatars: [{
|
||||
avatar: decryptedContent,
|
||||
timestamp,
|
||||
}]
|
||||
}];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const loadSystemAvatars = async () => {
|
||||
let avatarCacheEntrys : AvatarCacheEntry[] = [];
|
||||
for(let i = 0; i < systemAccounts.length; i++){
|
||||
let account = systemAccounts[i];
|
||||
avatarCacheEntrys.push({
|
||||
publicKey: account.publicKey,
|
||||
avatars: [{
|
||||
avatar: account.avatar,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
});
|
||||
}
|
||||
setDecodedAvatarsCache((prev) => {
|
||||
return [...prev, ...avatarCacheEntrys];
|
||||
});
|
||||
}
|
||||
|
||||
const syncAvatarDeliveryWithLocalDb = async () => {
|
||||
const result = await allQuery("SELECT * FROM `avatar_delivery` WHERE account = ?", [publicKey]);
|
||||
for(let i = 0; i < result.length; i++){
|
||||
let publicKey = result[i].public_key;
|
||||
setDeliveredAvatars((prev) => [...prev, publicKey]);
|
||||
}
|
||||
}
|
||||
|
||||
const loadAvatarsFromCacheByPublicKey = async (publicKey : string, allDecode : boolean = true) => {
|
||||
if(loadCacheRunningRef.current.indexOf(publicKey) !== -1){
|
||||
return;
|
||||
}
|
||||
loadCacheRunningRef.current.push(publicKey);
|
||||
const result = await allQuery("SELECT * FROM `avatar_cache` WHERE public_key = ? ORDER BY timestamp DESC", [publicKey]);
|
||||
if(result.length == 0){
|
||||
loadCacheRunningRef.current = loadCacheRunningRef.current.filter(pk => pk !== publicKey);
|
||||
return;
|
||||
}
|
||||
if(
|
||||
decodedAvatarsCache.find(e => e.publicKey === publicKey) &&
|
||||
(decodedAvatarsCache.find(e => e.publicKey === publicKey)?.avatars.length == result.length || !allDecode)
|
||||
){
|
||||
loadCacheRunningRef.current = loadCacheRunningRef.current.filter(pk => pk !== publicKey);
|
||||
return;
|
||||
}
|
||||
let avatars : AvatarInformation[] = [];
|
||||
for(let i = 0; i < result.length; i++){
|
||||
let file = await readFile(result[i].avatar);
|
||||
if(!file){
|
||||
error("Avatar file not found: " + result[i].avatar);
|
||||
await runQuery("DELETE FROM `avatar_cache` WHERE avatar = ?", [result[i].avatar]);
|
||||
continue;
|
||||
}
|
||||
let decodedAvatar = "";
|
||||
try{
|
||||
decodedAvatar = await decodeWithPassword(AVATAR_PASSWORD_TO_ENCODE,
|
||||
Buffer.from(file, 'binary').toString()
|
||||
);
|
||||
}catch(e){
|
||||
error("Failed to decode avatar from file: " + result[i].avatar);
|
||||
await runQuery("DELETE FROM `avatar_cache` WHERE avatar = ?", [result[i].avatar]);
|
||||
continue;
|
||||
}
|
||||
avatars.push({
|
||||
avatar: decodedAvatar,
|
||||
timestamp: result[i].timestamp,
|
||||
});
|
||||
if(!allDecode){
|
||||
break;
|
||||
}
|
||||
}
|
||||
setDecodedAvatarsCache((prev) => {
|
||||
const existingEntry = prev.find(e => e.publicKey === publicKey);
|
||||
if(existingEntry){
|
||||
let nextState = prev.map(e => {
|
||||
if(e.publicKey === publicKey){
|
||||
return {
|
||||
publicKey: publicKey,
|
||||
avatars: avatars
|
||||
}
|
||||
}
|
||||
return e;
|
||||
});
|
||||
return [...nextState];
|
||||
} else {
|
||||
return [...prev, {
|
||||
publicKey: publicKey,
|
||||
avatars: avatars
|
||||
}];
|
||||
}
|
||||
});
|
||||
loadCacheRunningRef.current = loadCacheRunningRef.current.filter(pk => pk !== publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the avatar for a specific entity
|
||||
* @param base64Image The base64 encoded image
|
||||
* @param entity The entity to change the avatar for (groupId or publicKey)
|
||||
*/
|
||||
const changeAvatar = async (base64Image : string, entity : string) => {
|
||||
const timestamp = Date.now();
|
||||
const avatarPath = `a/${await generateMd5(base64Image + entity)}`;
|
||||
const encodedForStorage = await encodeWithPassword(AVATAR_PASSWORD_TO_ENCODE, base64Image);
|
||||
await writeFile(avatarPath, Buffer.from(encodedForStorage, 'binary'));
|
||||
await runQuery("INSERT INTO `avatar_cache` (public_key, avatar, timestamp) VALUES (?, ?, ?)",
|
||||
[entity, avatarPath, timestamp]);
|
||||
|
||||
setDecodedAvatarsCache((prev) => {
|
||||
const existingEntry = prev.find(e => e.publicKey === entity);
|
||||
if(existingEntry){
|
||||
let nextState = prev.map(e => {
|
||||
if(e.publicKey === entity){
|
||||
return {
|
||||
publicKey: entity,
|
||||
avatars: [{
|
||||
avatar: base64Image,
|
||||
timestamp,
|
||||
}, ...e.avatars]
|
||||
}
|
||||
}
|
||||
return e;
|
||||
});
|
||||
return [...nextState];
|
||||
} else {
|
||||
return [...prev, {
|
||||
publicKey: entity,
|
||||
avatars: [{
|
||||
avatar: base64Image,
|
||||
timestamp,
|
||||
}]
|
||||
}];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarContext.Provider value={{
|
||||
deliveredAvatars,
|
||||
saveAvatar,
|
||||
loadAvatarsFromCacheByPublicKey,
|
||||
changeAvatar,
|
||||
decodedAvatarsCache
|
||||
}}>
|
||||
{props.children}
|
||||
</AvatarContext.Provider>
|
||||
)
|
||||
}
|
||||
10
app/providers/AvatarProvider/useAvatarDelivery.ts
Normal file
10
app/providers/AvatarProvider/useAvatarDelivery.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { AvatarContext } from "./AvatarProvider";
|
||||
|
||||
export function useAvatarDelivery(publicKey: string) {
|
||||
const context : any = useContext(AvatarContext);
|
||||
if (!context) {
|
||||
throw new Error("useAvatarDelivery must be used within an AvatarProvider");
|
||||
}
|
||||
return [context.deliveredAvatars.includes(publicKey), context.laterAvatarDelivery.bind(context), context.sendMyAvatarTo.bind(context)];
|
||||
}
|
||||
15
app/providers/AvatarProvider/useAvatars.ts
Normal file
15
app/providers/AvatarProvider/useAvatars.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useContext } from "react";
|
||||
import { AvatarContext, AvatarInformation } from "./AvatarProvider";
|
||||
|
||||
export function useAvatars(publicKey: string, allDecode: boolean = false) : AvatarInformation[] {
|
||||
const context : any = useContext(AvatarContext);
|
||||
if(!context){
|
||||
throw new Error("useAvatars must be used within an AvatarProvider");
|
||||
}
|
||||
/**
|
||||
* Load avatar to cache
|
||||
*/
|
||||
context.loadAvatarsFromCacheByPublicKey(publicKey, allDecode);
|
||||
|
||||
return context.decodedAvatarsCache.find((entry: any) => entry.publicKey === publicKey)?.avatars || [];
|
||||
}
|
||||
10
app/providers/AvatarProvider/useChangeAvatar.ts
Normal file
10
app/providers/AvatarProvider/useChangeAvatar.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { AvatarContext } from "./AvatarProvider";
|
||||
|
||||
export function useAvatarChange() {
|
||||
const context : any = useContext(AvatarContext);
|
||||
if(!context){
|
||||
throw new Error("useAvatarChange must be used within an AvatarProvider");
|
||||
}
|
||||
return context.changeAvatar;
|
||||
}
|
||||
10
app/providers/AvatarProvider/useSaveAvatar.ts
Normal file
10
app/providers/AvatarProvider/useSaveAvatar.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { AvatarContext } from "./AvatarProvider";
|
||||
|
||||
export function useSaveAvatar() {
|
||||
const context : any = useContext(AvatarContext);
|
||||
if(!context){
|
||||
throw new Error("useSaveAvatar must be used within an AvatarProvider");
|
||||
}
|
||||
return context.saveAvatar;
|
||||
}
|
||||
54
app/providers/BlacklistProvider/BlacklistProvider.tsx
Normal file
54
app/providers/BlacklistProvider/BlacklistProvider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import React, { createContext, useEffect, useState } from "react";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
|
||||
export const BlacklistContext = createContext<any>({});
|
||||
|
||||
interface BlacklistProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BlacklistProvider(props : BlacklistProviderProps) {
|
||||
const [blocked, setBlocked] = useState<string[]>([]);
|
||||
const {runQuery, allQuery} = useDatabase();
|
||||
const myPublicKey = usePublicKey();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
syncBlacklistWithLocalDb();
|
||||
}, [myPublicKey]);
|
||||
|
||||
const syncBlacklistWithLocalDb = async () => {
|
||||
const result = await allQuery("SELECT * FROM `blacklist` WHERE account = ?", [myPublicKey]);
|
||||
let publicKeysBlocked : string[] = [];
|
||||
for(let i = 0; i < result.length; i++){
|
||||
let publicKey = result[i].public_key;
|
||||
publicKeysBlocked.push(publicKey);
|
||||
}
|
||||
setBlocked(publicKeysBlocked);
|
||||
}
|
||||
|
||||
const blockUser = (publicKey : string) => {
|
||||
setBlocked((prev) => [...prev, publicKey]);
|
||||
runQuery("INSERT INTO `blacklist` (public_key, account) VALUES (?, ?)", [publicKey, myPublicKey]);
|
||||
}
|
||||
|
||||
const unblockUser = (publicKey : string) => {
|
||||
setBlocked((prev) => prev.filter(item => item != publicKey));
|
||||
runQuery("DELETE FROM `blacklist` WHERE `public_key` = ? AND `account` = ?", [publicKey, myPublicKey]);
|
||||
}
|
||||
|
||||
const isUserBlocked = (publicKey : string) => {
|
||||
return blocked.includes(publicKey);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlacklistContext.Provider value={
|
||||
{isUserBlocked,
|
||||
blockUser,
|
||||
unblockUser, blocked}
|
||||
}>
|
||||
{props.children}
|
||||
</BlacklistContext.Provider>
|
||||
)
|
||||
}
|
||||
30
app/providers/BlacklistProvider/useBlacklist.ts
Normal file
30
app/providers/BlacklistProvider/useBlacklist.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useContext } from "react";
|
||||
import { BlacklistContext } from "./BlacklistProvider";
|
||||
|
||||
export function useBlacklist(publicKey : string) : [
|
||||
boolean,
|
||||
() => void,
|
||||
() => void
|
||||
] {
|
||||
const context = useContext(BlacklistContext);
|
||||
if(!context){
|
||||
throw new Error("useBlacklist must be used within a BlacklistProvider");
|
||||
}
|
||||
|
||||
const {isUserBlocked, blockUser, unblockUser} = context;
|
||||
const blocked = isUserBlocked(publicKey);
|
||||
|
||||
const block = () => {
|
||||
blockUser(publicKey);
|
||||
}
|
||||
|
||||
const unblock = () => {
|
||||
unblockUser(publicKey);
|
||||
}
|
||||
|
||||
return [
|
||||
blocked,
|
||||
block,
|
||||
unblock
|
||||
]
|
||||
}
|
||||
164
app/providers/ContextMenuProvider/ContextMenuProvider.tsx
Normal file
164
app/providers/ContextMenuProvider/ContextMenuProvider.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Box, Menu } from "@mantine/core";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
interface ContextMenuProviderContextType {
|
||||
openContextMenu: (
|
||||
items : ContextMenuItem[],
|
||||
noRenderStandardItems?: boolean, noRenderDisabledItems?: boolean) => void;
|
||||
}
|
||||
|
||||
export const ContextMenuContext = createContext<ContextMenuProviderContextType|null>(null);
|
||||
|
||||
interface ContextMenuProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
action: () => void;
|
||||
icon: React.ReactNode;
|
||||
cond?: () => boolean | Promise<boolean>;
|
||||
__reserved_prerender_condition?: boolean;
|
||||
}
|
||||
|
||||
const standardMenuItems: ContextMenuItem[] = [];
|
||||
|
||||
const animationDelay = 40;
|
||||
|
||||
export function ContextMenuProvider(props : ContextMenuProviderProps) {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0
|
||||
});
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [items, setItems] = useState<ContextMenuItem[]>([]);
|
||||
const [noRenderStandardItems, setNoRenderStandardItems] = useState<boolean>(false);
|
||||
const [standardItemsReady, setStandardItemsReady] = useState<ContextMenuItem[]>([]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
document.removeEventListener('contextmenu', contextMenuHandler);
|
||||
document.removeEventListener('click', clickHandler);
|
||||
document.addEventListener('contextmenu',contextMenuHandler);
|
||||
document.addEventListener('click', clickHandler);
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', contextMenuHandler);
|
||||
document.removeEventListener('click', clickHandler);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setStandardItemsReady(await translateConditionsToReservedField(standardMenuItems));
|
||||
})();
|
||||
}, [open]);
|
||||
|
||||
const contextMenuHandler = (event) => {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
setCoords({
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
});
|
||||
}
|
||||
|
||||
const clickHandler = () => {
|
||||
if(open){
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
/**
|
||||
* Ждем завершения анимации
|
||||
*/
|
||||
setItems([]);
|
||||
}, animationDelay);
|
||||
}
|
||||
}
|
||||
|
||||
const translateConditionsToReservedField = async (fromItems : ContextMenuItem[], noRenderDisabledItems: boolean = false) : Promise<ContextMenuItem[]> => {
|
||||
const newItems: ContextMenuItem[] = [];
|
||||
for(const item of fromItems){
|
||||
if(!item.cond){
|
||||
newItems.push({
|
||||
...item,
|
||||
__reserved_prerender_condition: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const condResult = await item.cond();
|
||||
if(!condResult && noRenderDisabledItems){
|
||||
continue;
|
||||
}
|
||||
newItems.push({
|
||||
...item,
|
||||
__reserved_prerender_condition: condResult
|
||||
});
|
||||
}
|
||||
return newItems;
|
||||
}
|
||||
|
||||
const openContextMenu = async (
|
||||
items : ContextMenuItem[],
|
||||
noRenderStandardItems: boolean = false,
|
||||
noRenderDisabledItems: boolean = false
|
||||
) => {
|
||||
setItems(await translateConditionsToReservedField(items, noRenderDisabledItems));
|
||||
setNoRenderStandardItems(noRenderStandardItems);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenuContext.Provider value={{
|
||||
openContextMenu
|
||||
}}>
|
||||
{standardItemsReady.length > 0 || items.length > 0 && (
|
||||
<Box style={{
|
||||
position: 'absolute',
|
||||
top: coords.y > window.innerHeight - (items.concat(standardMenuItems).length * 45) ? (window.innerHeight - (items.concat(standardMenuItems).length * 45)) + 'px' : coords.y + 'px',
|
||||
left: coords.x > window.innerWidth - 210 ? (window.innerWidth - 210) + 'px' : coords.x + 'px',
|
||||
}}>
|
||||
<Menu
|
||||
trapFocus={false}
|
||||
shadow="md"
|
||||
opened={open}
|
||||
transitionProps={{
|
||||
duration: animationDelay,
|
||||
transition: 'pop-top-left',
|
||||
}}
|
||||
closeDelay={0}
|
||||
styles={{
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
top: coords.y > window.innerHeight - (items.concat(standardMenuItems).length * 45) ? (window.innerHeight - (items.concat(standardMenuItems).length * 45)) + 'px' : coords.y + 'px',
|
||||
left: coords.x > window.innerWidth - 210 ? (window.innerWidth - 210) + 'px' : coords.x + 'px',
|
||||
}
|
||||
}}
|
||||
width={150}>
|
||||
<Menu.Dropdown>
|
||||
{items.map((item, index) => (
|
||||
<Menu.Item fz={'xs'} fw={500} key={index} disabled={!item.__reserved_prerender_condition} leftSection={item.icon} onClick={() => {
|
||||
item.action();
|
||||
setOpen(false);
|
||||
}}>
|
||||
{item.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{items.length > 0 && !noRenderStandardItems && standardMenuItems.length > 0 && <Menu.Divider></Menu.Divider>}
|
||||
{!noRenderStandardItems && standardItemsReady.map((item, index) => (
|
||||
<Menu.Item fz={'xs'} fw={500} key={index} disabled={!item.__reserved_prerender_condition} leftSection={item.icon} onClick={() => {
|
||||
item.action();
|
||||
setOpen(false);
|
||||
}}>
|
||||
{item.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
{props.children}
|
||||
</ContextMenuContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
app/providers/ContextMenuProvider/useContextMenu.ts
Normal file
10
app/providers/ContextMenuProvider/useContextMenu.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { ContextMenuContext } from "./ContextMenuProvider";
|
||||
|
||||
export function useContextMenu() {
|
||||
const context = useContext(ContextMenuContext);
|
||||
if (!context) {
|
||||
throw new Error('useContextMenu must be used within a ContextMenuProvider');
|
||||
}
|
||||
return context.openContextMenu;
|
||||
}
|
||||
34
app/providers/DatabaseProvider/DatabaseProvider.tsx
Normal file
34
app/providers/DatabaseProvider/DatabaseProvider.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createContext } from "react";
|
||||
import { TABLES } from "./tables";
|
||||
|
||||
export const DatabaseContext = createContext<any>({});
|
||||
|
||||
interface DatabaseProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DatabaseProvider(props: DatabaseProviderProps) {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const {runQuery} = useDatabase();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await createAllTables();
|
||||
setInitialized(true);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const createAllTables = async () => {
|
||||
for(let i = 0; i < TABLES.length; i++){
|
||||
await runQuery(TABLES[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DatabaseContext.Provider value={{}}>
|
||||
{initialized && props.children}
|
||||
</DatabaseContext.Provider>
|
||||
);
|
||||
}
|
||||
77
app/providers/DatabaseProvider/tables.ts
Normal file
77
app/providers/DatabaseProvider/tables.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const TABLES = [
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
private_key TEXT NOT NULL,
|
||||
sfen TEXT NOT NULL,
|
||||
UNIQUE (public_key)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS blacklist (
|
||||
id INTEGER PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
account TEXT NOT NULL
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS avatar_delivery (
|
||||
id INTEGER PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
account TEXT NOT NULL
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS avatar_cache (
|
||||
id INTEGER PRIMARY KEY,
|
||||
public_key TEXT,
|
||||
avatar TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
UNIQUE (id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account TEXT NOT NULL,
|
||||
from_public_key TEXT NOT NULL,
|
||||
to_public_key BLOB NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
chacha_key BLOB NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
from_me INTEGER NOT NULL DEFAULT 0,
|
||||
delivered INTEGER NOT NULL DEFAULT 0,
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
plain_message BLOB NOT NULL,
|
||||
attachments TEXT NOT NULL DEFAULT '[]',
|
||||
UNIQUE (id)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS cached_users (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (public_key)
|
||||
)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
UNIQUE (id)
|
||||
)`,
|
||||
/**
|
||||
* dialog_id can be a public key for individual chats or a group ID for group chats
|
||||
* last_message encoded with private key of the account
|
||||
* last_message_from is the public key of the sender
|
||||
*/
|
||||
`CREATE TABLE IF NOT EXISTS dialogs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account TEXT NOT NULL,
|
||||
dialog_id TEXT NOT NULL,
|
||||
last_message_id TEXT NOT NULL,
|
||||
last_timestamp INTEGER NOT NULL,
|
||||
is_request INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (id)
|
||||
)`
|
||||
]
|
||||
38
app/providers/DatabaseProvider/useDatabase.ts
Normal file
38
app/providers/DatabaseProvider/useDatabase.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function useDatabase() {
|
||||
|
||||
const buildDebug = (query: string, params: any[]) => {
|
||||
console.info("-----------------");
|
||||
//build final query
|
||||
let finalQuery = query;
|
||||
params.forEach((param) => {
|
||||
let value = param;
|
||||
if(typeof param === 'string'){
|
||||
value = `'${param}'`;
|
||||
}
|
||||
finalQuery = finalQuery.replace('?', value);
|
||||
});
|
||||
console.info("Final Query: ", finalQuery);
|
||||
console.info("-----------------");
|
||||
}
|
||||
|
||||
const runQuery = async (query: string, params: any[] = []) => {
|
||||
return await window.electron.ipcRenderer.invoke('db:run', query, params);
|
||||
};
|
||||
|
||||
const getQuery = async (query: string, params: any[] = []) => {
|
||||
return await window.electron.ipcRenderer.invoke('db:get', query, params);
|
||||
};
|
||||
|
||||
const allQuery = async (query: string, params: any[] = [], debug: boolean = false) => {
|
||||
if(debug){
|
||||
buildDebug(query, params);
|
||||
}
|
||||
return await window.electron.ipcRenderer.invoke('db:all', query, params);
|
||||
};
|
||||
|
||||
return {
|
||||
runQuery,
|
||||
getQuery,
|
||||
allQuery,
|
||||
};
|
||||
}
|
||||
78
app/providers/DeviceProvider/DeviceProvider.tsx
Normal file
78
app/providers/DeviceProvider/DeviceProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { decodeWithPassword, encodeWithPassword } from "@/app/crypto/crypto";
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
interface DeviceProviderContextValue {
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export const DeviceProviderContext = createContext<DeviceProviderContextValue|null>(null);
|
||||
|
||||
interface DeviceProviderProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DeviceProvider(props: DeviceProviderProps) {
|
||||
const [deviceId, setDeviceId] = useState<string>("");
|
||||
const {writeFile, readFile} = useFileStorage();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeviceId();
|
||||
}, []);
|
||||
|
||||
const fetchDeviceId = async () => {
|
||||
const device = await readFile("device");
|
||||
if(device){
|
||||
const decoded = await decodeDevice(Buffer.from(device).toString('utf-8'));
|
||||
if(decoded){
|
||||
setDeviceId(decoded);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await createDeviceId();
|
||||
}
|
||||
|
||||
const createDeviceId = async () => {
|
||||
const newDevice = generateRandomKey(128);
|
||||
const encoded = await encodeDevice(newDevice);
|
||||
await writeFile("device", encoded);
|
||||
setDeviceId(newDevice);
|
||||
}
|
||||
|
||||
const decodeDevice = async (data: string) => {
|
||||
const hwid = window.deviceId;
|
||||
const platform = window.deviceName;
|
||||
const salt = "rosetta-device-salt";
|
||||
|
||||
try {
|
||||
const decoded = await decodeWithPassword(hwid + platform + salt, data);
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
console.error("Failed to decode device data:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const encodeDevice = async (data: string) => {
|
||||
const hwid = window.deviceId;
|
||||
const platform = window.deviceName;
|
||||
const salt = "rosetta-device-salt";
|
||||
|
||||
try {
|
||||
const encoded = await encodeWithPassword(hwid + platform + salt, data);
|
||||
return encoded;
|
||||
} catch (e) {
|
||||
console.error("Failed to encode device data:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DeviceProviderContext.Provider value={{
|
||||
deviceId: deviceId
|
||||
}}>
|
||||
{props.children}
|
||||
</DeviceProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
11
app/providers/DeviceProvider/useDeviceId.ts
Normal file
11
app/providers/DeviceProvider/useDeviceId.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import { DeviceProviderContext } from "./DeviceProvider";
|
||||
|
||||
export function useDeviceId(): string {
|
||||
const context = useContext(DeviceProviderContext);
|
||||
if(!context) {
|
||||
throw new Error("useDeviceId must be used within a DeviceProvider");
|
||||
}
|
||||
|
||||
return context.deviceId;
|
||||
}
|
||||
210
app/providers/DialogListProvider/DialogListProvider.tsx
Normal file
210
app/providers/DialogListProvider/DialogListProvider.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { DIALOG_DROP_TO_REQUESTS_IF_NO_MESSAGES_FROM_ME_COUNT } from "@/app/constants";
|
||||
import { useQueue } from "@/app/hooks/useQueue";
|
||||
import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts";
|
||||
import { DialogStateContext } from "../DialogStateProvider.tsx/DialogStateProvider";
|
||||
|
||||
|
||||
interface DialogListContextValue {
|
||||
dialogs: DialogRow[];
|
||||
setDialogs: (dialogs: DialogRow[]) => void;
|
||||
updateDialog: (dialog_id: string) => void;
|
||||
loadingDialogs: number; //if > 0 then loading
|
||||
}
|
||||
|
||||
export const DialogListContext = createContext<DialogListContextValue|null>(null);
|
||||
|
||||
interface DialogListProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DialogRow {
|
||||
dialog_id: string;
|
||||
last_message_id: string;
|
||||
last_timestamp: number; //timestamp update
|
||||
last_message_timestamp: number; //message timestamp
|
||||
is_request: boolean;
|
||||
/**
|
||||
* Закреплен ли диалог
|
||||
*/
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export function DialogListProvider(props : DialogListProviderProps) {
|
||||
const [dialogs, setDialogs] = useState<DialogRow[]>([]);
|
||||
const {info} = useConsoleLogger('DialogListProvider');
|
||||
const { allQuery, getQuery, runQuery } = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
const { inProcess, addToQueue, removeFromQueue, queue } = useQueue<string>();
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const { pinned } = useContext(DialogStateContext)!;
|
||||
const [loadingDialogs, setLoadingDialogs] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
initialLoadingFromDatabase();
|
||||
}, [publicKey]);
|
||||
|
||||
useEffect(() => {
|
||||
//console.info(pinned);
|
||||
setDialogs((prevDialogs) => {
|
||||
const newDialogs = prevDialogs.map(d => {
|
||||
return {
|
||||
...d,
|
||||
pinned: pinned.includes(d.dialog_id)
|
||||
}
|
||||
});
|
||||
return newDialogs.sort((a, b) =>
|
||||
a.pinned === b.pinned ?
|
||||
b.last_message_timestamp - a.last_message_timestamp
|
||||
: (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
||||
});
|
||||
}, [pinned]);
|
||||
|
||||
const initialLoadingFromDatabase = async () => {
|
||||
if(publicKey == ''){
|
||||
return;
|
||||
}
|
||||
const rows = await allQuery(`SELECT * FROM dialogs WHERE account = ? ORDER BY last_timestamp DESC`, [publicKey]);
|
||||
const loadedDialogs: DialogRow[] = rows.map((row: any) => {
|
||||
/**
|
||||
* Если диалог системный (бот, канал и т.д.), то он не может быть запросом
|
||||
*/
|
||||
const isRequest = row.is_request
|
||||
&& !systemAccounts.some(acc => acc.publicKey === row.dialog_id);
|
||||
|
||||
return {
|
||||
dialog_id: row.dialog_id,
|
||||
last_message_id: row.last_message_id,
|
||||
last_timestamp: Date.now(),
|
||||
is_request: isRequest,
|
||||
last_message_timestamp: row.last_timestamp,
|
||||
pinned: pinned.includes(row.dialog_id),
|
||||
}
|
||||
});
|
||||
const prepartedDialogsForBatching = loadedDialogs.sort((a, b) => a.pinned === b.pinned ? b.last_message_timestamp - a.last_message_timestamp : (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
||||
|
||||
if(prepartedDialogsForBatching.length > 0){
|
||||
/**
|
||||
* Показываем индикатор загрузки, потому что
|
||||
* есть баг с отрисовкой большого количества диалогов сразу
|
||||
* TODO: найти причину бага и исправить
|
||||
* UI блокируется на время отрисовки большого количества диалогов
|
||||
* не надо так)))
|
||||
*/
|
||||
setLoadingDialogs(prepartedDialogsForBatching.length);
|
||||
setTimeout(() => {
|
||||
setLoadingDialogs(0);
|
||||
setDialogs(prepartedDialogsForBatching);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
info(`Loaded ${loadedDialogs.length} dialogs from database.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет информацию о диалоге в общем списке диалогов
|
||||
* @param dialog_id id диалога в формате public_key или #group:group_id
|
||||
* @returns
|
||||
*/
|
||||
const updateDialog = useCallback(async (dialog_id: string) => {
|
||||
if(inProcess(dialog_id)){
|
||||
info(`Dialog ${dialog_id} is already being processed, skipping update.`);
|
||||
setTimeout(() => {
|
||||
/**
|
||||
* Попытка обновить диалог еще раз, чтобы избежать гонки
|
||||
*/
|
||||
updateDialog(dialog_id);
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
addToQueue(dialog_id);
|
||||
let last_message : any = null;
|
||||
if(dialog_id.startsWith('#group:')){
|
||||
last_message = await getQuery(`SELECT * FROM messages
|
||||
WHERE to_public_key = ?
|
||||
AND account = ?
|
||||
ORDER BY timestamp
|
||||
DESC LIMIT 1`, [dialog_id, publicKey]);
|
||||
}else{
|
||||
last_message = await getQuery(`SELECT * FROM messages
|
||||
WHERE ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?))
|
||||
AND account = ?
|
||||
ORDER BY timestamp
|
||||
DESC LIMIT 1`, [dialog_id, publicKey, publicKey, dialog_id, publicKey]);
|
||||
}
|
||||
let dialogsWithId = await getQuery(`SELECT COUNT(*) as count FROM dialogs WHERE dialog_id = ? AND account = ?`, [dialog_id, publicKey]);
|
||||
|
||||
if(!last_message && dialogsWithId.count > 0){
|
||||
setDialogs((prevDialogs) => {
|
||||
const filteredDialogs = prevDialogs.filter(d => d.dialog_id !== dialog_id);
|
||||
return filteredDialogs.sort((a, b) =>
|
||||
a.pinned === b.pinned ?
|
||||
b.last_message_timestamp - a.last_message_timestamp
|
||||
: (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));;
|
||||
});
|
||||
info(`Delete dialog ${dialog_id} as it has no messages.`);
|
||||
await runQuery(`DELETE FROM dialogs WHERE dialog_id = ? AND account = ?`, [dialog_id, publicKey]);
|
||||
removeFromQueue(dialog_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!last_message){
|
||||
info(`No messages found for dialog ${dialog_id}, skipping update.`);
|
||||
removeFromQueue(dialog_id);
|
||||
return;
|
||||
}
|
||||
|
||||
let messagesFromMeCount = await getQuery(`SELECT COUNT(*) as count FROM messages
|
||||
WHERE (
|
||||
(from_public_key = ? AND to_public_key = ?)
|
||||
OR
|
||||
(from_public_key = ? AND to_public_key = ?)
|
||||
)
|
||||
AND from_public_key = ?
|
||||
AND account = ? ORDER BY timestamp DESC LIMIT ?`,
|
||||
[dialog_id, publicKey, publicKey, dialog_id, publicKey, publicKey, DIALOG_DROP_TO_REQUESTS_IF_NO_MESSAGES_FROM_ME_COUNT]);
|
||||
|
||||
const updatedDialog: DialogRow = {
|
||||
dialog_id: dialog_id,
|
||||
last_message_id: last_message.message_id,
|
||||
last_timestamp: Date.now(),
|
||||
is_request: messagesFromMeCount.count <= 0 && !systemAccounts.some(acc => acc.publicKey === dialog_id),
|
||||
last_message_timestamp: last_message.timestamp,
|
||||
pinned: pinned.includes(dialog_id)
|
||||
};
|
||||
|
||||
|
||||
// Обновляем состояние диалога в базе
|
||||
if(dialogsWithId.count > 0){
|
||||
await runQuery(`UPDATE dialogs SET last_message_id = ?, last_timestamp = ?, is_request = ? WHERE dialog_id = ? AND account = ?`, [updatedDialog.last_message_id, updatedDialog.last_message_timestamp, updatedDialog.is_request, dialog_id, publicKey]);
|
||||
}else{
|
||||
await runQuery(`INSERT INTO dialogs (dialog_id, last_message_id, last_timestamp, is_request, account) VALUES (?, ?, ?, ?, ?)`, [dialog_id, updatedDialog.last_message_id, updatedDialog.last_message_timestamp, updatedDialog.is_request, publicKey]);
|
||||
}
|
||||
|
||||
setDialogs((prevDialogs) => {
|
||||
const filteredDialogs = prevDialogs.filter(d => d.dialog_id !== dialog_id);
|
||||
const newDialogs = [updatedDialog, ...filteredDialogs];
|
||||
return newDialogs.sort((a, b) =>
|
||||
a.pinned === b.pinned ?
|
||||
b.last_message_timestamp - a.last_message_timestamp
|
||||
: (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
||||
});
|
||||
removeFromQueue(dialog_id);
|
||||
info(`Dialog ${dialog_id} updated.`);
|
||||
}, [publicKey, queue, pinned, systemAccounts]);
|
||||
|
||||
|
||||
return (
|
||||
<DialogListContext.Provider value={{
|
||||
dialogs,
|
||||
setDialogs,
|
||||
updateDialog,
|
||||
loadingDialogs
|
||||
}}>
|
||||
{props.children}
|
||||
</DialogListContext.Provider>
|
||||
)
|
||||
}
|
||||
88
app/providers/DialogListProvider/useDialogInfo.ts
Normal file
88
app/providers/DialogListProvider/useDialogInfo.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { DialogRow } from "./DialogListProvider";
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { decodeWithPassword } from "@/app/crypto/crypto";
|
||||
import { constructLastMessageTextByAttachments } from "@/app/utils/constructLastMessageTextByAttachments";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { DeliveredMessageState, Message } from "../DialogProvider/DialogProvider";
|
||||
|
||||
/**
|
||||
* Получает информацию о последнем сообщении в диалоге и количестве непрочитанных сообщений,
|
||||
* работает как с групповыми диалогами, так и с личными.
|
||||
* Последнее сообщение содержит расшифрованный текст в поле plain_message.
|
||||
* @param row Диалог из списка диалогов
|
||||
* @returns информация о последнем сообщении и количестве непрочитанных сообщений
|
||||
*/
|
||||
export function useDialogInfo(row : DialogRow) : {
|
||||
lastMessage: Message;
|
||||
unreaded: number;
|
||||
loading: boolean;
|
||||
} {
|
||||
const {getQuery} = useDatabase();
|
||||
const publicKey = usePublicKey();
|
||||
const privatePlain = usePrivatePlain();
|
||||
const [lastMessage, setLastMessage] = useState<Message>({
|
||||
from_public_key: '',
|
||||
to_public_key: '',
|
||||
content: '',
|
||||
timestamp: 0,
|
||||
chacha_key: '',
|
||||
readed: 0,
|
||||
from_me: 0,
|
||||
delivered: DeliveredMessageState.WAITING,
|
||||
message_id: '',
|
||||
plain_message: '',
|
||||
attachments: [],
|
||||
});
|
||||
const [unreaded, setUnreaded] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadLastMessageInfo();
|
||||
}, [row.last_timestamp]);
|
||||
|
||||
const loadLastMessageInfo = async () => {
|
||||
let message = await getQuery(`SELECT * FROM messages WHERE message_id = ? AND account = ? LIMIT 1`, [row.last_message_id, publicKey]);
|
||||
if(!message){
|
||||
return;
|
||||
}
|
||||
|
||||
let lastMessage = '';
|
||||
try{
|
||||
lastMessage = await decodeWithPassword(privatePlain, message.plain_message);
|
||||
}catch(e){
|
||||
lastMessage = constructLastMessageTextByAttachments(message.attachments);
|
||||
}
|
||||
|
||||
setLastMessage({
|
||||
...message,
|
||||
plain_message: lastMessage,
|
||||
attachments: JSON.parse(message.attachments),
|
||||
});
|
||||
let unreadedCount = {
|
||||
count: 0
|
||||
};
|
||||
if(row.dialog_id.startsWith('#group:')){
|
||||
unreadedCount = await getQuery(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM messages
|
||||
WHERE read = 0
|
||||
AND to_public_key = ?
|
||||
AND account = ?`, [row.dialog_id, publicKey]);
|
||||
}else{
|
||||
unreadedCount = await getQuery(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM messages
|
||||
WHERE read = 0
|
||||
AND ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?))
|
||||
AND account = ?`, [publicKey, row.dialog_id, row.dialog_id, publicKey, publicKey]);
|
||||
}
|
||||
setUnreaded(unreadedCount.count);
|
||||
}
|
||||
|
||||
return {
|
||||
lastMessage,
|
||||
unreaded,
|
||||
loading: lastMessage.message_id == '',
|
||||
}
|
||||
}
|
||||
17
app/providers/DialogListProvider/useDialogsList.ts
Normal file
17
app/providers/DialogListProvider/useDialogsList.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useContext } from "react";
|
||||
import { DialogListContext } from "./DialogListProvider";
|
||||
|
||||
export function useDialogsList() {
|
||||
const context = useContext(DialogListContext);
|
||||
if(!context){
|
||||
throw new Error("useDialogList must be call in DialogListProvider");
|
||||
}
|
||||
const {dialogs, updateDialog, setDialogs, loadingDialogs} = context;
|
||||
|
||||
return {
|
||||
dialogs,
|
||||
updateDialog,
|
||||
setDialogs,
|
||||
loadingDialogs
|
||||
};
|
||||
}
|
||||
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}
|
||||
}
|
||||
122
app/providers/DialogStateProvider.tsx/DialogStateProvider.tsx
Normal file
122
app/providers/DialogStateProvider.tsx/DialogStateProvider.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import React, { createContext, useEffect, useState } from "react";
|
||||
|
||||
export interface DialogStateContextValue {
|
||||
muted: string[];
|
||||
pinned: string[];
|
||||
muteToggle: (dialogId: string) => void;
|
||||
pinToggle: (dialogId: string) => void;
|
||||
mentions: DialogMention[];
|
||||
pushMention: (dialogMention : DialogMention) => void;
|
||||
popMention: (DialogMention: DialogMention) => void;
|
||||
isMentioned: (dialogId: string) => boolean;
|
||||
getLastMention: (dialogId: string) => DialogMention;
|
||||
}
|
||||
|
||||
export const DialogStateContext = createContext<DialogStateContextValue | null>(null);
|
||||
|
||||
export interface DialogStateProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DialogMention {
|
||||
dialog_id: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Этот провайдер нужен для того, чтобы быстро определить состояние диалога,
|
||||
* например, закреплен ли он или нет, или отключены ли в нем уведомления.
|
||||
*
|
||||
* ВАЖНО! При отключенных уведомлениях все равно
|
||||
* будут доставляться сообщния с упоминаниями.
|
||||
*/
|
||||
export function DialogStateProvider(props : DialogStateProviderProps) {
|
||||
const [muted, setMuted] = useState<string[]>([]);
|
||||
const [pinned, setPinned] = useState<string[]>([]);
|
||||
const [mentions, setDialogMentions] = useState<DialogMention[]>([]);
|
||||
const {info} = useConsoleLogger('DialogStateProvider');
|
||||
|
||||
useEffect(() => {
|
||||
let muted = localStorage.getItem("mutedDialogs");
|
||||
let pinned = localStorage.getItem("pinnedDialogs");
|
||||
let mentions = localStorage.getItem("dialogMentions");
|
||||
|
||||
if (mentions) {
|
||||
setDialogMentions(JSON.parse(mentions));
|
||||
}
|
||||
if (muted) {
|
||||
setMuted(JSON.parse(muted));
|
||||
}
|
||||
if (pinned) {
|
||||
setPinned(JSON.parse(pinned));
|
||||
}
|
||||
|
||||
info("Initial dialog states is loaded");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("mutedDialogs", JSON.stringify(muted));
|
||||
}, [muted]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("pinnedDialogs", JSON.stringify(pinned));
|
||||
}, [pinned]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("dialogMentions", JSON.stringify(mentions));
|
||||
}, [mentions]);
|
||||
|
||||
const muteToggle = (dialogId: string) => {
|
||||
setMuted(prev => {
|
||||
if (prev.includes(dialogId)) {
|
||||
return prev.filter(id => id !== dialogId);
|
||||
} else {
|
||||
return [...prev, dialogId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const pinToggle = (dialogId: string) => {
|
||||
setPinned(prev => {
|
||||
if (prev.includes(dialogId)) {
|
||||
return prev.filter(id => id !== dialogId);
|
||||
} else {
|
||||
return [...prev, dialogId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const pushMention = (dialogMention: DialogMention) => {
|
||||
setDialogMentions((prev) => [...prev, dialogMention]);
|
||||
}
|
||||
|
||||
const popMention = (dialogMention: DialogMention) => {
|
||||
setDialogMentions((prev) => prev.filter(m => !(m.dialog_id === dialogMention.dialog_id && m.message_id === dialogMention.message_id)));
|
||||
}
|
||||
|
||||
const isMentioned = (dialogId: string) => {
|
||||
return mentions.some(m => m.dialog_id === dialogId);
|
||||
}
|
||||
|
||||
const getLastMention = (dialogId: string) : DialogMention => {
|
||||
const dialogMentions = mentions.filter(m => m.dialog_id === dialogId);
|
||||
return dialogMentions[dialogMentions.length - 1];
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogStateContext.Provider value={{
|
||||
muted,
|
||||
pinned,
|
||||
muteToggle,
|
||||
pinToggle,
|
||||
pushMention,
|
||||
popMention,
|
||||
mentions,
|
||||
isMentioned,
|
||||
getLastMention
|
||||
}}>
|
||||
{props.children}
|
||||
</DialogStateContext.Provider>
|
||||
)
|
||||
}
|
||||
15
app/providers/DialogStateProvider.tsx/useDialogMute.ts
Normal file
15
app/providers/DialogStateProvider.tsx/useDialogMute.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useContext } from "react";
|
||||
import { DialogStateContext } from "./DialogStateProvider";
|
||||
|
||||
export function useDialogMute(dialog_id: string) {
|
||||
const context = useContext(DialogStateContext);
|
||||
if(!context){
|
||||
throw new Error("useDialogState must be used within a DialogStateProvider");
|
||||
}
|
||||
|
||||
const isMuted = context.muted.includes(dialog_id);
|
||||
return {
|
||||
isMuted,
|
||||
muteToggle: context.muteToggle
|
||||
}
|
||||
}
|
||||
16
app/providers/DialogStateProvider.tsx/useDialogPin.ts
Normal file
16
app/providers/DialogStateProvider.tsx/useDialogPin.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useContext } from "react";
|
||||
import { DialogStateContext } from "./DialogStateProvider";
|
||||
|
||||
export function useDialogPin(dialog_id: string){
|
||||
const context = useContext(DialogStateContext);
|
||||
if(!context){
|
||||
throw new Error("useDialogState must be used within a DialogStateProvider");
|
||||
}
|
||||
|
||||
const isPinned = context.pinned.includes(dialog_id);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
pinToggle: context.pinToggle
|
||||
}
|
||||
}
|
||||
11
app/providers/DialogStateProvider.tsx/useDialogState.ts
Normal file
11
app/providers/DialogStateProvider.tsx/useDialogState.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import { DialogStateContext, DialogStateContextValue } from "./DialogStateProvider";
|
||||
|
||||
export function useDialogState() : DialogStateContextValue {
|
||||
const context = useContext(DialogStateContext);
|
||||
if(!context){
|
||||
throw new Error("useDialogState must be used within a DialogStateProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
16
app/providers/DialogStateProvider.tsx/useMentions.ts
Normal file
16
app/providers/DialogStateProvider.tsx/useMentions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useContext } from "react";
|
||||
import { DialogStateContext } from "./DialogStateProvider";
|
||||
|
||||
export function useMentions() {
|
||||
const context = useContext(DialogStateContext);
|
||||
if (!context) {
|
||||
throw new Error("useMentions must be used within a DialogStateProvider");
|
||||
}
|
||||
return {
|
||||
pushMention: context.pushMention,
|
||||
popMention: context.popMention,
|
||||
isMentioned: context.isMentioned,
|
||||
mentions: context.mentions,
|
||||
getLastMention: context.getLastMention
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { createContext } from "react";
|
||||
|
||||
interface ErrorBoundaryContextValue {
|
||||
error: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const ErrorBoundaryContext = createContext<ErrorBoundaryContextValue | null>(null);
|
||||
|
||||
interface ErrorBoundaryProviderProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProviderState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundaryProvider extends React.Component<ErrorBoundaryProviderProps, ErrorBoundaryProviderState> {
|
||||
constructor(props: ErrorBoundaryProviderProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryProviderState {
|
||||
return { hasError: true, error: error };
|
||||
}
|
||||
|
||||
render() {
|
||||
return <ErrorBoundaryContext.Provider value={{
|
||||
error: this.state.hasError,
|
||||
message: this.state.error ? this.state.error.toString() : ""
|
||||
}}>
|
||||
{this.state.hasError ? (
|
||||
this.props.fallback ? this.props.fallback : <div>An error occurred: {this.state.error?.toString()}</div>
|
||||
) : (
|
||||
this.props.children
|
||||
)}
|
||||
</ErrorBoundaryContext.Provider>;
|
||||
}
|
||||
}
|
||||
173
app/providers/ImageViewerProvider/ImageViewer.tsx
Normal file
173
app/providers/ImageViewerProvider/ImageViewer.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Flex, Overlay, Text } from "@mantine/core";
|
||||
import { ImageToView } from "./ImageViewerProvider";
|
||||
import { useState } from "react";
|
||||
import { IconChevronLeft, IconChevronRight, IconImageInPicture, IconX } from "@tabler/icons-react";
|
||||
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
|
||||
import { useEffect } from "react";
|
||||
import { useContextMenu } from "../ContextMenuProvider/useContextMenu";
|
||||
import { convertJpegBlobToPngBlob, createBlobFromBase64Image } from "@/app/utils/utils";
|
||||
|
||||
interface ImageViewerProps {
|
||||
images: ImageToView[];
|
||||
initialSlide: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImageViewer(props : ImageViewerProps) {
|
||||
const [slide, setSlide] = useState(props.initialSlide);
|
||||
const imageToRender = props.images[slide];
|
||||
const colors = useRosettaColors();
|
||||
const openContextMenu = useContextMenu();
|
||||
const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [wasDragging, setWasDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<{x: number, y: number} | null>(null);
|
||||
|
||||
const isNextSlideAvailable = slide + 1 <= props.images.length - 1;
|
||||
const isPrevSlideAvailable = slide - 1 >= 0;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft" && isPrevSlideAvailable) {
|
||||
prevSlide(e);
|
||||
}
|
||||
if (e.key === "ArrowRight" && isNextSlideAvailable) {
|
||||
nextSlide(e);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [slide, isPrevSlideAvailable, isNextSlideAvailable, props.onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setPos({ x: 0, y: 0, scale: 1 });
|
||||
}, [slide]);
|
||||
|
||||
const nextSlide = (e : any) => {
|
||||
e.stopPropagation();
|
||||
if(slide + 1 > props.images.length - 1) {
|
||||
return;
|
||||
}
|
||||
setSlide(slide + 1);
|
||||
}
|
||||
|
||||
const prevSlide = (e : any) => {
|
||||
e.stopPropagation();
|
||||
if(slide - 1 < 0) {
|
||||
return;
|
||||
}
|
||||
setSlide(slide - 1);
|
||||
}
|
||||
|
||||
const onContextMenuImg = async () => {
|
||||
let blob = await convertJpegBlobToPngBlob(
|
||||
createBlobFromBase64Image(imageToRender.src)
|
||||
);
|
||||
openContextMenu([
|
||||
{
|
||||
label: 'Copy Image',
|
||||
action: async () => {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
]);
|
||||
},
|
||||
icon: <IconImageInPicture size={14}></IconImageInPicture>
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
// Wheel zoom (zoom to cursor)
|
||||
const onWheel = (e: React.WheelEvent<HTMLImageElement>) => {
|
||||
//e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const prevScale = pos.scale;
|
||||
let newScale = prevScale - e.deltaY * 0.004; // ускоренное увеличение
|
||||
newScale = Math.max(0.2, Math.min(5, newScale));
|
||||
const offsetX = mouseX - pos.x;
|
||||
const offsetY = mouseY - pos.y;
|
||||
const newX = mouseX - (offsetX * newScale) / prevScale;
|
||||
const newY = mouseY - (offsetY * newScale) / prevScale;
|
||||
setPos({ ...pos, scale: newScale, x: newX, y: newY });
|
||||
};
|
||||
|
||||
// Drag logic
|
||||
const onMouseDown = (e: React.MouseEvent<HTMLImageElement | HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
setWasDragging(false);
|
||||
setDragStart({ x: e.clientX - pos.x, y: e.clientY - pos.y });
|
||||
};
|
||||
const onMouseMove = (e: React.MouseEvent<HTMLImageElement | HTMLDivElement>) => {
|
||||
if (!isDragging || !dragStart) return;
|
||||
setPos({ ...pos, x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
setWasDragging(true);
|
||||
};
|
||||
const onMouseUp = (e: React.MouseEvent<HTMLImageElement | HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
setDragStart(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
onClick={() => {
|
||||
if (!isDragging && !wasDragging) props.onClose();
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
backgroundOpacity={0.7}
|
||||
>
|
||||
<Flex direction={'column'} h={'100%'} justify={'space-around'}>
|
||||
<Flex justify={'flex-end'} p={'md'}>
|
||||
<IconX size={30} stroke={1} color={'white'} style={{cursor: 'pointer'}} onClick={props.onClose}>
|
||||
</IconX>
|
||||
</Flex>
|
||||
<Flex justify={'center'} align={'center'} gap={'sm'}>
|
||||
<IconChevronLeft onClick={prevSlide} size={30} color={isPrevSlideAvailable ? 'white' : colors.chevrons.disabled} style={{cursor: 'pointer', userSelect: 'none'}}></IconChevronLeft>
|
||||
<img
|
||||
onContextMenu={() => onContextMenuImg()}
|
||||
src={imageToRender.src}
|
||||
style={{
|
||||
maxWidth: '70vw',
|
||||
maxHeight: '70vh',
|
||||
borderRadius: 8,
|
||||
userSelect: 'none',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`,
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
draggable={false}
|
||||
/>
|
||||
<IconChevronRight size={30} onClick={nextSlide} color={isNextSlideAvailable ? 'white' : colors.chevrons.disabled} style={{cursor: 'pointer', userSelect: 'none'}}></IconChevronRight>
|
||||
</Flex>
|
||||
<Flex justify={'center'} gap={'lg'} align={'center'} direction={'row'}>
|
||||
<Text size={'sm'} c={'white'}>
|
||||
{slide + 1} of {props.images.length}
|
||||
</Text>
|
||||
{imageToRender.timestamp &&
|
||||
<Text size={'sm'} c={'dimmed'}>{new Date(imageToRender.timestamp).toLocaleString()}</Text>
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
51
app/providers/ImageViewerProvider/ImageViewerProvider.tsx
Normal file
51
app/providers/ImageViewerProvider/ImageViewerProvider.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createContext, useState } from "react";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
|
||||
interface ImageViewerProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ImageToView {
|
||||
src: string;
|
||||
caption?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface ImageViewerContextType {
|
||||
open: (images: ImageToView[], startIndex: number) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const ImageViewerContext = createContext<ImageViewerContextType|null>(null);
|
||||
|
||||
export function ImageViwerProvider(props : ImageViewerProviderProps) {
|
||||
|
||||
const [imagesToView, setImagesToView] = useState<ImageToView[]>([]);
|
||||
const [currentSlide, setCurrentSlide] = useState<number>(0);
|
||||
|
||||
const open = (images: ImageToView[], startIndex: number) => {
|
||||
setImagesToView(images);
|
||||
setCurrentSlide(startIndex);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setImagesToView([]);
|
||||
setCurrentSlide(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageViewerContext.Provider value={{
|
||||
open,
|
||||
close
|
||||
}}>
|
||||
{props.children}
|
||||
{imagesToView.length > 0 &&
|
||||
<ImageViewer
|
||||
images={imagesToView}
|
||||
initialSlide={currentSlide}
|
||||
onClose={close}
|
||||
/>
|
||||
}
|
||||
</ImageViewerContext.Provider>
|
||||
);
|
||||
}
|
||||
11
app/providers/ImageViewerProvider/useImageViewer.ts
Normal file
11
app/providers/ImageViewerProvider/useImageViewer.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import { ImageViewerContext } from "./ImageViewerProvider";
|
||||
|
||||
export function useImageViewer() {
|
||||
const context = useContext(ImageViewerContext);
|
||||
if(!context){
|
||||
throw new Error("useImageViewer must be used within a ImageViewerProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
171
app/providers/InformationProvider/InformationProvider.tsx
Normal file
171
app/providers/InformationProvider/InformationProvider.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { OnlineState, PacketOnlineState, PublicKeyOnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
|
||||
export const InformationContext = createContext<any>({});
|
||||
|
||||
interface InformationProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface UserInformation {
|
||||
publicKey: string;
|
||||
verified: number;
|
||||
title: string;
|
||||
username: string;
|
||||
online: OnlineState;
|
||||
}
|
||||
|
||||
export interface GroupInformation {
|
||||
groupId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function InformationProvider(props: InformationProviderProps) {
|
||||
const [cachedUsers, setCachedUsers] = useState<UserInformation[]>([]);
|
||||
const [cachedGroups, setCachedGroups] = useState<GroupInformation[]>([]);
|
||||
const {allQuery, getQuery, runQuery} = useDatabase();
|
||||
const {info} = useConsoleLogger("InformationProvider");
|
||||
const systemAccounts = useSystemAccounts();
|
||||
const publicKey = usePublicKey();
|
||||
|
||||
useEffect(() => {
|
||||
loadCachedUsers();
|
||||
loadCachedGroups();
|
||||
}, [publicKey]);
|
||||
|
||||
usePacket(0x5, (state: PacketOnlineState) => {
|
||||
const keys = state.getPublicKeysState();
|
||||
keys.map((value : PublicKeyOnlineState) => {
|
||||
const cachedUser = cachedUsers.find((userInfo) => userInfo.publicKey == value.publicKey);
|
||||
if(!cachedUser) {
|
||||
info(`No cached user found for public key: ${value.publicKey}, info not updated`);
|
||||
return;
|
||||
}
|
||||
updateUserInformation({
|
||||
...cachedUser,
|
||||
online: value.state
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
const loadCachedGroups = () => {
|
||||
if(publicKey == ''){
|
||||
return;
|
||||
}
|
||||
const result = allQuery("SELECT * FROM groups WHERE account = ?", [publicKey]);
|
||||
result.then((rows) => {
|
||||
const infos : GroupInformation[] = [];
|
||||
for(let i = 0; i < rows.length; i++) {
|
||||
infos.push({
|
||||
groupId: rows[i].group_id,
|
||||
title: rows[i].title,
|
||||
description: rows[i].description,
|
||||
});
|
||||
}
|
||||
setCachedGroups(infos);
|
||||
});
|
||||
}
|
||||
|
||||
const loadCachedUsers = () => {
|
||||
const result = allQuery("SELECT * FROM cached_users", []);
|
||||
result.then((rows) => {
|
||||
const infos : UserInformation[] = [];
|
||||
for(let i = 0; i < rows.length; i++) {
|
||||
infos.push({
|
||||
publicKey: rows[i].public_key,
|
||||
verified: rows[i].verified,
|
||||
title: rows[i].title,
|
||||
username: rows[i].username,
|
||||
online: publicKey == rows[i].public_key ? OnlineState.ONLINE : OnlineState.OFFLINE
|
||||
});
|
||||
}
|
||||
infos.push(...systemAccounts);
|
||||
setCachedUsers(infos);
|
||||
});
|
||||
}
|
||||
|
||||
const updateGroupInformation = async (groupInfo : GroupInformation) => {
|
||||
const result = await getQuery("SELECT COUNT(*) as count FROM groups WHERE account = ? AND group_id = ?", [publicKey, groupInfo.groupId]);
|
||||
if(result.count > 0){
|
||||
/**
|
||||
* Обрабатываем только событие если строка в базе уже есть,
|
||||
* потому что добавление строки мы отрабатываем в другом месте
|
||||
*/
|
||||
await runQuery(`UPDATE groups SET title = ?, description = ? WHERE account = ? AND group_id = ?`,
|
||||
[groupInfo.title, groupInfo.description, publicKey, groupInfo.groupId]);
|
||||
}
|
||||
|
||||
if(cachedGroups.find((v) => v.groupId == groupInfo.groupId)){
|
||||
setCachedGroups((prev) => prev.map((group) => {
|
||||
if (group.groupId == groupInfo.groupId) {
|
||||
return {
|
||||
...group,
|
||||
title: groupInfo.title,
|
||||
description: groupInfo.description
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}));
|
||||
}else{
|
||||
setCachedGroups((prev) => ([
|
||||
...prev,
|
||||
{
|
||||
groupId: groupInfo.groupId,
|
||||
title: groupInfo.title,
|
||||
description: groupInfo.description
|
||||
}
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
const updateUserInformation = async (userInfo : UserInformation) => {
|
||||
const result = await getQuery("SELECT COUNT(*) as count FROM cached_users WHERE public_key = ?", [userInfo.publicKey]);
|
||||
if (result.count > 0) {
|
||||
await runQuery(`UPDATE cached_users SET title = ?, username = ?, verified = ? WHERE public_key = ?`, [userInfo.title, userInfo.username, userInfo.verified, userInfo.publicKey]);
|
||||
setCachedUsers((prev) => prev.map((user) => {
|
||||
if (user.publicKey == userInfo.publicKey) {
|
||||
return {
|
||||
...user,
|
||||
verified: userInfo.verified,
|
||||
title: userInfo.title,
|
||||
username: userInfo.username,
|
||||
online: userInfo.online,
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}));
|
||||
} else {
|
||||
await runQuery(`INSERT INTO cached_users (public_key, title, username, verified) VALUES (?, ?, ?, ?)`,
|
||||
[userInfo.publicKey, userInfo.title, userInfo.username, userInfo.verified]);
|
||||
setCachedUsers((prev) => ([
|
||||
...prev,
|
||||
{
|
||||
publicKey: userInfo.publicKey,
|
||||
title: userInfo.title,
|
||||
username: userInfo.username,
|
||||
online: OnlineState.OFFLINE,
|
||||
verified: userInfo.verified
|
||||
}
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<InformationContext.Provider value={{
|
||||
cachedUsers,
|
||||
cachedGroups,
|
||||
setCachedUsers,
|
||||
updateUserInformation,
|
||||
updateGroupInformation
|
||||
}}>
|
||||
{props.children}
|
||||
</InformationContext.Provider>
|
||||
)
|
||||
}
|
||||
38
app/providers/InformationProvider/useGroupInformation.ts
Normal file
38
app/providers/InformationProvider/useGroupInformation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useContext } from "react";
|
||||
import { GroupInformation, InformationContext } from "./InformationProvider";
|
||||
|
||||
export function useGroupInformation(groupId: string) : {
|
||||
groupInfo: GroupInformation,
|
||||
markAsDeleted: () => void,
|
||||
updateGroupInformation: (groupInfo: GroupInformation) => void
|
||||
} {
|
||||
const context = useContext(InformationContext);
|
||||
const {cachedGroups, updateGroupInformation} = context;
|
||||
const group : GroupInformation = cachedGroups.find((group: GroupInformation) => group.groupId == groupId.replace("#group:", ""));
|
||||
|
||||
if(!context || !context.cachedGroups) {
|
||||
throw new Error("useGroupInformation must be used within a InformationProvider");
|
||||
}
|
||||
|
||||
if(groupId.trim() == ""){
|
||||
throw new Error("Empty string passed to groupId with useGroupInformation hook");
|
||||
}
|
||||
|
||||
const markAsDeleted = () => {
|
||||
updateGroupInformation({
|
||||
groupId: groupId,
|
||||
title: "DELETED",
|
||||
description: "No description available."
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
groupInfo: {
|
||||
groupId: groupId,
|
||||
title: group ? group.title : "DELETED",
|
||||
description: group ? group.description : "No description available."
|
||||
},
|
||||
markAsDeleted,
|
||||
updateGroupInformation
|
||||
};
|
||||
}
|
||||
69
app/providers/InformationProvider/useGroupMembers.ts
Normal file
69
app/providers/InformationProvider/useGroupMembers.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGroupInformation } from "./useGroupInformation";
|
||||
import { PacketGroupInfo } from "../ProtocolProvider/protocol/packets/packet.group.info";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { useGroups } from "../DialogProvider/useGroups";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
|
||||
/**
|
||||
* Хук для получения участников группы
|
||||
* @param groupId ид группы
|
||||
* @param force принудительное обновление, если true, то будет
|
||||
* отправлен запрос на сервер и получен актуальный список участников,
|
||||
* если false, то будет возвращено значение из памяти
|
||||
* @returns
|
||||
*/
|
||||
export function useGroupMembers(groupId: string, force?: boolean) : {
|
||||
members: string[];
|
||||
loading: boolean;
|
||||
} {
|
||||
const send = useSender();
|
||||
const {normalize, hasGroup} = useGroups();
|
||||
const {markAsDeleted} = useGroupInformation(normalize(groupId));
|
||||
const [members, setMembers] = useMemory<string[]>("members_group_" + groupId, [], true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateGroupMembers();
|
||||
}, [groupId]);
|
||||
|
||||
const updateGroupMembers = () => {
|
||||
if((!hasGroup(groupId) && groupId.length > 16)
|
||||
|| (members.length > 0 && !force)
|
||||
){
|
||||
/**
|
||||
* Не ID группы, пропускаем. Если ид группы больше 16 символов
|
||||
* и не начинается с #group:, то это не группа.
|
||||
* Однако если ID меньше 16 символов, то это и не
|
||||
* публичный ключ. Значит скорее всего это ID группы.
|
||||
*
|
||||
* Это условие нужно для оптимизации запросов на сервер.
|
||||
*/
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
let packet = new PacketGroupInfo();
|
||||
packet.setGroupId(normalize(groupId));
|
||||
send(packet);
|
||||
}
|
||||
|
||||
usePacket(0x12, (packet: PacketGroupInfo) => {
|
||||
if(packet.getGroupId() != normalize(groupId)){
|
||||
return;
|
||||
}
|
||||
const members = packet.getMembers();
|
||||
if(members.length <= 0){
|
||||
setLoading(false);
|
||||
markAsDeleted();
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
setMembers(members);
|
||||
}, [groupId]);
|
||||
|
||||
return {
|
||||
members,
|
||||
loading
|
||||
};
|
||||
}
|
||||
31
app/providers/InformationProvider/useSearch.ts
Normal file
31
app/providers/InformationProvider/useSearch.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { useProtocol } from "../ProtocolProvider/useProtocol";
|
||||
import { PacketSearch, PacketSearchUser } from "../ProtocolProvider/protocol/packets/packet.search";
|
||||
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
|
||||
|
||||
export function useSearch() : [
|
||||
PacketSearchUser[],
|
||||
(username : string) => void,
|
||||
React.Dispatch<React.SetStateAction<PacketSearchUser[]>>
|
||||
] {
|
||||
const {protocol} = useProtocol();
|
||||
const [searchResults, setSearchResults] = useState<PacketSearchUser[]>([]);
|
||||
const privateKeyHash = usePrivateKeyHash();
|
||||
|
||||
protocol.waitPacketOnce(0x03, (packet : PacketSearch) => {
|
||||
setSearchResults(packet.getUsers());
|
||||
});
|
||||
|
||||
const search = (username : string) => {
|
||||
let packet = new PacketSearch();
|
||||
packet.setSearch(username);
|
||||
packet.setPrivateKey(privateKeyHash);
|
||||
protocol.sendPacket(packet);
|
||||
}
|
||||
|
||||
return [
|
||||
searchResults,
|
||||
search,
|
||||
setSearchResults
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
import { GroupInformation, InformationContext } from "./InformationProvider";
|
||||
|
||||
export function useUpdateGroupInformation() : (groupInfo: GroupInformation) => void {
|
||||
const context = useContext(InformationContext);
|
||||
const {updateGroupInformation} = context;
|
||||
|
||||
if(!context || !context.cachedGroups) {
|
||||
throw new Error("useUpdateGroupInformation must be used within a InformationProvider");
|
||||
}
|
||||
|
||||
return updateGroupInformation;
|
||||
}
|
||||
36
app/providers/InformationProvider/useUserCache.ts
Normal file
36
app/providers/InformationProvider/useUserCache.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UserInformation } from "./InformationProvider";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
|
||||
/**
|
||||
* Информация запрашивается только из кэша, и в отличии от вызова
|
||||
* хука useUserInformation не использует подгрузку с сервера,
|
||||
* то есть если в кэше информации нет то вернет пустое значение
|
||||
* @param publicKey публичный ключ
|
||||
*/
|
||||
export function useUserCache(publicKey : string) {
|
||||
const {getQuery} = useDatabase();
|
||||
const [userInfo, setUserInfo] =
|
||||
useState<UserInformation|undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadFromCacheDatabase();
|
||||
}, [publicKey]);
|
||||
|
||||
const loadFromCacheDatabase = async () => {
|
||||
const result = await getQuery("SELECT * FROM `cached_users` WHERE `public_key` = ?", [publicKey]);
|
||||
if(!result){
|
||||
return;
|
||||
}
|
||||
setUserInfo({
|
||||
publicKey: result.public_key,
|
||||
verified: result.verified,
|
||||
title: result.title,
|
||||
username: result.username,
|
||||
online: OnlineState.OFFLINE
|
||||
});
|
||||
}
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
23
app/providers/InformationProvider/useUserCacheFunc.ts
Normal file
23
app/providers/InformationProvider/useUserCacheFunc.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { UserInformation } from "./InformationProvider";
|
||||
import { OnlineState } from "../ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
|
||||
export function useUserCacheFunc() {
|
||||
const {getQuery} = useDatabase();
|
||||
|
||||
const getUserInformation = async (publicKey: string): Promise<UserInformation | null> => {
|
||||
const result = await getQuery("SELECT * FROM `cached_users` WHERE `public_key` = ?", [publicKey]);
|
||||
if(!result){
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
publicKey: result.public_key,
|
||||
verified: result.verified,
|
||||
title: result.title,
|
||||
username: result.username,
|
||||
online: OnlineState.OFFLINE
|
||||
};
|
||||
}
|
||||
|
||||
return getUserInformation;
|
||||
}
|
||||
132
app/providers/InformationProvider/useUserInformation.ts
Normal file
132
app/providers/InformationProvider/useUserInformation.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { InformationContext, UserInformation } from "./InformationProvider";
|
||||
import { PacketOnlineSubscribe } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinesubscribe";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { PacketSearch } from "@/app/providers/ProtocolProvider/protocol/packets/packet.search";
|
||||
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { useBlacklist } from "../BlacklistProvider/useBlacklist";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
import { useSystemAccounts } from "../SystemAccountsProvider/useSystemAccounts";
|
||||
|
||||
export function useUserInformation(publicKey: string) : [
|
||||
UserInformation,
|
||||
(userInfo: UserInformation) => Promise<void>,
|
||||
() => void,
|
||||
boolean
|
||||
] {
|
||||
const context = useContext(InformationContext);
|
||||
const send = useSender();
|
||||
const privateKey = usePrivateKeyHash();
|
||||
const [onlineSubscribes, setOnlineSubscribes] = useMemory<string[]>("online_subscribes", [], true);
|
||||
const {cachedUsers, updateUserInformation} = context;
|
||||
const user : UserInformation = cachedUsers.find((user: UserInformation) => user.publicKey == publicKey);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ownPublicKey = usePublicKey();
|
||||
const [blocked] = useBlacklist(publicKey);
|
||||
const {info, warn} = useConsoleLogger('useUserInformation');
|
||||
const systemAccounts = useSystemAccounts();
|
||||
|
||||
if(!context || !context.cachedUsers) {
|
||||
throw new Error("useUserInformation must be used within a InformationProvider");
|
||||
}
|
||||
|
||||
const forceUpdateUserInformation = () => {
|
||||
if(publicKey.indexOf("#group:") !== -1){
|
||||
/**
|
||||
* This is group, only users can be force updated
|
||||
*/
|
||||
info("Force update skipped for group " + publicKey);
|
||||
return;
|
||||
}
|
||||
if(systemAccounts.find((acc) => acc.publicKey == publicKey)){
|
||||
/**
|
||||
* System account has no updates, its hardcoded display
|
||||
* name and user name
|
||||
*/
|
||||
info("System account not need force update");
|
||||
return;
|
||||
}
|
||||
if(blocked){
|
||||
warn("User is blocked, no force update " + publicKey);
|
||||
return;
|
||||
}
|
||||
warn("Force update " + publicKey);
|
||||
let packetSearch = new PacketSearch();
|
||||
packetSearch.setSearch(publicKey);
|
||||
packetSearch.setPrivateKey(privateKey);
|
||||
send(packetSearch);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Подписываемся на статус пользователя онлайн или не онлайн
|
||||
* если еще не подписаны
|
||||
*/
|
||||
if(onlineSubscribes.indexOf(publicKey) !== -1
|
||||
|| publicKey.indexOf("#group:") !== -1
|
||||
|| publicKey == ownPublicKey
|
||||
|| publicKey.trim() == ''
|
||||
|| blocked){
|
||||
/**
|
||||
* Уже подписаны на онлайн статус этого пользователя или это группа
|
||||
*/
|
||||
return;
|
||||
}
|
||||
let subscribePacket = new PacketOnlineSubscribe();
|
||||
subscribePacket.setPrivateKey(privateKey);
|
||||
subscribePacket.addPublicKey(publicKey);
|
||||
send(subscribePacket);
|
||||
setOnlineSubscribes((prev) => [...prev, publicKey]);
|
||||
}, [blocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if(user || publicKey.trim() == ''){
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
let packetSearch = new PacketSearch();
|
||||
packetSearch.setSearch(publicKey);
|
||||
packetSearch.setPrivateKey(privateKey);
|
||||
send(packetSearch);
|
||||
}, [publicKey, privateKey, user]);
|
||||
|
||||
usePacket(0x03, (packet : PacketSearch) => {
|
||||
const users = packet.getUsers();
|
||||
if (users.length > 0 && users[0].publicKey == publicKey) {
|
||||
if( user &&
|
||||
user.username == users[0].username &&
|
||||
user.verified == users[0].verified &&
|
||||
user.title == users[0].title
|
||||
){
|
||||
/**
|
||||
* No update readed from server, stop rerender
|
||||
*/
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
updateUserInformation({
|
||||
publicKey: users[0].publicKey,
|
||||
avatar: "", // No avatar in search packet
|
||||
username: users[0].username,
|
||||
title: users[0].title,
|
||||
online: users[0].online,
|
||||
verified: users[0].verified
|
||||
});
|
||||
}
|
||||
}, [publicKey, privateKey]);
|
||||
|
||||
return [
|
||||
{
|
||||
title: user ? user.title : "DELETED",
|
||||
username: user ? user.username : "",
|
||||
publicKey: user ? user.publicKey : "",
|
||||
online: user ? user.online : OnlineState.OFFLINE,
|
||||
verified: user ? user.verified : 0
|
||||
}, updateUserInformation, forceUpdateUserInformation, loading
|
||||
]
|
||||
|
||||
}
|
||||
11
app/providers/MemoryProvider/MemoryProvider.tsx
Normal file
11
app/providers/MemoryProvider/MemoryProvider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createContext, useState } from 'react';
|
||||
export const MemoryContext = createContext<any>({});
|
||||
|
||||
export function MemoryProvider({children}) {
|
||||
const [memory, setMemory] = useState({});
|
||||
return (
|
||||
<MemoryContext.Provider value={{memory, setMemory}}>
|
||||
{children}
|
||||
</MemoryContext.Provider>
|
||||
)
|
||||
}
|
||||
27
app/providers/MemoryProvider/useMemory.ts
Normal file
27
app/providers/MemoryProvider/useMemory.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useContext } from "react";
|
||||
import { MemoryContext } from "./MemoryProvider";
|
||||
|
||||
export function useMemory<T>(selector : string, def : T, writable : boolean = true) : [T, (value: T | ((prevValue: T) => T)) => void] {
|
||||
const {memory, setMemory} = useContext(MemoryContext);
|
||||
const value = () => {
|
||||
if(memory[selector] == undefined){
|
||||
return def;
|
||||
}
|
||||
return memory[selector];
|
||||
};
|
||||
const setValue = (value: T | ((prevValue: T) => T)) => {
|
||||
if (!writable && memory[selector] == undefined) {
|
||||
return;
|
||||
}
|
||||
const newValue = typeof value === "function" ?
|
||||
(value as (prevValue: T) => T)(memory[selector] ?? def)
|
||||
: value;
|
||||
setMemory((prev : any) => ({
|
||||
...prev,
|
||||
[selector]: typeof value === "function"
|
||||
? (value as (prevValue: T) => T)(prev[selector] ?? def)
|
||||
: newValue,
|
||||
}));
|
||||
}
|
||||
return [value(), setValue];
|
||||
}
|
||||
12
app/providers/MemoryProvider/useMemoryClean.ts
Normal file
12
app/providers/MemoryProvider/useMemoryClean.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { MemoryContext } from "./MemoryProvider";
|
||||
|
||||
export function useMemoryClean() {
|
||||
const {setMemory} = useContext(MemoryContext);
|
||||
|
||||
const clean = () => {
|
||||
setMemory({});
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
BIN
app/providers/ProtocolProvider/.DS_Store
vendored
Normal file
BIN
app/providers/ProtocolProvider/.DS_Store
vendored
Normal file
Binary file not shown.
80
app/providers/ProtocolProvider/ProtocolProvider.tsx
Normal file
80
app/providers/ProtocolProvider/ProtocolProvider.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import Protocol from "@/app/providers/ProtocolProvider/protocol/protocol";
|
||||
import { createContext, useEffect, useMemo, useState } from "react";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
|
||||
import { useLogger } from "@/app/hooks/useLogger";
|
||||
import { useMemory } from "../MemoryProvider/useMemory";
|
||||
import { useDeviceId } from "../DeviceProvider/useDeviceId";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export enum ProtocolState {
|
||||
CONNECTED,
|
||||
HANDSHAKE_EXCHANGE,
|
||||
DISCONNECTED,
|
||||
RECONNECTING,
|
||||
DEVICE_VERIFICATION_REQUIRED
|
||||
}
|
||||
|
||||
export const ProtocolContext = createContext<[Protocol|null, ProtocolState]>([null, ProtocolState.DISCONNECTED]);
|
||||
|
||||
interface ProtocolProviderProps {
|
||||
children: React.ReactNode;
|
||||
serverAddress : string;
|
||||
}
|
||||
|
||||
export function ProtocolProvider(props : ProtocolProviderProps) {
|
||||
const publicKey = usePublicKey();
|
||||
const privateKey = usePrivateKeyHash();
|
||||
const protocol = useMemo(() => {
|
||||
return new Protocol(props.serverAddress)
|
||||
}, [props.serverAddress, publicKey, privateKey]);
|
||||
const log = useLogger('ProtocolProvider');
|
||||
const [connect, setConnect] = useState(ProtocolState.DISCONNECTED);
|
||||
const [_, setOnlineSubscribes] = useMemory<string[]>("online_subscribes", [], true);
|
||||
const deviceId = useDeviceId();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if(publicKey.trim() == ""
|
||||
|| privateKey.trim() == "" || deviceId == "") {
|
||||
return;
|
||||
}
|
||||
const device = {
|
||||
deviceId: deviceId,
|
||||
deviceName: window.deviceName || "Unknown Device"
|
||||
}
|
||||
protocol.startHandshakeExchange(publicKey, privateKey, device);
|
||||
protocol.on('connect', () => {
|
||||
protocol.startHandshakeExchange(publicKey, privateKey, device);
|
||||
/**
|
||||
* Сбрасываем подписки на онлайн статусы пользователей
|
||||
* так как при переподключении они слетают
|
||||
*/
|
||||
setOnlineSubscribes([]);
|
||||
});
|
||||
protocol.on('reconnect', () => {
|
||||
log("Connection lost, reconnecting and starting handshake exchange");
|
||||
setConnect(ProtocolState.RECONNECTING);
|
||||
});
|
||||
protocol.on('handshake_start', () => {
|
||||
log("Handshake exchange started");
|
||||
setConnect(ProtocolState.HANDSHAKE_EXCHANGE);
|
||||
});
|
||||
protocol.on('handshake_complete', () => {
|
||||
log("Handshake exchange complete");
|
||||
setConnect(ProtocolState.CONNECTED);
|
||||
});
|
||||
protocol.on('handshake_need_device_verification', () => {
|
||||
log("Handshake exchange needs device verification");
|
||||
setConnect(ProtocolState.DEVICE_VERIFICATION_REQUIRED);
|
||||
navigate('/deviceconfirm');
|
||||
});
|
||||
}, [publicKey, privateKey, deviceId]);
|
||||
|
||||
return (
|
||||
<ProtocolContext.Provider value={[protocol, connect]}>
|
||||
{props.children}
|
||||
</ProtocolContext.Provider>
|
||||
);
|
||||
|
||||
}
|
||||
BIN
app/providers/ProtocolProvider/protocol/.DS_Store
vendored
Normal file
BIN
app/providers/ProtocolProvider/protocol/.DS_Store
vendored
Normal file
Binary file not shown.
28
app/providers/ProtocolProvider/protocol/packet.ts
Normal file
28
app/providers/ProtocolProvider/protocol/packet.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Stream from "./stream";
|
||||
|
||||
/**
|
||||
* Packet abstract class
|
||||
*/
|
||||
export default abstract class Packet {
|
||||
|
||||
/**
|
||||
* Get the packet ID
|
||||
* @returns packet ID
|
||||
*/
|
||||
public abstract getPacketId(): number;
|
||||
/**
|
||||
* Use the stream to read the packet and fill structure
|
||||
* @param stream stream
|
||||
*/
|
||||
public abstract _receive(stream: Stream): void;
|
||||
/**
|
||||
* Use the stream to write the packet and return the stream
|
||||
* @returns stream
|
||||
*/
|
||||
public abstract _send(): Promise<Stream> | Stream;
|
||||
|
||||
public clone(): Packet {
|
||||
return new (this as any).constructor();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketAppUpdate extends Packet {
|
||||
|
||||
private url: string = "";
|
||||
private version: string = "";
|
||||
private kernelVersionRequired: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x0E;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.url = stream.readString();
|
||||
this.version = stream.readString();
|
||||
this.kernelVersionRequired = stream.readString();
|
||||
}
|
||||
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
let stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.url);
|
||||
stream.writeString(this.version);
|
||||
stream.writeString(this.kernelVersionRequired);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getUrl(): string {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public setUrl(url: string): void {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public getVersion(): string {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public setVersion(version: string): void {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public getKernelVersionRequired(): string {
|
||||
return this.kernelVersionRequired;
|
||||
}
|
||||
|
||||
public setKernelVersionRequired(kernelVersionRequired: string): void {
|
||||
this.kernelVersionRequired = kernelVersionRequired;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketAvatar extends Packet {
|
||||
|
||||
private privateKey : string = "";
|
||||
private fromPublicKey: string = "";
|
||||
private toPublicKey: string = "";
|
||||
private blob: string = "";
|
||||
|
||||
private chachaKey: string = "";
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x0C;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.privateKey = stream.readString();
|
||||
this.fromPublicKey = stream.readString();
|
||||
this.toPublicKey = stream.readString();
|
||||
this.chachaKey = stream.readString();
|
||||
this.blob = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeString(this.fromPublicKey);
|
||||
stream.writeString(this.toPublicKey);
|
||||
stream.writeString(this.chachaKey);
|
||||
stream.writeString(this.blob);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setFromPublicKey(fromPublicKey: string): void {
|
||||
this.fromPublicKey = fromPublicKey;
|
||||
}
|
||||
|
||||
public setToPublicKey(toPublicKey: string): void {
|
||||
this.toPublicKey = toPublicKey;
|
||||
}
|
||||
|
||||
public getFromPublicKey(): string {
|
||||
return this.fromPublicKey;
|
||||
}
|
||||
|
||||
public getToPublicKey(): string {
|
||||
return this.toPublicKey;
|
||||
}
|
||||
|
||||
public setPrivateKey(hash: string): void {
|
||||
this.privateKey = hash;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
|
||||
public setBlob(blob: string): void {
|
||||
this.blob = blob;
|
||||
}
|
||||
|
||||
public getBlob(): string {
|
||||
return this.blob;
|
||||
}
|
||||
|
||||
public setChachaKey(key: string): void {
|
||||
this.chachaKey = key;
|
||||
}
|
||||
|
||||
public getChachaKey(): string {
|
||||
return this.chachaKey;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketCreateGroup extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x11;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setGroupId(groupId: string) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public getGroupId(): string {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketDelivery extends Packet {
|
||||
|
||||
private messageId: string = "";
|
||||
private toPublicKey: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x08;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.toPublicKey = stream.readString();
|
||||
this.messageId = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.toPublicKey);
|
||||
stream.writeString(this.messageId);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setMessageId(messageId: string) {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
public getMessageId(): string {
|
||||
return this.messageId;
|
||||
}
|
||||
|
||||
public setToPublicKey(toPublicKey: string) {
|
||||
this.toPublicKey = toPublicKey;
|
||||
}
|
||||
|
||||
public getToPublicKey(): string {
|
||||
return this.toPublicKey;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketGroupBan extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
private publicKey: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x16;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
this.publicKey = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
stream.writeString(this.publicKey);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getGroupId(): string {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
public getPublicKey(): string {
|
||||
return this.publicKey;
|
||||
}
|
||||
|
||||
public setGroupId(groupId: string): void {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public setPublicKey(publicKey: string): void {
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
|
||||
export class PacketGroupInfo extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
private members: string[] = [];
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x12;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
const membersCount = stream.readInt16();
|
||||
this.members = [];
|
||||
for(let i = 0; i < membersCount; i++) {
|
||||
this.members.push(stream.readString());
|
||||
}
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
stream.writeInt16(this.members.length);
|
||||
this.members.forEach((member) => {
|
||||
stream.writeString(member);
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getGroupId(): string {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
public setGroupId(groupId: string): void {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public setMembers(members: string[]): void {
|
||||
this.members = members;
|
||||
}
|
||||
|
||||
public getMembers(): string[] {
|
||||
return this.members;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Packet from "../packet"
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum GroupStatus {
|
||||
JOINED = 0,
|
||||
INVALID = 1,
|
||||
NOT_JOINED = 2,
|
||||
BANNED = 3
|
||||
}
|
||||
|
||||
export class PacketGroupInviteInfo extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
private membersCount = 0;
|
||||
private groupStatus: GroupStatus = GroupStatus.NOT_JOINED;
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x13;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
this.membersCount = stream.readInt16();
|
||||
this.groupStatus = stream.readInt8();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
stream.writeInt16(this.membersCount);
|
||||
stream.writeInt8(this.groupStatus);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setGroupId(groupId: string) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public getGroupId(): string {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
public setMembersCount(count: number) {
|
||||
this.membersCount = count;
|
||||
}
|
||||
|
||||
public getMembersCount(): number {
|
||||
return this.membersCount;
|
||||
}
|
||||
|
||||
public setGroupStatus(status: GroupStatus) {
|
||||
this.groupStatus = status;
|
||||
}
|
||||
|
||||
public getGroupStatus(): GroupStatus {
|
||||
return this.groupStatus;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum GroupStatus {
|
||||
JOINED = 0,
|
||||
INVALID = 1,
|
||||
NOT_JOINED = 2,
|
||||
BANNED = 3
|
||||
}
|
||||
|
||||
export class PacketGroupJoin extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
private groupStatus: GroupStatus = GroupStatus.NOT_JOINED;
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x14;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
this.groupStatus = stream.readInt8();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
stream.writeInt8(this.groupStatus);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setGroupId(groupId: string) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public getGroupId(): string {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
public setGroupStatus(groupStatus: GroupStatus) {
|
||||
this.groupStatus = groupStatus;
|
||||
}
|
||||
|
||||
public getGroupStatus(): GroupStatus {
|
||||
return this.groupStatus;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketGroupLeave extends Packet {
|
||||
|
||||
private groupId: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x15;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.groupId = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.groupId);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setGroupId(groupId: string) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public getGroupId(): string {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum HandshakeState {
|
||||
COMPLETED,
|
||||
NEED_DEVICE_VERIFICATION
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hadshake packet
|
||||
* ID: 0x00
|
||||
*
|
||||
* The handshake packet is the first packet sent by the client to the server.
|
||||
* It contains the hash of the client's public key and the public key itself.
|
||||
*/
|
||||
export default class PacketHandshake extends Packet {
|
||||
private privateKey: string = "";
|
||||
private publicKey: string = "";
|
||||
private protocolVersion: number = 1;
|
||||
/**
|
||||
* Interval seconds
|
||||
*/
|
||||
private heartbeatInterval : number = 15;
|
||||
private device: Device = {
|
||||
deviceId: "",
|
||||
deviceName: ""
|
||||
};
|
||||
private handshakeState:
|
||||
HandshakeState = HandshakeState.NEED_DEVICE_VERIFICATION;
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x00;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.privateKey = stream.readString();
|
||||
this.publicKey = stream.readString();
|
||||
this.protocolVersion = stream.readInt8();
|
||||
this.heartbeatInterval = stream.readInt8();
|
||||
this.device = {
|
||||
deviceId: stream.readString(),
|
||||
deviceName: stream.readString()
|
||||
}
|
||||
this.handshakeState = stream.readInt8();
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeString(this.publicKey);
|
||||
stream.writeInt8(this.protocolVersion);
|
||||
stream.writeInt8(this.heartbeatInterval);
|
||||
stream.writeString(this.device.deviceId);
|
||||
stream.writeString(this.device.deviceName);
|
||||
stream.writeInt8(this.handshakeState);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
|
||||
public getPublicKey(): string {
|
||||
return this.publicKey;
|
||||
}
|
||||
|
||||
public setPrivateKey(privateKey: string): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public setPublicKey(publicKey: string): void {
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
public getProtocolVersion(): number {
|
||||
return this.protocolVersion;
|
||||
}
|
||||
|
||||
public setProtocolVersion(protocolVersion: number): void {
|
||||
this.protocolVersion = protocolVersion;
|
||||
}
|
||||
|
||||
public getHeartbeatInterval(): number {
|
||||
return this.heartbeatInterval;
|
||||
}
|
||||
|
||||
public setHeartbeatInterval(heartbeatInterval: number): void {
|
||||
this.heartbeatInterval = heartbeatInterval;
|
||||
}
|
||||
|
||||
public getDevice(): Device {
|
||||
return this.device;
|
||||
}
|
||||
|
||||
public setDevice(device: Device): void {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
public getHandshakeState(): HandshakeState {
|
||||
return this.handshakeState;
|
||||
}
|
||||
|
||||
public setHandshakeState(handshakeState: HandshakeState): void {
|
||||
this.handshakeState = handshakeState;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketKernelUpdate extends Packet {
|
||||
|
||||
private url: string = "";
|
||||
private version: string = "";
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x0D;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.url = stream.readString();
|
||||
this.version = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
let stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.url);
|
||||
stream.writeString(this.version);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getUrl(): string {
|
||||
return this.url;
|
||||
}
|
||||
|
||||
public setUrl(url: string): void {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public getVersion(): string {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public setVersion(version: string): void {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum AttachmentType {
|
||||
IMAGE = 0,
|
||||
MESSAGES = 1,
|
||||
FILE = 2,
|
||||
AVATAR = 3
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
blob: string;
|
||||
type: AttachmentType;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export class PacketMessage extends Packet {
|
||||
|
||||
private fromPublicKey: string = "";
|
||||
private toPublicKey: string = "";
|
||||
private content: string = "";
|
||||
private chachaKey: string = "";
|
||||
private timestamp: number = 0;
|
||||
private privateKey: string = "";
|
||||
private messageId: string = "";
|
||||
/**
|
||||
* Закодированный с помощью AES ключ chacha, нужен
|
||||
* для последующей синхронизации своих же сообщений
|
||||
*/
|
||||
private aesChachaKey: string = "";
|
||||
|
||||
private attachments: Attachment[] = [];
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x06;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.fromPublicKey = stream.readString();
|
||||
this.toPublicKey = stream.readString();
|
||||
this.content = stream.readString();
|
||||
this.chachaKey = stream.readString();
|
||||
this.timestamp = stream.readInt64();
|
||||
this.privateKey = stream.readString();
|
||||
this.messageId = stream.readString();
|
||||
let attachmentsCount = stream.readInt8();
|
||||
for(let i = 0; i < attachmentsCount; i++){
|
||||
let id = stream.readString();
|
||||
let preview = stream.readString();
|
||||
let blob = stream.readString();
|
||||
let type = stream.readInt8() as AttachmentType;
|
||||
this.attachments.push({id, preview, type, blob});
|
||||
}
|
||||
this.aesChachaKey = stream.readString();
|
||||
}
|
||||
|
||||
public async _send(): Promise<Stream> {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.fromPublicKey);
|
||||
stream.writeString(this.toPublicKey);
|
||||
stream.writeString(this.content);
|
||||
stream.writeString(this.chachaKey);
|
||||
stream.writeInt64(this.timestamp);
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeString(this.messageId);
|
||||
stream.writeInt8(this.attachments.length);
|
||||
for(let i = 0; i < this.attachments.length; i++){
|
||||
stream.writeString(this.attachments[i].id);
|
||||
stream.writeString(this.attachments[i].preview);
|
||||
stream.writeString(this.attachments[i].blob);
|
||||
stream.writeInt8(this.attachments[i].type);
|
||||
}
|
||||
stream.writeString(this.aesChachaKey);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setAttachments(attachments: Attachment[]): void {
|
||||
this.attachments = attachments;
|
||||
}
|
||||
|
||||
public getAttachments(): Attachment[] {
|
||||
return this.attachments;
|
||||
}
|
||||
|
||||
public setMessageId(messageId: string): void {
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
public getMessageId(): string {
|
||||
return this.messageId;
|
||||
}
|
||||
|
||||
public setTimestamp(timestamp: number): void {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public getTimestamp() : number {
|
||||
return this.timestamp;
|
||||
}
|
||||
|
||||
public getFromPublicKey(): string {
|
||||
return this.fromPublicKey;
|
||||
}
|
||||
|
||||
public getToPublicKey(): string {
|
||||
return this.toPublicKey;
|
||||
}
|
||||
|
||||
public getContent(): string {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
public getChachaKey(): string {
|
||||
return this.chachaKey;
|
||||
}
|
||||
|
||||
public setFromPublicKey(fromPublicKey: string): void {
|
||||
this.fromPublicKey = fromPublicKey;
|
||||
}
|
||||
|
||||
public setToPublicKey(toPublicKey: string): void {
|
||||
this.toPublicKey = toPublicKey;
|
||||
}
|
||||
|
||||
public setContent(content: string): void {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public setChachaKey(chachaKey: string): void {
|
||||
this.chachaKey = chachaKey;
|
||||
}
|
||||
|
||||
public setPrivateKey(privateKey: string): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
|
||||
public getAesChachaKey() : string {
|
||||
return this.aesChachaKey;
|
||||
}
|
||||
|
||||
public setAesChachaKey(aesChachaKey: string) {
|
||||
this.aesChachaKey = aesChachaKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum OnlineState {
|
||||
ONLINE,
|
||||
OFFLINE
|
||||
}
|
||||
|
||||
export interface PublicKeyOnlineState {
|
||||
publicKey: string;
|
||||
state: OnlineState;
|
||||
}
|
||||
|
||||
export class PacketOnlineState extends Packet {
|
||||
|
||||
private publicKeysState: PublicKeyOnlineState[] = [];
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x05;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
const publicKeyCount = stream.readInt8();
|
||||
for (let i = 0; i < publicKeyCount; i++) {
|
||||
const publicKey = stream.readString();
|
||||
const state = stream.readBoolean() ? OnlineState.ONLINE : OnlineState.OFFLINE;
|
||||
this.publicKeysState.push({ publicKey, state });
|
||||
}
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt8(this.publicKeysState.length);
|
||||
for (const publicKeyState of this.publicKeysState) {
|
||||
stream.writeString(publicKeyState.publicKey);
|
||||
stream.writeBoolean(publicKeyState.state === OnlineState.ONLINE);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
public addPublicKeyState(publicKey: string, state: OnlineState): void {
|
||||
this.publicKeysState.push({ publicKey, state });
|
||||
}
|
||||
|
||||
public getPublicKeysState(): PublicKeyOnlineState[] {
|
||||
return this.publicKeysState;
|
||||
}
|
||||
|
||||
public setPublicKeysState(publicKeysState: PublicKeyOnlineState[]): void {
|
||||
this.publicKeysState = publicKeysState;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketOnlineSubscribe extends Packet {
|
||||
|
||||
private privateKey : string = "";
|
||||
private publicKeys: string[] = [];
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x04;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.privateKey = stream.readString();
|
||||
const keysCount = stream.readInt16();
|
||||
for (let i = 0; i < keysCount; i++) {
|
||||
this.publicKeys.push(stream.readString());
|
||||
}
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeInt16(this.publicKeys.length);
|
||||
for (const key of this.publicKeys) {
|
||||
stream.writeString(key);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
|
||||
public setPrivateKey(privateKey: string): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public getPublicKeys(): string[] {
|
||||
return this.publicKeys;
|
||||
}
|
||||
|
||||
public setPublicKeys(publicKeys: string[]): void {
|
||||
this.publicKeys = publicKeys;
|
||||
}
|
||||
|
||||
public addPublicKey(publicKey: string): void {
|
||||
this.publicKeys.push(publicKey);
|
||||
}
|
||||
|
||||
public removePublicKey(publicKey: string): void {
|
||||
const index = this.publicKeys.indexOf(publicKey);
|
||||
if (index > -1) {
|
||||
this.publicKeys.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
|
||||
/**
|
||||
* Push Notification actions
|
||||
* SUBSCRIBE - подписаться на push-уведомления
|
||||
* UNSUBSCRIBE - отписаться от push-уведомлений
|
||||
*/
|
||||
export enum PushNotificationAction {
|
||||
SUBSCRIBE = 0,
|
||||
UNSUBSCRIBE = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Push Notification packet
|
||||
* ID: 0x10
|
||||
*
|
||||
* Этот пакет отправляется клиентом для подписки на push-уведомления.
|
||||
* Отправлять можно только в том случае, если пользователь уже прошел
|
||||
* рукопожатие и установил соединение.
|
||||
*/
|
||||
export class PacketPushNotification extends Packet {
|
||||
private notificationsToken: string = "";
|
||||
private action: PushNotificationAction = PushNotificationAction.SUBSCRIBE;
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x10;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.notificationsToken = stream.readString();
|
||||
this.action = stream.readInt8();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.notificationsToken);
|
||||
stream.writeInt8(this.action);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getNotificationsToken(): string {
|
||||
return this.notificationsToken;
|
||||
}
|
||||
|
||||
public setNotificationsToken(notificationsToken: string): void {
|
||||
this.notificationsToken = notificationsToken;
|
||||
}
|
||||
|
||||
public getAction(): PushNotificationAction {
|
||||
return this.action;
|
||||
}
|
||||
|
||||
public setAction(action: PushNotificationAction): void {
|
||||
this.action = action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketRead extends Packet {
|
||||
|
||||
private privateKey : string = "";
|
||||
private fromPublicKey: string = "";
|
||||
private toPublicKey: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x07;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.privateKey = stream.readString();
|
||||
this.fromPublicKey = stream.readString();
|
||||
this.toPublicKey = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeString(this.fromPublicKey);
|
||||
stream.writeString(this.toPublicKey);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setFromPublicKey(fromPublicKey: string): void {
|
||||
this.fromPublicKey = fromPublicKey;
|
||||
}
|
||||
|
||||
public setToPublicKey(toPublicKey: string): void {
|
||||
this.toPublicKey = toPublicKey;
|
||||
}
|
||||
|
||||
public getFromPublicKey(): string {
|
||||
return this.fromPublicKey;
|
||||
}
|
||||
|
||||
public getToPublicKey(): string {
|
||||
return this.toPublicKey;
|
||||
}
|
||||
|
||||
public setPrivateKey(privateKey: string): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketRequestTransport extends Packet {
|
||||
|
||||
private transportServer: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x0F;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.transportServer = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.transportServer);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getTransportServer(): string {
|
||||
return this.transportServer;
|
||||
}
|
||||
|
||||
public setTransportServer(transportServer: string): void {
|
||||
this.transportServer = transportServer;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
|
||||
export class PacketRequestUpdate extends Packet {
|
||||
|
||||
private kernelVersion: string = "";
|
||||
private appVersion: string = "";
|
||||
private arch: string = "";
|
||||
private platform: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0xA;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.kernelVersion = stream.readString();
|
||||
this.appVersion = stream.readString();
|
||||
this.arch = stream.readString();
|
||||
this.platform = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Promise<Stream> | Stream {
|
||||
let stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.kernelVersion);
|
||||
stream.writeString(this.appVersion);
|
||||
stream.writeString(this.arch);
|
||||
stream.writeString(this.platform);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setKernelVersion(version: string): void {
|
||||
this.kernelVersion = version;
|
||||
}
|
||||
|
||||
public getKernelVersion(): string {
|
||||
return this.kernelVersion;
|
||||
}
|
||||
|
||||
public setAppVersion(version: string): void {
|
||||
this.appVersion = version;
|
||||
}
|
||||
|
||||
public getAppVersion(): string {
|
||||
return this.appVersion;
|
||||
}
|
||||
|
||||
public setArch(arch: string): void {
|
||||
this.arch = arch;
|
||||
}
|
||||
|
||||
public getArch(): string {
|
||||
return this.arch;
|
||||
}
|
||||
|
||||
public setPlatform(platform: string): void {
|
||||
this.platform = platform;
|
||||
}
|
||||
|
||||
public getPlatform(): string {
|
||||
return this.platform;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export enum ResultCode {
|
||||
SUCCESS = 0,
|
||||
ERROR = 1,
|
||||
INVALID = 2,
|
||||
USERNAME_TAKEN = 3,
|
||||
}
|
||||
|
||||
export class PacketResult extends Packet {
|
||||
|
||||
private resultCode: ResultCode = 0;
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x02;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.resultCode = stream.readInt16();
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeInt16(this.resultCode);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getResultCode(): ResultCode {
|
||||
return this.resultCode;
|
||||
}
|
||||
|
||||
public setResultCode(resultCode: ResultCode): void {
|
||||
this.resultCode = resultCode;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
import { OnlineState } from "./packet.onlinestate";
|
||||
|
||||
export interface PacketSearchUser {
|
||||
username: string;
|
||||
title: string;
|
||||
publicKey: string;
|
||||
verified: number;
|
||||
online: OnlineState;
|
||||
}
|
||||
|
||||
export class PacketSearch extends Packet {
|
||||
|
||||
private privateKey : string = "";
|
||||
private search : string = "";
|
||||
private users : PacketSearchUser[] = [];
|
||||
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x03;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.privateKey = stream.readString();
|
||||
this.search = stream.readString();
|
||||
const userCount = stream.readInt16();
|
||||
for (let i = 0; i < userCount; i++) {
|
||||
const username = stream.readString();
|
||||
const title = stream.readString();
|
||||
const publicKey = stream.readString();
|
||||
const verified = stream.readInt8();
|
||||
const online = stream.readInt8();
|
||||
this.users.push({ username, title, publicKey, online, verified });
|
||||
}
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeString(this.search);
|
||||
stream.writeInt16(this.users.length);
|
||||
for (const user of this.users) {
|
||||
stream.writeString(user.username);
|
||||
stream.writeString(user.title);
|
||||
stream.writeString(user.publicKey);
|
||||
stream.writeInt8(user.verified);
|
||||
stream.writeInt8(user.online);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
|
||||
public setPrivateKey(privateKey: string): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public getSearch(): string {
|
||||
return this.search;
|
||||
}
|
||||
|
||||
public setSearch(search: string): void {
|
||||
this.search = search;
|
||||
}
|
||||
|
||||
public addUser(searchUser: PacketSearchUser): void {
|
||||
this.users.push(searchUser);
|
||||
}
|
||||
|
||||
public getUsers(): PacketSearchUser[] {
|
||||
return this.users;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketTyping extends Packet {
|
||||
|
||||
private privateKey: string = "";
|
||||
private fromPublicKey: string = "";
|
||||
private toPublicKey: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x0B;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.privateKey = stream.readString();
|
||||
this.fromPublicKey = stream.readString();
|
||||
this.toPublicKey = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.privateKey);
|
||||
stream.writeString(this.fromPublicKey);
|
||||
stream.writeString(this.toPublicKey);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public setFromPublicKey(fromPublicKey: string): void {
|
||||
this.fromPublicKey = fromPublicKey;
|
||||
}
|
||||
|
||||
public setToPublicKey(toPublicKey: string): void {
|
||||
this.toPublicKey = toPublicKey;
|
||||
}
|
||||
|
||||
public getFromPublicKey(): string {
|
||||
return this.fromPublicKey;
|
||||
}
|
||||
|
||||
public getToPublicKey(): string {
|
||||
return this.toPublicKey;
|
||||
}
|
||||
|
||||
public setPrivateKey(privateKey: string): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Packet from "../packet";
|
||||
import Stream from "../stream";
|
||||
|
||||
export class PacketUserInfo extends Packet {
|
||||
private privateKey : string = "";
|
||||
private username: string = "";
|
||||
private title: string = "";
|
||||
|
||||
public getPacketId(): number {
|
||||
return 0x01;
|
||||
}
|
||||
|
||||
public _receive(stream: Stream): void {
|
||||
this.username = stream.readString();
|
||||
this.title = stream.readString();
|
||||
this.privateKey = stream.readString();
|
||||
}
|
||||
|
||||
public _send(): Stream {
|
||||
const stream = new Stream();
|
||||
stream.writeInt16(this.getPacketId());
|
||||
stream.writeString(this.username);
|
||||
stream.writeString(this.title);
|
||||
stream.writeString(this.privateKey);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public getUsername(): string {
|
||||
return this.username;
|
||||
}
|
||||
|
||||
public setUsername(username: string): void {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public getTitle(): string {
|
||||
return this.title;
|
||||
}
|
||||
|
||||
public setTitle(title: string): void {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public setPrivateKey(privateKey: string): void {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public getPrivateKey(): string {
|
||||
return this.privateKey;
|
||||
}
|
||||
}
|
||||
269
app/providers/ProtocolProvider/protocol/protocol.ts
Normal file
269
app/providers/ProtocolProvider/protocol/protocol.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { EventEmitter } from "events";
|
||||
import Packet from "./packet";
|
||||
import PacketHandshake, { Device, HandshakeState } from "./packets/packet.handshake";
|
||||
import { PacketMessage } from "./packets/packet.message";
|
||||
import { PacketOnlineState } from "./packets/packet.onlinestate";
|
||||
import { PacketOnlineSubscribe } from "./packets/packet.onlinesubscribe";
|
||||
import { PacketRead } from "./packets/packet.read";
|
||||
import { PacketResult } from "./packets/packet.result";
|
||||
import { PacketSearch } from "./packets/packet.search";
|
||||
import { PacketUserInfo } from "./packets/packet.userinfo";
|
||||
import Stream from "./stream";
|
||||
import { PacketDelivery } from "./packets/packet.delivery";
|
||||
import { PacketRequestUpdate } from "./packets/packet.requestupdate";
|
||||
import { RECONNECTING_INTERVAL } from "@/app/constants";
|
||||
import { PacketTyping } from "./packets/packet.typeing";
|
||||
import { PacketAvatar } from "./packets/packet.avatar";
|
||||
import { PacketKernelUpdate } from "./packets/packet.kernelupdate";
|
||||
import { PacketAppUpdate } from "./packets/packet.appupdate";
|
||||
import { PacketRequestTransport } from "./packets/packet.requesttransport";
|
||||
import { PacketPushNotification } from "./packets/packet.push.notification";
|
||||
import { PacketCreateGroup } from "./packets/packet.create.group";
|
||||
import { PacketGroupInfo } from "./packets/packet.group.info";
|
||||
import { PacketGroupInviteInfo } from "./packets/packet.group.invite.info";
|
||||
import { PacketGroupJoin } from "./packets/packet.group.join";
|
||||
import { PacketGroupLeave } from "./packets/packet.group.leave";
|
||||
import { PacketGroupBan } from "./packets/packet.group.ban";
|
||||
|
||||
export default class Protocol extends EventEmitter {
|
||||
private serverAddress: string;
|
||||
private socket: WebSocket | null = null;
|
||||
private reconnectInterval: number = RECONNECTING_INTERVAL * 1000;
|
||||
private isManuallyClosed: boolean = false;
|
||||
private _supportedPackets: Map<number, Packet> = new Map();
|
||||
private _packetWaiters: Map<number, ((packet: any) => void)[]> = new Map();
|
||||
private _packetQueue: Packet[] = []; // Очередь для пакетов
|
||||
private handshakeExchangeComplete : boolean = false;
|
||||
private heartbeatIntervalTimer : NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(serverAddress: string) {
|
||||
super();
|
||||
this.serverAddress = serverAddress;
|
||||
this.loadAllSupportedPackets();
|
||||
this.connect();
|
||||
|
||||
let _this = this;
|
||||
this.waitPacket(0x00, (packet : PacketHandshake) => {
|
||||
if(packet.getHandshakeState() == HandshakeState.COMPLETED) {
|
||||
console.info('[protocol] %chandshake exchange complete', 'color: #12c456;');
|
||||
_this.emit('handshake_complete');
|
||||
_this.handshakeExchangeComplete = true;
|
||||
_this._flushPacketQueue();
|
||||
this.startHeartbeat(packet.getHeartbeatInterval());
|
||||
}
|
||||
if(packet.getHandshakeState() == HandshakeState.NEED_DEVICE_VERIFICATION) {
|
||||
console.info('[protocol] %chandshake exchange need device verification', 'color: orange;');
|
||||
_this.emit('handshake_need_device_verification');
|
||||
_this._packetQueue = [];
|
||||
this.startHeartbeat(packet.getHeartbeatInterval());
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public startHeartbeat(intervalS : number) {
|
||||
const heartbeat = () => {
|
||||
if(this.socket && this.socket.readyState === WebSocket.OPEN){
|
||||
this.socket?.send('heartbeat');
|
||||
}
|
||||
}
|
||||
if(this.heartbeatIntervalTimer){
|
||||
clearInterval(this.heartbeatIntervalTimer);
|
||||
}
|
||||
console.info(`[protocol] %cstarting heartbeat with interval: %c${intervalS} seconds`, 'color: #12c456;', 'color: orange;');
|
||||
heartbeat();
|
||||
this.heartbeatIntervalTimer = setInterval(() => {
|
||||
heartbeat();
|
||||
}, ((intervalS * 1000) / 2));
|
||||
}
|
||||
|
||||
public startHandshakeExchange(publicKey: string, privateKey: string,
|
||||
device: Device
|
||||
) {
|
||||
console.info(
|
||||
`[protocol] %cstarting handshake exchange with server, public key: %c${publicKey}%c, private key hash: %c${privateKey}`,
|
||||
'color: #deadcd;',
|
||||
'color: #12c456;',
|
||||
'color: #deadcd;',
|
||||
'color: #12c456;'
|
||||
);
|
||||
let handshake = new PacketHandshake();
|
||||
handshake.setPublicKey(publicKey);
|
||||
handshake.setPrivateKey(privateKey);
|
||||
handshake.setDevice(device);
|
||||
this.sendPacket(handshake);
|
||||
this.emit('handshake_start');
|
||||
}
|
||||
|
||||
private loadAllSupportedPackets() {
|
||||
this._supportedPackets.set(0x00, new PacketHandshake());
|
||||
this._supportedPackets.set(0x01, new PacketUserInfo());
|
||||
this._supportedPackets.set(0x02, new PacketResult());
|
||||
this._supportedPackets.set(0x03, new PacketSearch());
|
||||
this._supportedPackets.set(0x04, new PacketOnlineSubscribe());
|
||||
this._supportedPackets.set(0x05, new PacketOnlineState());
|
||||
this._supportedPackets.set(0x06, new PacketMessage());
|
||||
this._supportedPackets.set(0x07, new PacketRead());
|
||||
this._supportedPackets.set(0x08, new PacketDelivery());
|
||||
//TODO: 0x09
|
||||
this._supportedPackets.set(0x0A, new PacketRequestUpdate());
|
||||
this._supportedPackets.set(0x0B, new PacketTyping());
|
||||
this._supportedPackets.set(0x0C, new PacketAvatar());
|
||||
this._supportedPackets.set(0x0D, new PacketKernelUpdate());
|
||||
this._supportedPackets.set(0x0E, new PacketAppUpdate());
|
||||
this._supportedPackets.set(0x0F, new PacketRequestTransport());
|
||||
this._supportedPackets.set(0x10, new PacketPushNotification());
|
||||
this._supportedPackets.set(0x11, new PacketCreateGroup());
|
||||
this._supportedPackets.set(0x12, new PacketGroupInfo());
|
||||
this._supportedPackets.set(0x13, new PacketGroupInviteInfo());
|
||||
this._supportedPackets.set(0x14, new PacketGroupJoin());
|
||||
this._supportedPackets.set(0x15, new PacketGroupLeave());
|
||||
this._supportedPackets.set(0x16, new PacketGroupBan());
|
||||
}
|
||||
|
||||
private _findWaiters(packetId: number): ((packet: Packet) => void)[] {
|
||||
if (!this._packetWaiters.has(packetId)) {
|
||||
return [];
|
||||
}
|
||||
return this._packetWaiters.get(packetId)!;
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.socket = new WebSocket(this.serverAddress);
|
||||
|
||||
this.socket.addEventListener('open', () => {
|
||||
//this.reconnectTryings = 0;
|
||||
this.emit('connect');
|
||||
this._flushPacketQueue(); // Отправляем все пакеты из очереди
|
||||
});
|
||||
|
||||
this.socket.addEventListener('message', async (event: MessageEvent) => {
|
||||
let stream = new Stream();
|
||||
const data = await event.data.arrayBuffer();
|
||||
const numbers = Array.from(new Uint8Array(data));
|
||||
stream.setStream(numbers);
|
||||
const packetId = stream.readInt16();
|
||||
this._supportedPackets.get(packetId);
|
||||
if (!this._supportedPackets.get(packetId)) {
|
||||
console.error(`Unsupported packet ID: ${packetId}`);
|
||||
return;
|
||||
}
|
||||
//чек безопасность
|
||||
const packet = this._supportedPackets.get(packetId)!.clone();
|
||||
packet._receive(stream);
|
||||
const waiters = this._findWaiters(packetId);
|
||||
if (waiters.length === 0) {
|
||||
console.error(`No waiters found for packet ID: ${packetId}`);
|
||||
return;
|
||||
}
|
||||
for (const waiter of waiters) {
|
||||
waiter(packet);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.addEventListener('close', (e: CloseEvent) => {
|
||||
console.log(`Connection reset by peer`, e.code);
|
||||
this.handshakeExchangeComplete = false;
|
||||
if (!this.isManuallyClosed) {
|
||||
console.log(`Attempting to reconnect...`);
|
||||
this.emit('reconnect');
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
if(this.socket?.readyState == WebSocket.OPEN){
|
||||
return;
|
||||
}
|
||||
}, this.reconnectInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _flushPacketQueue() {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
for(let i = this._packetQueue.length - 1; i >= 0; i--){
|
||||
let packet : Packet = this._packetQueue[i];
|
||||
if(!this.handshakeExchangeComplete && packet.getPacketId() != 0x00){
|
||||
/**
|
||||
* Если рукопожатие еще не выполнено и текущий пакет для отправки из очереди -
|
||||
* не рукопожатие то пропускаем, отправим в следующее очищение очереди
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
this.sendPacket(packet);
|
||||
this._packetQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async sendPacket(packet: Packet) {
|
||||
if(!this.socket){
|
||||
this.addPacketToQueue(packet);
|
||||
return;
|
||||
}
|
||||
if(this.socket.readyState !== WebSocket.OPEN){
|
||||
this.addPacketToQueue(packet);
|
||||
return;
|
||||
}
|
||||
if(!this.handshakeExchangeComplete && packet.getPacketId() != 0x00){
|
||||
this.addPacketToQueue(packet);
|
||||
return;
|
||||
}
|
||||
const stream = await packet._send();
|
||||
const packetName = packet.constructor.name;
|
||||
|
||||
const pIdHex = packet.getPacketId().toString(16).toLocaleUpperCase();
|
||||
const pIdHexPadded = pIdHex.length === 1 ? '0' + pIdHex : pIdHex;
|
||||
|
||||
console.info(`[protocol] %csending packet: %c${packetName} (ID: 0x${pIdHexPadded})`, 'color: #deadcd;', 'color: orange;');
|
||||
|
||||
/**
|
||||
* Если пакет больше максимально допустимого размера, то разбиваем его на чанки
|
||||
* и отправляем по частям
|
||||
*/
|
||||
this.socket.send(Buffer.from(stream.getStream()));
|
||||
}
|
||||
|
||||
public addPacketToQueue(packet : Packet) {
|
||||
this._packetQueue.push(packet);
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isManuallyClosed = true;
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
public unwaitPacket<T>(packet: number, callback: (packet: T) => void) {
|
||||
if (!this._packetWaiters.has(packet)) {
|
||||
return;
|
||||
}
|
||||
const waiters = this._packetWaiters.get(packet)!;
|
||||
const index = waiters.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
waiters.splice(index, 1);
|
||||
}
|
||||
if (waiters.length === 0) {
|
||||
this._packetWaiters.delete(packet);
|
||||
}
|
||||
}
|
||||
|
||||
public waitPacket<T>(packet: number, callback: (packet: T) => void) : number {
|
||||
if (!this._packetWaiters.has(packet)) {
|
||||
this._packetWaiters.set(packet, []);
|
||||
}
|
||||
return this._packetWaiters.get(packet)!.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a single packet to be received.
|
||||
* @param packet packet number to wait
|
||||
* @param callback callback to execute once the packet is received
|
||||
*/
|
||||
public waitPacketOnce<T>(packet: number, callback: (packet: T) => void) {
|
||||
let wrapper = (receivedPacket: T) => {
|
||||
callback(receivedPacket);
|
||||
this.unwaitPacket(packet, wrapper);
|
||||
};
|
||||
this.waitPacket(packet, wrapper);
|
||||
}
|
||||
}
|
||||
143
app/providers/ProtocolProvider/protocol/stream.ts
Normal file
143
app/providers/ProtocolProvider/protocol/stream.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export default class Stream {
|
||||
|
||||
private _stream: number[];
|
||||
private _readPoiner: number = 0;
|
||||
private _writePointer: number = 0;
|
||||
|
||||
constructor(stream : number[] = []) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
public getStream(): number[] {
|
||||
return this._stream;
|
||||
}
|
||||
|
||||
public setStream(stream: number[]) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
public writeInt8(value: number) {
|
||||
const negationBit = value < 0 ? 1 : 0;
|
||||
const int8Value = Math.abs(value) & 0xFF;
|
||||
this._stream[this._writePointer >> 3] |= negationBit << (7 - (this._writePointer & 7));
|
||||
this._writePointer++;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const bit = (int8Value >> (7 - i)) & 1;
|
||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
||||
this._writePointer++;
|
||||
}
|
||||
}
|
||||
|
||||
public readInt8(): number {
|
||||
let value = 0;
|
||||
const negationBit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
||||
this._readPoiner++;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
||||
value |= bit << (7 - i);
|
||||
this._readPoiner++;
|
||||
}
|
||||
return negationBit ? -value : value;
|
||||
}
|
||||
|
||||
public writeBit(value: number) {
|
||||
const bit = value & 1;
|
||||
this._stream[this._writePointer >> 3] |= bit << (7 - (this._writePointer & 7));
|
||||
this._writePointer++;
|
||||
}
|
||||
|
||||
public readBit(): number {
|
||||
const bit = (this._stream[this._readPoiner >> 3] >> (7 - (this._readPoiner & 7))) & 1;
|
||||
this._readPoiner++;
|
||||
return bit;
|
||||
}
|
||||
|
||||
public writeBoolean(value: boolean) {
|
||||
this.writeBit(value ? 1 : 0);
|
||||
}
|
||||
|
||||
public readBoolean(): boolean {
|
||||
return this.readBit() === 1;
|
||||
}
|
||||
|
||||
public writeInt16(value: number) {
|
||||
this.writeInt8(value >> 8);
|
||||
this.writeInt8(value & 0xFF);
|
||||
}
|
||||
|
||||
public readInt16(): number {
|
||||
const value = this.readInt8() << 8;
|
||||
return value | this.readInt8();
|
||||
}
|
||||
|
||||
public writeInt32(value: number) {
|
||||
this.writeInt16(value >> 16);
|
||||
this.writeInt16(value & 0xFFFF);
|
||||
}
|
||||
|
||||
public readInt32(): number {
|
||||
const value = this.readInt16() << 16;
|
||||
return value | this.readInt16();
|
||||
}
|
||||
|
||||
public writeFloat32(value: number) {
|
||||
const buffer = new ArrayBuffer(4);
|
||||
new DataView(buffer).setFloat32(0, value, true);
|
||||
const float32Value = new Uint32Array(buffer)[0];
|
||||
this.writeInt32(float32Value);
|
||||
}
|
||||
|
||||
public readFloat32(): number {
|
||||
const float32Value = this.readInt32();
|
||||
const buffer = new ArrayBuffer(4);
|
||||
new Uint32Array(buffer)[0] = float32Value;
|
||||
return new DataView(buffer).getFloat32(0, true);
|
||||
}
|
||||
|
||||
public writeInt64(value: number) {
|
||||
const high = Math.floor(value / 0x100000000);
|
||||
const low = value >>> 0;
|
||||
this.writeInt32(high);
|
||||
this.writeInt32(low);
|
||||
}
|
||||
|
||||
public readInt64(): number {
|
||||
const high = this.readInt32();
|
||||
const low = this.readInt32() >>> 0;
|
||||
return high * 0x100000000 + low;
|
||||
}
|
||||
|
||||
public writeString(value: string) {
|
||||
let length = value.length;
|
||||
this.writeInt32(length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
this.writeInt16(value.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
public readString(): string {
|
||||
let length = this.readInt32();
|
||||
let value = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
value += String.fromCharCode(this.readInt16());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public writeBytes(value: number[]) {
|
||||
this.writeInt32(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
this.writeInt8(value[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public readBytes(): number[] {
|
||||
let length = this.readInt32();
|
||||
let value : any = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
value.push(this.readInt8());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
||||
26
app/providers/ProtocolProvider/usePacket.ts
Normal file
26
app/providers/ProtocolProvider/usePacket.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from "react";
|
||||
import { useProtocol } from "./useProtocol";
|
||||
import Packet from "@/app/providers/ProtocolProvider/protocol/packet";
|
||||
|
||||
export const usePacket = (packetId : number, callback: (packet : any) => void, deps?: any[]) => {
|
||||
const {protocol} = useProtocol();
|
||||
|
||||
const waitPacket = (packetId: number, callback: (packet: Packet) => void) : number => {
|
||||
return protocol.waitPacket(packetId, callback);
|
||||
};
|
||||
|
||||
const unwaitPacket = (packetId: number, callback: (packet: Packet) => void) => {
|
||||
protocol.unwaitPacket(packetId, callback);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let unwait = (receivedPacket: Packet) => {
|
||||
callback(receivedPacket);
|
||||
};
|
||||
waitPacket(packetId, unwait);
|
||||
return () => {
|
||||
// Cleanup function to remove the packet listener
|
||||
unwaitPacket(packetId, unwait);
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
14
app/providers/ProtocolProvider/useProtocol.ts
Normal file
14
app/providers/ProtocolProvider/useProtocol.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react";
|
||||
import { ProtocolContext } from "./ProtocolProvider";
|
||||
|
||||
export const useProtocol = () => {
|
||||
const [context, connect] = useContext(ProtocolContext);
|
||||
|
||||
if(!context){
|
||||
throw new Error("useProtocol must be used within a ProtocolProvider");
|
||||
}
|
||||
|
||||
const protocol = context;
|
||||
|
||||
return {protocol, protocolState: connect };
|
||||
};
|
||||
12
app/providers/ProtocolProvider/useProtocolState.ts
Normal file
12
app/providers/ProtocolProvider/useProtocolState.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { ProtocolContext } from "./ProtocolProvider";
|
||||
|
||||
export const useProtocolState = () => {
|
||||
const [context, connect] = useContext(ProtocolContext);
|
||||
|
||||
if(!context){
|
||||
throw new Error("useProtocol must be used within a ProtocolProvider");
|
||||
}
|
||||
|
||||
return connect;
|
||||
};
|
||||
12
app/providers/ProtocolProvider/useSender.ts
Normal file
12
app/providers/ProtocolProvider/useSender.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import Packet from "@/app/providers/ProtocolProvider/protocol/packet";
|
||||
import { useProtocol } from "./useProtocol";
|
||||
|
||||
export const useSender = () => {
|
||||
const {protocol} = useProtocol();
|
||||
|
||||
const send = (packet: Packet) => {
|
||||
protocol.sendPacket(packet);
|
||||
}
|
||||
|
||||
return send;
|
||||
}
|
||||
57
app/providers/SettingsProvider/SettingsProvider.tsx
Normal file
57
app/providers/SettingsProvider/SettingsProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useFileStorage } from "@/app/hooks/useFileStorage";
|
||||
import { useLogger } from "@/app/hooks/useLogger";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
export interface SettingsContextValue {
|
||||
getSetting: (key: string, def?: any) => any;
|
||||
setSetting: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
export const SettingsProviderContext = createContext<SettingsContextValue|null>(null);
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsProvider(props: SettingsProviderProps) {
|
||||
const [settings, setSettings] = useState<any>({});
|
||||
const {readFile, writeFile} = useFileStorage();
|
||||
const log = useLogger('SettingsProvider');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
const data = await readFile("settings.json");
|
||||
if(data){
|
||||
try {
|
||||
const parsedSettings = JSON.parse(Buffer.from(data).toString());
|
||||
setSettings(parsedSettings);
|
||||
} catch (e) {
|
||||
log("Failed to parse settings.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const getSetting = (key: string, def? : any) => {
|
||||
if(settings[key] === undefined){
|
||||
return def;
|
||||
}
|
||||
return settings[key];
|
||||
}
|
||||
|
||||
const setSetting = (key: string, value: any) => {
|
||||
setSettings((prevSettings: any) => {
|
||||
const newSettings = {...prevSettings, [key]: value};
|
||||
writeFile("settings.json", JSON.stringify(newSettings));
|
||||
return newSettings;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsProviderContext.Provider value={{getSetting, setSetting}}>
|
||||
{props.children}
|
||||
</SettingsProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
14
app/providers/SettingsProvider/useSetting.ts
Normal file
14
app/providers/SettingsProvider/useSetting.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react";
|
||||
import { SettingsProviderContext } from "./SettingsProvider";
|
||||
|
||||
export function useSetting<T>(key: string, def?: any): [T, (value: T) => void] {
|
||||
const context = useContext(SettingsProviderContext);
|
||||
if(!context){
|
||||
throw new Error("useSetting must be used within a SettingsProvider");
|
||||
}
|
||||
|
||||
return [
|
||||
context.getSetting(key, def),
|
||||
(value: any) => context.setSetting(key, value)
|
||||
]
|
||||
}
|
||||
BIN
app/providers/SystemAccountsProvider/.DS_Store
vendored
Normal file
BIN
app/providers/SystemAccountsProvider/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
import { useDatabase } from "@/app/providers/DatabaseProvider/useDatabase";
|
||||
import { createContext, useEffect } from "react";
|
||||
import { useSystemAccount } from "./useSystemAccount";
|
||||
import { usePublicKey } from "../AccountProvider/usePublicKey";
|
||||
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
|
||||
import { APP_VERSION, RELEASE_NOTICE } from "@/app/version";
|
||||
import { chacha20Encrypt, encodeWithPassword, encrypt } from "@/app/crypto/crypto";
|
||||
import { generateRandomKey } from "@/app/utils/utils";
|
||||
import { DeliveredMessageState } from "../DialogProvider/DialogProvider";
|
||||
import { UserInformation } from "../InformationProvider/InformationProvider";
|
||||
import { useNotification } from "@/app/hooks/useNotification";
|
||||
import { useDialogsList } from "../DialogListProvider/useDialogsList";
|
||||
|
||||
export const SystemAccountContext = createContext(null);
|
||||
|
||||
export interface SystemUserInformation extends UserInformation {
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface SystemAccountProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SystemAccountProvider(props : SystemAccountProviderProps) {
|
||||
const {runQuery} = useDatabase();
|
||||
const lastNoticeVersion = localStorage.getItem("lastNoticeVersion") || "0.0.0";
|
||||
const updateAccount = useSystemAccount("updates");
|
||||
const publicKey = usePublicKey();
|
||||
const privatePlain = usePrivatePlain();
|
||||
const {updateDialog} = useDialogsList();
|
||||
const notify = useNotification();
|
||||
|
||||
useEffect(() => {
|
||||
if(publicKey == ""){
|
||||
return;
|
||||
}
|
||||
if(lastNoticeVersion !== APP_VERSION){
|
||||
sendReleaseNoticeFromUpdatesAccount();
|
||||
localStorage.setItem("lastNoticeVersion", APP_VERSION);
|
||||
}
|
||||
}, [lastNoticeVersion, publicKey]);
|
||||
|
||||
const sendReleaseNoticeFromUpdatesAccount = async () => {
|
||||
const message = RELEASE_NOTICE;
|
||||
if(message.trim() == ""){
|
||||
return;
|
||||
}
|
||||
const cahchaEncrypted = await chacha20Encrypt(message.trim());
|
||||
const key = Buffer.concat([
|
||||
Buffer.from(cahchaEncrypted.key, "hex"),
|
||||
Buffer.from(cahchaEncrypted.nonce, "hex")]);
|
||||
const encryptedKey = await encrypt(key.toString('binary'), publicKey);
|
||||
const messageId = generateRandomKey(16);
|
||||
const plainMessage = await encodeWithPassword(privatePlain, message.trim());
|
||||
|
||||
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [updateAccount!.publicKey, publicKey, cahchaEncrypted.ciphertext, Date.now(), 0, encryptedKey, 0, plainMessage, publicKey, messageId, DeliveredMessageState.DELIVERED, JSON.stringify([])]);
|
||||
updateDialog(updateAccount!.publicKey);
|
||||
notify("New message", "You have a new message");
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<SystemAccountContext.Provider value={null}>
|
||||
{props.children}
|
||||
</SystemAccountContext.Provider>
|
||||
)
|
||||
}
|
||||
BIN
app/providers/SystemAccountsProvider/avatars/updates.png
Normal file
BIN
app/providers/SystemAccountsProvider/avatars/updates.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
8
app/providers/SystemAccountsProvider/useSystemAccount.ts
Normal file
8
app/providers/SystemAccountsProvider/useSystemAccount.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
import { SystemUserInformation } from "./SystemAccountsProvider";
|
||||
import { useSystemAccounts } from "./useSystemAccounts";
|
||||
|
||||
export function useSystemAccount(username: string) : SystemUserInformation | undefined {
|
||||
const systemAccounts = useSystemAccounts();
|
||||
return systemAccounts.find((v) => v.username == username);
|
||||
}
|
||||
18
app/providers/SystemAccountsProvider/useSystemAccounts.ts
Normal file
18
app/providers/SystemAccountsProvider/useSystemAccounts.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { OnlineState } from "@/app/providers/ProtocolProvider/protocol/packets/packet.onlinestate";
|
||||
import { SystemUserInformation } from "./SystemAccountsProvider";
|
||||
import updates from './avatars/updates.png';
|
||||
|
||||
export function useSystemAccounts() : SystemUserInformation[] {
|
||||
const accounts : SystemUserInformation[] = [
|
||||
{
|
||||
publicKey: "0x000000000000000000000000000000000000000001",
|
||||
verified: 1,
|
||||
title: "Rosetta Updates",
|
||||
username: "updates",
|
||||
online: OnlineState.OFFLINE,
|
||||
avatar: updates
|
||||
}
|
||||
];
|
||||
|
||||
return accounts;
|
||||
}
|
||||
140
app/providers/TransportProvider/TransportProvider.tsx
Normal file
140
app/providers/TransportProvider/TransportProvider.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createContext, useEffect, useRef, useState } from "react";
|
||||
import { PacketRequestTransport } from "../ProtocolProvider/protocol/packets/packet.requesttransport";
|
||||
import { useSender } from "../ProtocolProvider/useSender";
|
||||
import { usePacket } from "../ProtocolProvider/usePacket";
|
||||
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
|
||||
|
||||
interface TransportContextValue {
|
||||
transportServer: string | null;
|
||||
uploadFile: (id: string, content: string) => Promise<any>;
|
||||
downloadFile: (id: string, tag: string) => Promise<string>;
|
||||
uploading: TransportState[];
|
||||
downloading: TransportState[];
|
||||
}
|
||||
|
||||
export const TransportContext = createContext<TransportContextValue | null>(null);
|
||||
|
||||
interface TransportProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TransportState {
|
||||
id: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Этот провайдер занимается тем, что передает
|
||||
* файлы на сервер и получает их с сервера
|
||||
*/
|
||||
export function TransportProvider(props: TransportProviderProps) {
|
||||
const transportServerRef = useRef<string | null>(null);
|
||||
const [uploading, setUploading] = useState<TransportState[]>([]);
|
||||
const [downloading, setDownloading] = useState<TransportState[]>([]);
|
||||
const send = useSender();
|
||||
const { info } = useConsoleLogger('TransportProvider');
|
||||
|
||||
useEffect(() => {
|
||||
let packet = new PacketRequestTransport();
|
||||
send(packet);
|
||||
}, []);
|
||||
|
||||
usePacket(0x0F, (packet: PacketRequestTransport) => {
|
||||
transportServerRef.current = packet.getTransportServer();
|
||||
info(`Transport server ${transportServerRef.current}`);
|
||||
});
|
||||
|
||||
const uploadFile = async (id: string, content: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!transportServerRef.current) {
|
||||
throw new Error("Transport server is not set");
|
||||
}
|
||||
setUploading(prev => [...prev, { id: id, progress: 0 }]);
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob([content]), id);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${transportServerRef.current}/u`);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
setUploading(prev =>
|
||||
prev.map(u =>
|
||||
u.id === id ? { ...u, progress } : u
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
resolve(JSON.parse(xhr.responseText).t);
|
||||
setUploading(prev => prev.filter(u => u.id !== id));
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject();
|
||||
setUploading(prev => prev.filter(u => u.id !== id));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает файл с транспортного сервера
|
||||
* @param tag тег файла
|
||||
* @param chachaDecryptedKey ключ для расшифровки файла
|
||||
*/
|
||||
const downloadFile = (id: string, tag : string) : Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!transportServerRef.current) {
|
||||
throw new Error("Transport server is not set");
|
||||
}
|
||||
setDownloading(prev => [...prev, { id: id, progress: 0 }]);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', `${transportServerRef.current}/d/${tag}`);
|
||||
xhr.responseType = 'text';
|
||||
|
||||
xhr.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
setDownloading(prev =>
|
||||
prev.map(u =>
|
||||
u.id === id ? { ...u, progress } : u
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = async () => {
|
||||
if(xhr.status != 200){
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
setDownloading(prev => prev.filter(u => u.id !== tag));
|
||||
let blob = xhr.responseText;
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
setDownloading(prev => prev.filter(u => u.id !== tag));
|
||||
reject();
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TransportContext.Provider value={{
|
||||
transportServer: transportServerRef.current,
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
uploading,
|
||||
downloading
|
||||
}}>
|
||||
{props.children}
|
||||
</TransportContext.Provider>
|
||||
)
|
||||
}
|
||||
13
app/providers/TransportProvider/useDownloadStatus.ts
Normal file
13
app/providers/TransportProvider/useDownloadStatus.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
import { TransportContext } from "./TransportProvider";
|
||||
|
||||
export function useDownloadStatus(tag: string) {
|
||||
const context = useContext(TransportContext);
|
||||
if (!context) {
|
||||
throw new Error("useDownloadStatus must be used within a TransportProvider");
|
||||
}
|
||||
const { downloading } = context;
|
||||
let downloadState = downloading.find(u => u.id === tag);
|
||||
|
||||
return downloadState ? downloadState.progress : 0;
|
||||
}
|
||||
13
app/providers/TransportProvider/useTransport.ts
Normal file
13
app/providers/TransportProvider/useTransport.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
import { TransportContext } from "./TransportProvider";
|
||||
|
||||
|
||||
export function useTransport() {
|
||||
const context = useContext(TransportContext);
|
||||
if(!context){
|
||||
throw new Error("useTransport must be used within a TransportProvider");
|
||||
}
|
||||
const { uploadFile, downloadFile } = context;
|
||||
|
||||
return { downloadFile, uploadFile };
|
||||
}
|
||||
17
app/providers/TransportProvider/useUploadStatus.ts
Normal file
17
app/providers/TransportProvider/useUploadStatus.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useContext } from "react";
|
||||
import { TransportContext } from "./TransportProvider";
|
||||
|
||||
/**
|
||||
* Хук для получения статуса загрузки файла по его upid
|
||||
* @returns Функцию для получения статуса загрузки файла по его upid
|
||||
*/
|
||||
export function useUploadStatus(upid: string) {
|
||||
const context = useContext(TransportContext);
|
||||
if (!context) {
|
||||
throw new Error("useUploadStatus must be used within a TransportProvider");
|
||||
}
|
||||
const { uploading } = context;
|
||||
let uploadState = uploading.find(u => u.id === upid);
|
||||
|
||||
return uploadState ? uploadState.progress : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user