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

BIN
app/providers/.DS_Store vendored Normal file

Binary file not shown.

View 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>
)
}

View 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];
}

View 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;
}

View 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];
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
};
}

View 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>
)
}

View 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)];
}

View 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 || [];
}

View 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;
}

View 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;
}

View 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>
)
}

View 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
]
}

View 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>
</>
);
}

View 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;
}

View 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>
);
}

View 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)
)`
]

View 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,
};
}

View 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>
)
}

View 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;
}

View 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>
)
}

View 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 == '',
}
}

View 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
};
}

View File

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

View File

@@ -0,0 +1,242 @@
import { useContext } from "react";
import { useDatabase } from "../DatabaseProvider/useDatabase";
import { chacha20Encrypt, encodeWithPassword, encrypt, generateMd5} from "../../crypto/crypto";
import { AttachmentMeta, DeliveredMessageState, DialogContext, Message } from "./DialogProvider";
import { Attachment, AttachmentType, PacketMessage } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message";
import { usePublicKey } from "../AccountProvider/usePublicKey";
import { usePrivatePlain } from "../AccountProvider/usePrivatePlain";
import { usePrivateKeyHash } from "../AccountProvider/usePrivateKeyHash";
import { useSender } from "../ProtocolProvider/useSender";
import { generateRandomKey } from "@/app/utils/utils";
import { useFileStorage } from "@/app/hooks/useFileStorage";
import { useDialogsList } from "../DialogListProvider/useDialogsList";
import { useProtocolState } from "../ProtocolProvider/useProtocolState";
import { ProtocolState } from "../ProtocolProvider/ProtocolProvider";
import { useGroups } from "./useGroups";
import { useConsoleLogger } from "@/app/hooks/useConsoleLogger";
export function useDialog() : {
messages: Message[];
sendMessage: (message: string, attachemnts : Attachment[]) => Promise<void>;
deleteMessages: () => Promise<void>;
loadMessagesToTop: (count?: number) => Promise<void>;
deleteMessageById: (messageId: string) => Promise<void>;
loading: boolean;
deleteSelectedMessages: (messageIds: string[]) => Promise<void>;
dialog: string;
loadMessagesToMessageId: (messageId: string) => Promise<void>;
updateAttachmentsInMessagesByAttachmentId: (attachmentId: string, blob: string) => Promise<void>;
} {
const {runQuery} = useDatabase();
const send = useSender();
const context = useContext(DialogContext);
if(!context) {
throw new Error("useDialog must be used within a DialogProvider");
}
const {loading,
messages,
prepareAttachmentsToSend,
clearDialogCache,
setMessages,
dialog, loadMessagesToTop, loadMessagesToMessageId} = context;
const {updateDialog} = useDialogsList();
const publicKey = usePublicKey();
const privateKey = usePrivateKeyHash();
const privatePlain = usePrivatePlain();
const {writeFile} = useFileStorage();
const protocolState = useProtocolState();
const {hasGroup, getGroupKey} = useGroups();
const {warn} = useConsoleLogger('useDialog');
/**
* Отправка сообщения в диалог
* @param message Сообщение
* @param attachemnts Вложения
*/
const sendMessage = async (message: string, attachemnts : Attachment[]) => {
const messageId = generateRandomKey(16);
let cahchaEncrypted = {ciphertext: "", key: "", nonce: ""} as any;
let key = Buffer.from("");
let encryptedKey = "";
let plainMessage = "";
let content = "";
if(!hasGroup(dialog)){
cahchaEncrypted = (await chacha20Encrypt(message.trim()) as any);
key = Buffer.concat([
Buffer.from(cahchaEncrypted.key, "hex"),
Buffer.from(cahchaEncrypted.nonce, "hex")]);
encryptedKey = await encrypt(key.toString('binary'), dialog);
plainMessage = await encodeWithPassword(privatePlain, message.trim());
content = cahchaEncrypted.ciphertext;
}else{
/**
* Это группа, там шифрование устроено иначе
* для групп используется один общий ключ, который
* есть только у участников группы, сам ключ при этом никак
* не отправляется по сети (ведь ID у группы общий и у каждого
* и так есть этот ключ)
*/
const groupKey = await getGroupKey(dialog);
if(!groupKey){
warn("Group key not found for dialog " + dialog);
return;
}
content = await encodeWithPassword(groupKey, message.trim());
plainMessage = await encodeWithPassword(privatePlain, message.trim());
encryptedKey = ""; // В группах не нужен зашифрованный ключ
key = Buffer.from(groupKey);
}
/**
* Нужно зашифровать ключ еще и нашим ключом,
* чтобы в последствии мы могли расшифровать этот ключ у своих
* же сообщений (смотреть problem_sync.md)
*/
const aesChachaKey = await encodeWithPassword(privatePlain, key.toString('binary'));
setMessages((prev : Message[]) => ([...prev, {
from_public_key: publicKey,
to_public_key: dialog,
content: content,
timestamp: Date.now(),
readed: publicKey == dialog ? 1 : 0,
chacha_key: "",
from_me: 1,
plain_message: message,
delivered: publicKey == dialog ? DeliveredMessageState.DELIVERED : DeliveredMessageState.WAITING,
message_id: messageId,
attachments: attachemnts
}]));
let attachmentsMeta : AttachmentMeta[] = [];
for(let i = 0; i < attachemnts.length; i++) {
const attachment = attachemnts[i];
attachmentsMeta.push({
id: attachment.id,
type: attachment.type,
preview: attachment.preview
});
if(attachment.type == AttachmentType.FILE){
/**
* Обычно вложения дублируются на диск. Так происходит со всем.
* Кроме файлов. Если дублировать файл весом в 2гб на диск отправка будет
* занимать очень много времени.
* К тому же, это приведет к созданию ненужной копии у отправителя
*/
continue;
}
writeFile(`m/${await generateMd5(attachment.id + publicKey)}`, Buffer.from(await encodeWithPassword(privatePlain, attachment.blob)).toString('binary'));
}
await runQuery(`
INSERT INTO messages
(from_public_key, to_public_key, content, timestamp, read, chacha_key, from_me, plain_message, account, message_id, delivered, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [publicKey, dialog, content, Date.now(), publicKey == dialog ? 1 : 0, encryptedKey, 1, plainMessage, publicKey, messageId, publicKey == dialog ? DeliveredMessageState.DELIVERED : (
protocolState != ProtocolState.CONNECTED ? DeliveredMessageState.ERROR : DeliveredMessageState.WAITING
), JSON.stringify(attachmentsMeta)]);
updateDialog(dialog);
if(publicKey == ""
|| dialog == ""
|| publicKey == dialog) {
return;
}
//98acbbc68f4b2449daf0a39d1b3eab9a3056da5d45b811bbc903e214c21d39643394980231e1a89c811830d870f3354184319665327ca8bd
console.info("Sending key for message ", key.toString('hex'));
let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(key.toString('utf-8'), attachemnts);
if(attachemnts.length <= 0 && message.trim() == ""){
runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]);
updateDialog(dialog);
return;
}
const packet = new PacketMessage();
packet.setFromPublicKey(publicKey);
packet.setToPublicKey(dialog);
packet.setContent(content);
packet.setChachaKey(encryptedKey);
packet.setPrivateKey(privateKey);
packet.setMessageId(messageId);
packet.setTimestamp(Date.now());
packet.setAttachments(preparedToNetworkSendAttachements);
packet.setAesChachaKey(aesChachaKey);
send(packet);
}
const deleteMessages = async () => {
if(!hasGroup(dialog)){
await runQuery(`
DELETE FROM messages WHERE ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?)) AND account = ?
`, [dialog, publicKey, publicKey, dialog, publicKey]);
}else{
await runQuery(`
DELETE FROM messages WHERE to_public_key = ? AND account = ?
`, [dialog, publicKey]);
}
setMessages([]);
updateDialog(dialog);
clearDialogCache();
}
const deleteMessageById = async (messageId: string) => {
await runQuery(`
DELETE FROM messages WHERE message_id = ? AND account = ?
`, [messageId, publicKey]);
setMessages((prev) => prev.filter((msg) => msg.message_id !== messageId));
updateDialog(dialog);
}
const deleteSelectedMessages = async (messageIds: string[]) => {
if(messageIds.length == 0){
return;
}
/**
* Old messages support, ignore empty IDs
* @since 0.1.7 all messages have IDs
*/
let idsNotEmpty = messageIds.filter(v => v.trim() != "");
if(idsNotEmpty.length == 0){
return;
}
const placeholders = idsNotEmpty.map(() => '?').join(',');
await runQuery(`
DELETE FROM messages WHERE message_id IN (` +placeholders+ `) AND account = ?
`, [...idsNotEmpty, publicKey]);
setMessages((prev) => prev.filter((msg) => !messageIds.includes(msg.message_id)));
updateDialog(dialog);
}
const updateAttachmentsInMessagesByAttachmentId = async (attachmentId: string, blob: string) => {
setMessages((prevMessages) => {
return prevMessages.map((msg) => {
let updated = false;
const updatedAttachments = msg.attachments.map((attachment) => {
if (attachment.id === attachmentId) {
updated = true;
return {
...attachment,
blob: blob
};
}
return attachment;
});
if (updated) {
return {
...msg,
attachments: updatedAttachments,
};
}
return msg;
});
});
}
return {
messages,
sendMessage, updateAttachmentsInMessagesByAttachmentId, deleteMessages, loadMessagesToTop, loadMessagesToMessageId, deleteMessageById, loading, deleteSelectedMessages,
dialog,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
)
}

View 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
}
}

View 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
}
}

View 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;
}

View 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
};
}

View File

@@ -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>;
}
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
)
}

View 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
};
}

View 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
};
}

View 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
];
}

View File

@@ -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;
}

View 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;
}

View 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;
}

View 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
]
}

View 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>
)
}

View 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];
}

View 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

Binary file not shown.

View 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>
);
}

Binary file not shown.

View 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}

View 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 };
};

View 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;
};

View 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;
}

View 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>
);
}

View 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)
]
}

Binary file not shown.

View File

@@ -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>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View 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);
}

View 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;
}

View 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>
)
}

View 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;
}

View 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 };
}

View 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;
}