From a15d3a08bfc38e1cec2a7254b88cebca4cfda819 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 22:02:44 +0300 Subject: [PATCH 1/4] Refactor APIs --- .../api/{devicesApi.ts => account/devices.ts} | 9 +- .../core/api/{authApi.ts => account/index.ts} | 175 +++++++---- frontend/src/core/api/account/profile.ts | 275 ++++++++++++++++++ frontend/src/core/api/crypto.ts | 76 +++++ frontend/src/core/api/dm.ts | 178 ++++++++++++ frontend/src/core/api/dmApi.ts | 35 +-- frontend/src/core/api/files.ts | 39 +++ frontend/src/core/api/messaging.ts | 87 ++++++ frontend/src/core/api/moderation.ts | 54 ++++ frontend/src/core/api/profileApi.ts | 66 ++++- frontend/src/core/api/push.ts | 50 ++++ frontend/src/core/api/securityApi.ts | 36 --- frontend/src/core/api/users.ts | 28 ++ frontend/src/core/api/webrtc.ts | 15 + frontend/src/core/calls/encryption.ts | 2 +- frontend/src/core/calls/webrtc.ts | 21 +- frontend/src/core/components/StatusBadge.tsx | 2 +- frontend/src/core/components/VerifyButton.tsx | 2 +- .../push-notifications/push-notifications.ts | 14 +- frontend/src/pages/auth/LoginForm.tsx | 33 +-- frontend/src/pages/auth/RegisterForm.tsx | 26 +- frontend/src/pages/chat/hooks/useDM.ts | 2 +- frontend/src/pages/chat/hooks/useProfile.ts | 2 +- frontend/src/pages/chat/state.ts | 14 +- frontend/src/pages/chat/ui/ChatPage.tsx | 2 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 44 +-- .../pages/chat/ui/left/UnifiedChatsList.tsx | 19 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 2 +- .../chat/ui/left/settings/AccountPanel.tsx | 2 +- .../ui/left/settings/ChangePasswordDialog.tsx | 2 +- .../chat/ui/left/settings/DevicesPanel.tsx | 2 +- .../ui/left/settings/NotificationsPanel.tsx | 12 +- frontend/src/pages/chat/ui/right/Message.tsx | 5 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 4 +- .../chat/ui/right/panels/PublicChatPanel.ts | 53 +--- 35 files changed, 1075 insertions(+), 313 deletions(-) rename frontend/src/core/api/{devicesApi.ts => account/devices.ts} (84%) rename frontend/src/core/api/{authApi.ts => account/index.ts} (55%) create mode 100644 frontend/src/core/api/account/profile.ts create mode 100644 frontend/src/core/api/crypto.ts create mode 100644 frontend/src/core/api/dm.ts create mode 100644 frontend/src/core/api/files.ts create mode 100644 frontend/src/core/api/messaging.ts create mode 100644 frontend/src/core/api/moderation.ts create mode 100644 frontend/src/core/api/push.ts delete mode 100644 frontend/src/core/api/securityApi.ts create mode 100644 frontend/src/core/api/users.ts create mode 100644 frontend/src/core/api/webrtc.ts diff --git a/frontend/src/core/api/devicesApi.ts b/frontend/src/core/api/account/devices.ts similarity index 84% rename from frontend/src/core/api/devicesApi.ts rename to frontend/src/core/api/account/devices.ts index 12882cd..aaa139d 100644 --- a/frontend/src/core/api/devicesApi.ts +++ b/frontend/src/core/api/account/devices.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; +import { getAuthHeaders } from "./index"; export interface DeviceInfo { session_id: string; @@ -18,20 +18,19 @@ export interface DeviceInfo { } export async function listDevices(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token) }); + const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) }); if (!res.ok) throw new Error("Failed to fetch devices"); const data = await res.json(); return data.devices as DeviceInfo[]; } export async function revokeDevice(token: string, sessionId: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token) }); + const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) }); if (!res.ok) throw new Error("Failed to revoke device"); } export async function logoutAllOtherDevices(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token) }); + const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) }); if (!res.ok) throw new Error("Failed to logout all devices"); } - diff --git a/frontend/src/core/api/authApi.ts b/frontend/src/core/api/account/index.ts similarity index 55% rename from frontend/src/core/api/authApi.ts rename to frontend/src/core/api/account/index.ts index a95c08f..af2318b 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/account/index.ts @@ -1,12 +1,15 @@ -import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types"; +import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types"; import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; import { b64, ub64 } from "@/utils/utils"; -import { API_BASE_URL } from "@/core/config"; import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; +import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; +import type { Headers } from "@/core/types"; /** * Generates authentication headers for API requests + * @param {string | null} token - Authentication token * @param {boolean} json - Whether to include JSON content type header * @returns {Headers} Headers object with authentication and content type */ @@ -23,54 +26,15 @@ export function getAuthHeaders(token: string | null, json: boolean = true): Head return headers; } -let currentPublicKey: Uint8Array | null = null; -let currentPrivateKey: Uint8Array | null = null; - -async function fetchPublicKey(token: string): Promise { - const headers = getAuthHeaders(token, true); - const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers }); - if (!res.ok) return null; - const data = await res.json(); - if (!data?.publicKey) return null; - return ub64(data.publicKey); +export interface CheckAuthResponse { + authenticated: boolean; + username: string; + admin: boolean; } -async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { - const payload: UploadPublicKeyRequest = { - publicKey: b64(publicKey) - } - - const headers = getAuthHeaders(token, true); - await fetch(`${API_BASE_URL}/crypto/public-key`, { - method: "POST", - headers, - body: JSON.stringify(payload) - }); -} - -async function fetchBackupBlob(token: string): Promise { - const headers = getAuthHeaders(token, true); - const res = await fetch(`${API_BASE_URL}/crypto/backup`, { - method: "GET", - headers - }); - if (res.ok) { - const response: BackupBlob = await res.json(); - return response.blob; - } else { - return null; - } -} - -async function uploadBackupBlob(blobJson: string, token: string): Promise { - const payload: BackupBlob = { blob: blobJson } - - const headers = getAuthHeaders(token, true); - await fetch(`${API_BASE_URL}/crypto/backup`, { - method: "POST", - headers, - body: JSON.stringify(payload) - }); +export interface LogoutResponse { + status: string; + message: string; } export interface UserKeyPairMemory { @@ -78,6 +42,9 @@ export interface UserKeyPairMemory { privateKey: Uint8Array; } +let currentPublicKey: Uint8Array | null = null; +let currentPrivateKey: Uint8Array | null = null; + export function getCurrentKeys(): UserKeyPairMemory | null { if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; return null; @@ -94,6 +61,72 @@ function saveKeys( localStorage.setItem("privateKey", encodedPrivateKey); } +/** + * Checks if the current user is authenticated + */ +export async function checkAuth(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/check_auth`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to check auth"); + return await res.json(); +} + +/** + * Logs in a user with username and password + */ +export async function login(request: LoginRequest): Promise { + const res = await fetch(`${API_BASE_URL}/login`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Login failed" })); + throw new Error(error.detail || "Login failed"); + } + return await res.json(); +} + +/** + * Registers a new user + */ +export async function register(request: RegisterRequest): Promise { + const res = await fetch(`${API_BASE_URL}/register`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Registration failed" })); + throw new Error(error.detail || "Registration failed"); + } + return await res.json(); +} + +/** + * Logs out the current user + */ +export async function logout(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/logout`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to logout"); + return await res.json(); +} + +/** + * Derive a client-side authentication secret so the raw password never leaves the client. + * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. + */ +export async function deriveAuthSecret(username: string, password: string): Promise { + // Use per-user salt derived from username; in future we can fetch a server-provided salt + const salt = new TextEncoder().encode(`fromchat.user:${username}`); + // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword + const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32); + return b64(derived); +} + export async function ensureKeysOnLogin(password: string, token: string): Promise { // Try to restore from backup const blobJson = await fetchBackupBlob(token); @@ -147,13 +180,41 @@ export function getAuthToken(): string | null { } /** - * Derive a client-side authentication secret so the raw password never leaves the client. - * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. + * Changes the user's password */ -export async function deriveAuthSecret(username: string, password: string): Promise { - // Use per-user salt derived from username; in future we can fetch a server-provided salt - const salt = new TextEncoder().encode(`fromchat.user:${username}`); - // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword - const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32); - return b64(derived); -} \ No newline at end of file +export async function changePassword( + token: string, + username: string, + currentPassword: string, + newPassword: string, + logoutAllExceptCurrent: boolean +): Promise { + const currentDerived = await deriveAuthSecret(username, currentPassword); + const newDerived = await deriveAuthSecret(username, newPassword); + const res = await fetch(`${API_BASE_URL}/change-password`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ + currentPasswordDerived: currentDerived, + newPasswordDerived: newDerived, + logoutAllExceptCurrent + }) + }); + if (!res.ok) throw new Error("Failed to change password"); +} + +/** + * Deletes the current user's account + */ +export async function deleteAccount(token: string): Promise<{ status: string; message: string }> { + const res = await fetch(`${API_BASE_URL}/account/delete`, { + method: "POST", + headers: getAuthHeaders(token, true) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to delete account" })); + throw new Error(error.detail || "Failed to delete account"); + } + return await res.json(); +} + diff --git a/frontend/src/core/api/account/profile.ts b/frontend/src/core/api/account/profile.ts new file mode 100644 index 0000000..ddb1fbc --- /dev/null +++ b/frontend/src/core/api/account/profile.ts @@ -0,0 +1,275 @@ +import { getAuthHeaders } from "."; +import { API_BASE_URL } from "@/core/config"; +import type { UserProfile } from "@/core/types"; + +export interface ProfileData { + profile_picture?: string; + username?: string; + display_name?: string; + description?: string; +} + +export interface UploadResponse { + profile_picture_url: string; +} + +/** + * Loads user profile data from the server + */ +export async function loadProfile(token: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + const data = await response.json(); + // Map backend fields to frontend fields + return { + profile_picture: data.profile_picture, + username: data.username, + display_name: data.display_name, + description: data.bio + }; + } + + return null; + } catch (error) { + console.error('Error loading profile:', error); + return null; + } +} + +/** + * Uploads a profile picture to the server + */ +export async function uploadProfilePicture(token: string, file: Blob): Promise { + try { + const formData = new FormData(); + formData.append('profile_picture', file, 'profile_picture.jpg'); + + const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, { + method: 'POST', + body: formData, + headers: getAuthHeaders(token, false) + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Upload error:', error); + return null; + } +} + +/** + * Updates user profile information + */ +export async function updateProfile(token: string, data: Partial): Promise { + try { + // Map frontend fields to backend fields + const backendData = { + username: data.username, + display_name: data.display_name, + description: data.description + }; + + const response = await fetch(`${API_BASE_URL}/user/profile`, { + method: 'PUT', + headers: { + ...getAuthHeaders(token, true), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(backendData) + }); + + return response.ok; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } +} + +/** + * Updates user bio + */ +export async function updateBio(token: string, bio: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/bio`, { + method: 'PUT', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ bio }) + }); + + return response.ok; + } catch (error) { + console.error('Error updating bio:', error); + return false; + } +} + +/** + * Fetches user profile data by username + */ +export async function fetchUserProfile(token: string, username: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/${username}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile:', error); + return null; + } +} + +/** + * Fetches user profile data by user ID + */ +export async function fetchUserProfileById(token: string, userId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile by ID:', error); + return null; + } +} + +/** + * Toggles verification status for a user (owner only) + */ +export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error verifying user:', error); + return null; + } +} + +/** + * In-memory cache for user similarity results + * Key: userId, Value: similarity result + */ +const similarityCache = new Map(); + +/** + * Checks if a user is similar to any verified user + * Results are cached in memory to avoid redundant API calls + */ +export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { + // Check cache first + if (similarityCache.has(userId)) { + return similarityCache.get(userId) ?? null; + } + + try { + const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + let result: {isSimilar: boolean, similarTo?: string} | null = null; + if (response.ok) { + result = await response.json(); + } + + // Cache the result (even if null/error) + similarityCache.set(userId, result); + return result; + } catch (error) { + console.error('Error checking user similarity:', error); + const result: null = null; + // Cache null result to avoid retrying on errors + similarityCache.set(userId, result); + return result; + } +} + +/** + * Suspends a user account (admin only) + */ +export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, { + method: 'POST', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error suspending user:', error); + return null; + } +} + +/** + * Unsuspends a user account (admin only) + */ +export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error unsuspending user:', error); + return null; + } +} + +/** + * Deletes a user account (admin only) + */ +export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error deleting user:', error); + return null; + } +} + diff --git a/frontend/src/core/api/crypto.ts b/frontend/src/core/api/crypto.ts new file mode 100644 index 0000000..ddb668f --- /dev/null +++ b/frontend/src/core/api/crypto.ts @@ -0,0 +1,76 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; + +/** + * Fetches the current user's public key + */ +export async function fetchPublicKey(token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers }); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.publicKey) return null; + return ub64(data.publicKey); +} + +/** + * Uploads the current user's public key + */ +export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { + const payload: UploadPublicKeyRequest = { + publicKey: b64(publicKey) + } + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload public key"); +} + +/** + * Fetches another user's public key by user ID + */ +export async function fetchUserPublicKey(userId: number, token: string): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return null; + const data = await res.json(); + return data.publicKey; +} + +/** + * Fetches the current user's backup blob + */ +export async function fetchBackupBlob(token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "GET", + headers + }); + if (res.ok) { + const response: BackupBlob = await res.json(); + return response.blob; + } else { + return null; + } +} + +/** + * Uploads the current user's backup blob + */ +export async function uploadBackupBlob(blobJson: string, token: string): Promise { + const payload: BackupBlob = { blob: blobJson } + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload backup blob"); +} + diff --git a/frontend/src/core/api/dm.ts b/frontend/src/core/api/dm.ts new file mode 100644 index 0000000..b0cc194 --- /dev/null +++ b/frontend/src/core/api/dm.ts @@ -0,0 +1,178 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { randomBytes } from "@/utils/crypto/kdf"; +import { getCurrentKeys } from "./account"; +import { request } from "@/core/websocket"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; +import { fetchUserPublicKey } from "./crypto"; +import { fetchUsers, searchUsers } from "./users"; + +export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Obtain the key + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); + + // Decrypt + const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); + return new TextDecoder().decode(msg); +} + +export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return []; + const data = await response.json(); + return data.messages || []; +} + +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + +export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Encryption key + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Encrypt the message + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); + const wrap = await aesGcmEncrypt(wk, mk); + + const payload: SendDMRequest = { + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + }; + if (replyToId) payload.replyToId = replyToId; + + await request({ + type: "dmSend", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: payload + }); +} + +export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + const wrap = await aesGcmEncrypt(wk, mk); + + const form = new FormData(); + const names: string[] = []; + function sliceBuffer(u8: Uint8Array): ArrayBuffer { + return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + } + + for (const f of files) { + // Encrypt file with same mk + const data = new Uint8Array(await f.arrayBuffer()); + const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); + const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); + const serverName = f.name; // server uses provided name + names.push(serverName); + form.append("files", new File([blob], serverName)); + } + form.append("fileNames", JSON.stringify(names)); + + // Merge files metadata into plaintext JSON and encrypt + let obj: DmEncryptedJSON; + try { + obj = JSON.parse(plaintextJson); + } catch { + obj = { type: "text", data: { content: String(plaintextJson) } }; + } + + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj))); + form.append("dm_payload", JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } satisfies BaseDmEnvelope)); + + await fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, false), + body: form + }); +} + +export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); + const wrap = await aesGcmEncrypt(wk, mk); + + await request({ + type: "dmEdit", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { + id, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext), + salt: b64(wkSalt) + } + } as DMEditRequest); +} + +export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { + await request({ + type: "dmDelete", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id, recipientId } + }); +} + +export interface DMConversationResponse { + user: User; + lastMessage: DmEnvelope; + unreadCount: number; +} + +export async function fetchDMConversations(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/dm/conversations`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.conversations || []; +} + diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index 632a40f..b0cc194 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,12 +1,14 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./authApi"; +import { getAuthHeaders } from "./account"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { randomBytes } from "@/utils/crypto/kdf"; -import { getCurrentKeys } from "./authApi"; +import { getCurrentKeys } from "./account"; import { request } from "@/core/websocket"; -import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import { b64, ub64 } from "@/utils/utils"; +import { fetchUserPublicKey } from "./crypto"; +import { fetchUsers, searchUsers } from "./users"; export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { const keys = getCurrentKeys(); @@ -23,20 +25,6 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string return new TextDecoder().decode(msg); } -export async function fetchUsers(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) }); - if (!res.ok) return []; - const data = await res.json(); - return data.users || []; -} - -export async function fetchUserPublicKey(userId: number, token: string): Promise { - const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) }); - if (!res.ok) return null; - const data = await res.json(); - return data.publicKey; -} - export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { headers: getAuthHeaders(token, true) @@ -46,6 +34,9 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe return data.messages || []; } +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { const keys = getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); @@ -185,13 +176,3 @@ export async function fetchDMConversations(token: string): Promise { - if (query.length < 2) return []; - - const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { - headers: getAuthHeaders(token, true) - }); - if (!res.ok) return []; - const data = await res.json(); - return data.users || []; -} diff --git a/frontend/src/core/api/files.ts b/frontend/src/core/api/files.ts new file mode 100644 index 0000000..01fe6bd --- /dev/null +++ b/frontend/src/core/api/files.ts @@ -0,0 +1,39 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +/** + * Gets the URL for a normal (unencrypted) file + */ +export function getNormalFileUrl(filename: string): string { + return `${API_BASE_URL}/uploads/files/normal/${filename}`; +} + +/** + * Gets the URL for an encrypted file + */ +export function getEncryptedFileUrl(filename: string): string { + return `${API_BASE_URL}/uploads/files/encrypted/${filename}`; +} + +/** + * Fetches a normal file (unencrypted) + */ +export async function fetchNormalFile(filename: string, token: string): Promise { + const res = await fetch(getNormalFileUrl(filename), { + headers: getAuthHeaders(token, false) + }); + if (!res.ok) throw new Error("Failed to fetch file"); + return await res.blob(); +} + +/** + * Fetches an encrypted file + */ +export async function fetchEncryptedFile(filename: string, token: string): Promise { + const res = await fetch(getEncryptedFileUrl(filename), { + headers: getAuthHeaders(token, false) + }); + if (!res.ok) throw new Error("Failed to fetch encrypted file"); + return await res.blob(); +} + diff --git a/frontend/src/core/api/messaging.ts b/frontend/src/core/api/messaging.ts new file mode 100644 index 0000000..6cc3261 --- /dev/null +++ b/frontend/src/core/api/messaging.ts @@ -0,0 +1,87 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { Message, Messages, SendMessageRequest } from "@/core/types"; +import { request } from "@/core/websocket"; + +/** + * Fetches public chat messages + */ +export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise { + let url = `${API_BASE_URL}/get_messages?limit=${limit}`; + if (beforeId) { + url += `&before_id=${beforeId}`; + } + const response = await fetch(url, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return []; + const data: Messages = await response.json(); + return data.messages || []; +} + +/** + * Sends a public chat message via WebSocket + */ +export async function sendMessage(content: string, replyToId: number | null, authToken: string): Promise { + await request({ + data: { + content: content.trim(), + reply_to_id: replyToId ?? null + }, + credentials: { + scheme: "Bearer", + credentials: authToken + }, + type: "sendMessage" + } satisfies SendMessageRequest); +} + +/** + * Sends a public chat message with files via HTTP + */ +export async function sendMessageWithFiles( + content: string, + replyToId: number | null, + files: File[], + authToken: string +): Promise { + const form = new FormData(); + form.append("payload", JSON.stringify({ + content: content.trim(), + reply_to_id: replyToId ?? null + } satisfies SendMessageRequest["data"])); + for (const f of files) form.append("files", f, f.name); + const res = await fetch(`${API_BASE_URL}/send_message`, { + method: "POST", + headers: getAuthHeaders(authToken, false), + body: form + }); + if (!res.ok) { + const error = await res.text(); + throw new Error(error || "Failed to send message with files"); + } +} + +/** + * Edits a public chat message + */ +export async function editMessage(messageId: number, newContent: string, authToken: string): Promise { + const res = await fetch(`${API_BASE_URL}/edit_message/${messageId}`, { + method: "PUT", + headers: getAuthHeaders(authToken, true), + body: JSON.stringify({ content: newContent }) + }); + if (!res.ok) throw new Error("Failed to edit message"); +} + +/** + * Deletes a public chat message + */ +export async function deleteMessage(messageId: number, authToken: string): Promise { + const res = await fetch(`${API_BASE_URL}/delete_message/${messageId}`, { + method: "DELETE", + headers: getAuthHeaders(authToken, true) + }); + if (!res.ok) throw new Error("Failed to delete message"); +} + diff --git a/frontend/src/core/api/moderation.ts b/frontend/src/core/api/moderation.ts new file mode 100644 index 0000000..6786973 --- /dev/null +++ b/frontend/src/core/api/moderation.ts @@ -0,0 +1,54 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +export interface BlocklistResponse { + words: string[]; +} + +export interface BlocklistUpdateRequest { + words: string[]; +} + +export interface BlocklistUpdateResponse { + added?: string[]; + removed?: string[]; + words: string[]; +} + +/** + * Fetches the current blocklist (admin only) + */ +export async function getBlocklist(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch blocklist"); + return await res.json(); +} + +/** + * Adds words to the blocklist (admin only) + */ +export async function addToBlocklist(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to add to blocklist"); + return await res.json(); +} + +/** + * Removes words from the blocklist (admin only) + */ +export async function removeFromBlocklist(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "DELETE", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to remove from blocklist"); + return await res.json(); +} + diff --git a/frontend/src/core/api/profileApi.ts b/frontend/src/core/api/profileApi.ts index 5a25935..b161756 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -1,4 +1,4 @@ -import { getAuthHeaders } from "./authApi"; +import { getAuthHeaders } from "./account"; import { API_BASE_URL } from "@/core/config"; import type { UserProfile } from "@/core/types"; @@ -208,3 +208,67 @@ export async function checkUserSimilarity(userId: number, token: string): Promis return result; } } + +/** + * Suspends a user account (admin only) + */ +export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, { + method: 'POST', + headers: getAuthHeaders(token), + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error suspending user:', error); + return null; + } +} + +/** + * Unsuspends a user account (admin only) + */ +export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, { + method: 'POST', + headers: getAuthHeaders(token) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error unsuspending user:', error); + return null; + } +} + +/** + * Deletes a user account (admin only) + */ +export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, { + method: 'POST', + headers: getAuthHeaders(token) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error deleting user:', error); + return null; + } +} diff --git a/frontend/src/core/api/push.ts b/frontend/src/core/api/push.ts new file mode 100644 index 0000000..253b5cb --- /dev/null +++ b/frontend/src/core/api/push.ts @@ -0,0 +1,50 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +export interface PushSubscriptionRequest { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface PushSubscriptionResponse { + status: string; + message: string; +} + +/** + * Subscribes the current user to push notifications + */ +export async function subscribeToPush( + subscription: PushSubscriptionRequest, + token: string +): Promise { + const res = await fetch(`${API_BASE_URL}/push/subscribe`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify(subscription) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" })); + throw new Error(error.detail || "Failed to subscribe to push notifications"); + } + return await res.json(); +} + +/** + * Unsubscribes the current user from push notifications + */ +export async function unsubscribeFromPush(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, { + method: "DELETE", + headers: getAuthHeaders(token, true) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" })); + throw new Error(error.detail || "Failed to unsubscribe from push notifications"); + } + return await res.json(); +} + diff --git a/frontend/src/core/api/securityApi.ts b/frontend/src/core/api/securityApi.ts deleted file mode 100644 index a8488b8..0000000 --- a/frontend/src/core/api/securityApi.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders, deriveAuthSecret } from "@/core/api/authApi"; - -export async function changePassword( - token: string, - username: string, - currentPassword: string, - newPassword: string, - logoutAllExceptCurrent: boolean -): Promise { - const currentDerived = await deriveAuthSecret(username, currentPassword); - const newDerived = await deriveAuthSecret(username, newPassword); - const res = await fetch(`${API_BASE_URL}/change-password`, { - method: "POST", - headers: getAuthHeaders(token), - body: JSON.stringify({ - currentPasswordDerived: currentDerived, - newPasswordDerived: newDerived, - logoutAllExceptCurrent - }) - }); - if (!res.ok) throw new Error("Failed to change password"); -} - -export async function deleteAccount(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/account/delete`, { - method: "POST", - headers: getAuthHeaders(token) - }); - if (!res.ok) { - const error = await res.json().catch(() => ({ detail: "Failed to delete account" })); - throw new Error(error.detail || "Failed to delete account"); - } -} - - diff --git a/frontend/src/core/api/users.ts b/frontend/src/core/api/users.ts new file mode 100644 index 0000000..31dcb2c --- /dev/null +++ b/frontend/src/core/api/users.ts @@ -0,0 +1,28 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { User } from "@/core/types"; + +/** + * Fetches a list of all users (excluding current user) + */ +export async function fetchUsers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + +/** + * Searches for users by username query + */ +export async function searchUsers(query: string, token: string): Promise { + if (query.length < 2) return []; + + const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + diff --git a/frontend/src/core/api/webrtc.ts b/frontend/src/core/api/webrtc.ts new file mode 100644 index 0000000..81f7ff7 --- /dev/null +++ b/frontend/src/core/api/webrtc.ts @@ -0,0 +1,15 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { IceServersResponse } from "@/core/types"; + +/** + * Fetches ICE server configuration for WebRTC + */ +export async function getIceServers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/webrtc/ice`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch ICE servers"); + return await res.json(); +} + diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index 5c0c926..e08c213 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -2,7 +2,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/sy import { randomBytes } from "@/utils/crypto/kdf"; import { b64, ub64 } from "@/utils/utils"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { getCurrentKeys } from "@/core/api/authApi"; +import { getCurrentKeys } from "@/core/api/account"; import type { WrappedSessionKeyPayload } from "@/core/types"; export interface CallSessionKey { diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 1680908..470e42c 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,8 +1,9 @@ -import { getAuthHeaders, getAuthToken } from "@/core/api/authApi"; -import type { CallSignalingMessage, IceServersResponse, WrappedSessionKeyPayload } from "@/core/types"; +import { getAuthToken } from "@/core/api/account"; +import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; +import { getIceServers as fetchIceServers } from "@/core/api/webrtc"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; -import { fetchUserPublicKey } from "@/core/api/dmApi"; +import { fetchUserPublicKey } from "@/core/api/dm"; import { importAesGcmKey } from "@/utils/crypto/symmetric"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; @@ -99,16 +100,10 @@ export class WebRTCCall { */ private async getIceServers(): Promise { try { - const response = await fetch("/api/webrtc/ice", { - headers: getAuthHeaders(getAuthToken()!) - }); - - if (response.ok) { - const data = await response.json() as IceServersResponse; - return data.iceServers || []; - } else { - console.warn("Failed to fetch ICE servers:", response.status, response.statusText); - } + const token = getAuthToken(); + if (!token) throw new Error("No auth token"); + const data = await fetchIceServers(token); + return data.iceServers || []; } catch (error) { console.warn("Failed to fetch ICE servers:", error); } diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index bce9df8..b8755d2 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { checkUserSimilarity } from "@/core/api/profileApi"; +import { checkUserSimilarity } from "@/core/api/account/profile"; import { useAppState } from "@/pages/chat/state"; import { MaterialIcon } from "@/utils/material"; diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 07ed3de..4ddfa54 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { verifyUser } from "@/core/api/profileApi"; +import { verifyUser } from "@/core/api/account/profile"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton } from "@/utils/material"; diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index a0760dd..ac94d75 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL } from "@/core/config"; +import { subscribeToPush } from "@/core/api/push"; import { isElectron } from "@/core/electron/electron"; import { websocket } from "@/core/websocket"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; @@ -89,16 +89,8 @@ async function sendSubscriptionToServer(token: string): Promise { }; try { - const response = await fetch(`${API_BASE_URL}/push/subscribe`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: JSON.stringify(subscriptionData) - }); - - return response.ok; + await subscribeToPush(subscriptionData, token); + return true; } catch (error) { console.error("Failed to send subscription to server:", error); return false; diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index be6b091..4d3a930 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -2,11 +2,10 @@ import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; -import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; -import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest } from "@/core/types"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; @@ -86,16 +85,8 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { password: derived } - const response = await fetch(`${API_BASE_URL}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request) - }); - - if (response.ok) { - const data: LoginResponse = await response.json(); + try { + const data = await login(request); setUser(data.token, data.user); try { @@ -126,20 +117,16 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { } catch (e) { console.error("Notification setup failed:", e); } - } else { - const data: ErrorResponse = await response.json(); - - if (response.status === 403 && response.headers.get("suspension_reason")) { - const suspensionReason = response.headers.get("suspension_reason"); + } catch (error: any) { + if (error.message && error.message.includes("suspension")) { const setSuspended = useAppState.getState().setSuspended; - setSuspended(suspensionReason || "No reason provided"); + setSuspended(error.message || "No reason provided"); return; } - - showAlert("danger", data.message || "Неверное имя пользователя или пароль"); + showAlert("danger", error.message || "Неверное имя пользователя или пароль"); } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); } finally { setIsLoading(false); } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index b3e5c02..9ef19b5 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -2,11 +2,10 @@ import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; -import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; -import { API_BASE_URL } from "@/core/config"; +import type { RegisterRequest } from "@/core/types"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import type { Alert, AlertType } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth"; @@ -115,16 +114,8 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { confirm_password: derived } - const response = await fetch(`${API_BASE_URL}/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request) - }); - - if (response.ok) { - const data: LoginResponse = await response.json(); + try { + const data = await register(request); setUser(data.token, data.user); try { @@ -134,12 +125,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { } navigate("/chat"); - } else { - const data: ErrorResponse = await response.json(); - showAlert("danger", data.message || "Ошибка при регистрации"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка при регистрации"); } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); } finally { setIsLoading(false); } diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 28dfb5f..e652b18 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -7,7 +7,7 @@ import { sendDMViaWebSocket, fetchDMConversations, type DMConversationResponse -} from "@/core/api/dmApi"; +} from "@/core/api/dm"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index 3e0eb65..aa3f57b 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from "react"; import { useAppState } from "@/pages/chat/state"; -import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi"; +import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 52679f4..7aea4c1 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -4,9 +4,9 @@ import { request } from "@/core/websocket"; import { MessagePanel } from "./ui/right/panels/MessagePanel"; import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel"; import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel"; -import { getAuthHeaders } from "@/core/api/authApi"; -import { restoreKeys } from "@/core/api/authApi"; +import { restoreKeys } from "@/core/api/account"; import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "@/core/api/account"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -298,12 +298,12 @@ export const useAppState = create((set, get) => ({ const token = localStorage.getItem('authToken'); if (token) { - const response = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token) + // Fetch full user profile + const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) }); - - if (response.ok) { - const user: User = await response.json(); + if (fullResponse.ok) { + const user: User = await fullResponse.json(); restoreKeys(); // Check if user is suspended diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 0a67f6f..33f8ade 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -5,7 +5,7 @@ import { CallWindow } from "./right/calls/CallWindow"; import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useAppState } from "@/pages/chat/state"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; +import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import styles from "@/pages/chat/css/layout.module.scss"; export default function ChatPage() { diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index b576348..11546bb 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -4,7 +4,7 @@ import type { ProfileDialogData } from "@/pages/chat/state"; import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { prompt } from "mdui/functions/prompt"; -import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi"; +import { updateProfile, uploadProfilePicture, fetchUserProfileById, suspendUser, unsuspendUser, deleteUser } from "@/core/api/account/profile"; import { RichTextArea } from "@/core/components/RichTextArea"; import { StatusBadge } from "@/core/components/StatusBadge"; import { VerifyButton } from "@/core/components/VerifyButton"; @@ -349,37 +349,20 @@ export function ProfileDialog() { }); if (reason) { - const response = await fetch(`/api/user/${currentData.userId}/suspend`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ reason }) - }); - - if (response.ok) { + const result = await suspendUser(currentData.userId, reason, user.authToken!); + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to suspend user:", error); + console.error("Failed to suspend user"); } } } else { // Unsuspend user - const response = await fetch(`/api/user/${currentData.userId}/unsuspend`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - } - }); - - if (response.ok) { + const result = await unsuspendUser(currentData.userId, user.authToken!); + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to unsuspend user:", error); + console.error("Failed to unsuspend user"); } } } catch (error) { @@ -398,19 +381,12 @@ export function ProfileDialog() { cancelText: "Cancel" }); - const response = await fetch(`/api/user/${currentData.userId}/delete`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - } - }); + const result = await deleteUser(currentData.userId, user.authToken!); - if (response.ok) { + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to delete user:", error); + console.error("Failed to delete user"); } } catch (error) { // User cancelled or error occurred diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index fb40dad..0994cdb 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -1,9 +1,8 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import { useAppState } from "@/pages/chat/state"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; -import { fetchUserPublicKey } from "@/core/api/dmApi"; +import { fetchMessages } from "@/core/api/messaging"; +import { fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -51,16 +50,10 @@ export function UnifiedChatsList() { if (!user.authToken) return; try { - const response = await fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders(user.authToken) - }); - - if (response.ok) { - const data = await response.json(); - if (data.messages?.length > 0) { - const lastMessage = data.messages[data.messages.length - 1]; - setLastMessages({ general: lastMessage }); - } + const messages = await fetchMessages(user.authToken, 1); + if (messages?.length > 0) { + const lastMessage = messages[messages.length - 1]; + setLastMessages({ general: lastMessage }); } } catch (error) { console.error("Error loading last messages:", error); diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index 9ed8dae..b37d0e1 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useAppState } from "@/pages/chat/state"; -import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; +import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; import { onlineStatusManager } from "@/core/onlineStatusManager"; diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 16e39a9..9061e34 100644 --- a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -1,6 +1,6 @@ import { MaterialList, MaterialListItem } from "@/utils/material"; import { useAppState } from "@/pages/chat/state"; -import { deleteAccount } from "@/core/api/securityApi"; +import { deleteAccount } from "@/core/api/account"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index 8b6f9f1..78a9da6 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { StyledDialog } from "@/core/components/StyledDialog"; import type { DialogProps } from "@/core/types"; import { useAppState } from "@/pages/chat/state"; -import { changePassword } from "@/core/api/securityApi"; +import { changePassword } from "@/core/api/account"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index ac9dbc7..b6ccc4f 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useImmer } from "use-immer"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; import { useAppState } from "@/pages/chat/state"; -import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi"; +import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index d5f0931..ba5c8bf 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -3,8 +3,7 @@ import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from import { useAppState } from "@/pages/chat/state"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; +import { unsubscribeFromPush } from "@/core/api/push"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { @@ -74,14 +73,7 @@ export function NotificationsPanel() { } // Then unsubscribe from server - const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, { - method: "DELETE", - headers: getAuthHeaders(authToken) - }); - - if (!response.ok) { - throw new Error("Failed to unsubscribe from push notifications"); - } + await unsubscribeFromPush(authToken); // After unsubscribing, permission is still granted but we're not subscribed // So we keep the state as disabled (false) diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 757cdd9..137318a 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -5,12 +5,11 @@ import Quote from "@/core/components/Quote"; import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; -import { getCurrentKeys } from "@/core/api/authApi"; +import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { getAuthHeaders } from "@/core/api/authApi"; import { useAppState } from "@/pages/chat/state"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; +import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 6956d1c..dc841f4 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -6,8 +6,8 @@ import { sendDmWithFiles, editDmEnvelope, deleteDmEnvelope -} from "@/core/api/dmApi"; -import { fetchUserProfileById } from "@/core/api/profileApi"; +} from "@/core/api/dm"; +import { fetchUserProfileById } from "@/core/api/account/profile"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/pages/chat/state"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index d8c0e7f..cc89906 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -1,9 +1,8 @@ import { MessagePanel } from "./MessagePanel"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; import { request } from "@/core/websocket"; -import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types"; +import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; export class PublicChatPanel extends MessagePanel { private messagesLoaded: boolean = false; @@ -42,18 +41,12 @@ export class PublicChatPanel extends MessagePanel { this.setLoading(true); try { - const response = await fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders(this.currentUser.authToken) - }); - - if (response.ok) { - const data = await response.json(); - if (data.messages && data.messages.length > 0) { - this.clearMessages(); - data.messages.forEach((msg: Message) => { - this.addMessage(msg); - }); - } + const messages = await fetchMessages(this.currentUser.authToken); + if (messages && messages.length > 0) { + this.clearMessages(); + messages.forEach((msg: Message) => { + this.addMessage(msg); + }); } this.messagesLoaded = true; } catch (error) { @@ -68,35 +61,9 @@ export class PublicChatPanel extends MessagePanel { try { if (files.length === 0) { - const response = await request({ - data: { - content: content.trim(), - reply_to_id: replyToId ?? null - }, - credentials: { - scheme: "Bearer", - credentials: this.currentUser.authToken - }, - type: "sendMessage" - } satisfies SendMessageRequest); - if (response.error) { - console.error("Error sending message:", response.error); - } + await sendMessage(content, replyToId ?? null, this.currentUser.authToken); } else { - const form = new FormData(); - form.append("payload", JSON.stringify({ - content: content.trim(), - reply_to_id: replyToId ?? null - } satisfies SendMessageRequest["data"])); - for (const f of files) form.append("files", f, f.name); - const res = await fetch(`${API_BASE_URL}/send_message`, { - method: "POST", - headers: getAuthHeaders(this.currentUser.authToken, false), - body: form - }); - if (!res.ok) { - console.error("Error sending message with files", await res.text()); - } + await sendMessageWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); } } catch (error) { console.error("Error sending message:", error); From 0725eecd41ec86e279cd4ec95a5cab3549a7d476 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 22:18:43 +0300 Subject: [PATCH 2/4] Add SVG optimization --- frontend/plugins/optimizeSvg.ts | 67 +++++++++++++++++++++++++++++++++ frontend/vite.config.ts | 2 + package.json | 1 + 3 files changed, 70 insertions(+) create mode 100644 frontend/plugins/optimizeSvg.ts diff --git a/frontend/plugins/optimizeSvg.ts b/frontend/plugins/optimizeSvg.ts new file mode 100644 index 0000000..9b1d120 --- /dev/null +++ b/frontend/plugins/optimizeSvg.ts @@ -0,0 +1,67 @@ +import type { Plugin } from 'vite'; +import { optimize } from 'svgo'; + +export interface OptimizeSvgOptions { + /** + * Whether to enable SVG optimization + * @default true + */ + enabled?: boolean; +} + +const svgoConfig: Parameters[1] = { + multipass: true, + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + // Keep IDs if they might be referenced (minify instead of remove) + cleanupIds: { + remove: false, + minify: true + } + } + } + } + ] +}; + +/** + * Optimizes SVG files during build by: + * - Minifying SVG code + * - Removing metadata and comments + * - Removing unnecessary attributes + * - Optimizing paths and shapes + */ +export function optimizeSvg(options?: OptimizeSvgOptions): Plugin { + const enabled = options?.enabled !== false; + + return { + name: 'optimize-svg', + apply: 'build', + enforce: 'post', + async generateBundle(options, bundle) { + if (!enabled) return; + + // Optimize SVGs in the bundle + for (const [fileName, chunk] of Object.entries(bundle)) { + if (fileName.endsWith('.svg') && chunk.type === 'asset') { + try { + const svgContent = typeof chunk.source === 'string' + ? chunk.source + : Buffer.from(chunk.source).toString('utf-8'); + + const result = optimize(svgContent, svgoConfig); + + if (result.data && result.data !== svgContent) { + chunk.source = result.data; + } + } catch (error) { + console.warn(`Failed to optimize SVG ${fileName}:`, error); + } + } + } + } + }; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ab5318a..719e614 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,6 +7,7 @@ import path from "path"; import { visualizer } from 'rollup-plugin-visualizer'; import sassDts from 'vite-plugin-sass-dts'; import { optimizeCssModules } from './plugins/optimizeCssModules'; +import { optimizeSvg } from './plugins/optimizeSvg'; const currentDir = path.resolve(__dirname); const outDir = process.env.VITE_ELECTRON ? `${currentDir}/build/electron` : `${currentDir}/build/normal`; @@ -21,6 +22,7 @@ const plugins: PluginOption[] = [ enabledMode: ['development', 'production'] }), optimizeCssModules(), + optimizeSvg(), createHtmlPlugin({ minify: { collapseWhitespace: true, diff --git a/package.json b/package.json index d59d2b4..db5d7ce 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "postcss": "^8.5.6", "rollup-plugin-visualizer": "^6.0.4", "sass-embedded": "^1.93.0", + "svgo": "^4.0.0", "terser": "^5.44.0", "typescript": "~5.9.2", "vite": "^7.1.6", From c6c60e640446729cba13a23170f88f23d24e82ce Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 22:26:52 +0300 Subject: [PATCH 3/4] Simplify code --- frontend/src/pages/ProtectedRoute.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index dec0ff5..9f1569f 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -1,21 +1,13 @@ -import { useEffect } from "react"; +import type { ReactNode } from "react"; import { useAppState } from "./chat/state"; -import { useNavigate } from "react-router-dom"; +import { Navigate } from "react-router-dom"; interface ProtectedRouteProps { - children: React.ReactNode; + children: ReactNode; } export default function ProtectedRoute({ children }: ProtectedRouteProps) { const { user } = useAppState(); - const navigate = useNavigate(); - useEffect(() => { - if (!user.authToken) { - navigate("/login"); - return; - } - }, [user.authToken, user.currentUser, navigate]); - - return <>{children}; + return !user.authToken ? : children; } From dc4536cabc33340de3b130012f3e012bd7cce2d8 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 23:21:30 +0300 Subject: [PATCH 4/4] Refactor state --- frontend/src/App.tsx | 8 +- frontend/src/core/components/StatusBadge.tsx | 4 +- frontend/src/core/components/VerifyButton.tsx | 4 +- frontend/src/core/onlineStatusManager.ts | 4 +- frontend/src/core/typingManager.ts | 10 +- frontend/src/core/websocket.ts | 6 +- frontend/src/pages/ProtectedRoute.tsx | 4 +- frontend/src/pages/auth/LoginForm.tsx | 6 +- frontend/src/pages/auth/RegisterForm.tsx | 4 +- frontend/src/pages/chat/hooks/useCall.ts | 59 +- frontend/src/pages/chat/hooks/useDM.ts | 8 +- frontend/src/pages/chat/hooks/useProfile.ts | 4 +- frontend/src/pages/chat/state.ts | 728 ------------------ frontend/src/pages/chat/ui/ChatPage.tsx | 8 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 16 +- .../src/pages/chat/ui/left/ChatHeader.tsx | 6 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 4 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 12 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 12 +- .../chat/ui/left/settings/AccountPanel.tsx | 4 +- .../ui/left/settings/ChangePasswordDialog.tsx | 4 +- .../chat/ui/left/settings/DevicesPanel.tsx | 4 +- .../ui/left/settings/NotificationsPanel.tsx | 4 +- .../pages/chat/ui/right/ChatMainHeader.tsx | 4 +- .../src/pages/chat/ui/right/ChatMessages.tsx | 4 +- frontend/src/pages/chat/ui/right/Message.tsx | 8 +- .../chat/ui/right/MessageContextMenu.tsx | 4 +- .../chat/ui/right/MessagePanelRenderer.tsx | 37 +- .../pages/chat/ui/right/OnlineIndicator.tsx | 6 +- .../src/pages/chat/ui/right/OnlineStatus.tsx | 8 +- .../src/pages/chat/ui/right/RightPanel.tsx | 6 +- .../pages/chat/ui/right/calls/CallWindow.tsx | 9 +- .../chat/ui/right/calls/MinimizedCallBar.tsx | 7 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 2 +- .../chat/ui/right/panels/MessagePanel.ts | 2 +- .../chat/ui/right/panels/PublicChatPanel.ts | 2 +- frontend/src/pages/home/HomePage.tsx | 4 +- frontend/src/state/call.ts | 123 +++ frontend/src/state/chat.ts | 150 ++++ frontend/src/state/presence.ts | 41 + frontend/src/state/profile.ts | 14 + frontend/src/state/types.ts | 73 ++ frontend/src/state/user.ts | 159 ++++ 43 files changed, 720 insertions(+), 866 deletions(-) delete mode 100644 frontend/src/pages/chat/state.ts create mode 100644 frontend/src/state/call.ts create mode 100644 frontend/src/state/chat.ts create mode 100644 frontend/src/state/presence.ts create mode 100644 frontend/src/state/profile.ts create mode 100644 frontend/src/state/types.ts create mode 100644 frontend/src/state/user.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cb4602..73e1724 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; import { ElectronTitleBar } from "./Electron"; -import { useAppState } from "./pages/chat/state"; +import { useUserStore } from "./state/user"; import { lazy, useEffect, useRef, useState } from "react"; import { parseProfileLink } from "./core/profileLinks"; import NotFoundPage from "./pages/not-found/NotFoundPage"; @@ -117,14 +117,14 @@ function AnimatedRoutes() { } export default function App() { - const { restoreUserFromStorage, user } = useAppState(); + const { restoreFromStorage, user } = useUserStore(); const [authReady, setAuthReady] = useState(false); useEffect(() => { - restoreUserFromStorage().finally(() => { + restoreFromStorage().finally(() => { setAuthReady(true); }); - }, [restoreUserFromStorage]); + }, [restoreFromStorage]); return authReady && ( diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index b8755d2..6268a45 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { checkUserSimilarity } from "@/core/api/account/profile"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialIcon } from "@/utils/material"; interface StatusBadgeProps { @@ -11,7 +11,7 @@ interface StatusBadgeProps { export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) { const [isSimilarToVerified, setIsSimilarToVerified] = useState(false); - const { user } = useAppState(); + const { user } = useUserStore(); const className = `status-badge ${size}`; diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 4ddfa54..111a6ac 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { verifyUser } from "@/core/api/account/profile"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; interface VerifyButtonProps { @@ -11,7 +11,7 @@ interface VerifyButtonProps { export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) { const [isVerifying, setIsVerifying] = useState(false); - const { user } = useAppState(); + const { user } = useUserStore(); // Only show for owner if (user.currentUser?.id !== 1) { diff --git a/frontend/src/core/onlineStatusManager.ts b/frontend/src/core/onlineStatusManager.ts index 6f2525a..fe05253 100644 --- a/frontend/src/core/onlineStatusManager.ts +++ b/frontend/src/core/onlineStatusManager.ts @@ -11,7 +11,7 @@ import type { SubscribeStatusWebSocketMessage, UnsubscribeStatusWebSocketMessage } from "./types"; -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; export interface UserStatus { online: boolean; @@ -96,7 +96,7 @@ export class OnlineStatusManager { this.statusCache.set(userId, { online, lastSeen }); // Update the global state - const { updateOnlineStatus } = useAppState.getState(); + const { updateOnlineStatus } = usePresenceStore.getState(); updateOnlineStatus(userId, online, lastSeen); } diff --git a/frontend/src/core/typingManager.ts b/frontend/src/core/typingManager.ts index da5b119..740c800 100644 --- a/frontend/src/core/typingManager.ts +++ b/frontend/src/core/typingManager.ts @@ -16,7 +16,7 @@ import type { DmTypingRequest, StopDmTypingRequest } from "./types"; -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; /** * Manages typing indicators for public chat and DMs @@ -133,7 +133,7 @@ export class TypingManager { * Handle incoming typing indicator from WebSocket */ handleTyping(message: TypingWebSocketMessage): void { - const { addTypingUser } = useAppState.getState(); + const { addTypingUser } = usePresenceStore.getState(); addTypingUser(message.data.userId, message.data.username); } @@ -141,7 +141,7 @@ export class TypingManager { * Handle incoming stop typing indicator from WebSocket */ handleStopTyping(message: StopTypingWebSocketMessage): void { - const { removeTypingUser } = useAppState.getState(); + const { removeTypingUser } = usePresenceStore.getState(); removeTypingUser(message.data.userId); } @@ -149,7 +149,7 @@ export class TypingManager { * Handle incoming DM typing indicator from WebSocket */ handleDmTyping(message: DmTypingWebSocketMessage): void { - const { setDmTypingUser } = useAppState.getState(); + const { setDmTypingUser } = usePresenceStore.getState(); setDmTypingUser(message.data.userId, true); } @@ -157,7 +157,7 @@ export class TypingManager { * Handle incoming stop DM typing indicator from WebSocket */ handleStopDmTyping(message: StopDmTypingWebSocketMessage): void { - const { setDmTypingUser } = useAppState.getState(); + const { setDmTypingUser } = usePresenceStore.getState(); setDmTypingUser(message.data.userId, false); } diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index d4400c1..6eea282 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -11,7 +11,7 @@ import { delay } from "@/utils/utils"; import { CallSignalingHandler } from "./calls/signaling"; import { onlineStatusManager } from "./onlineStatusManager"; import { typingManager } from "./typingManager"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; /** * Creates a new WebSocket connection to the chat server @@ -170,14 +170,14 @@ function setupEventHandlers(): void { typingManager.handleStopDmTyping(response as any); } else if (response.type === "suspended") { // Handle account suspension - const { setSuspended } = useAppState.getState(); + const { setSuspended } = useUserStore.getState(); const reason = response.data?.reason || "No reason provided"; setSuspended(reason); // Close WebSocket connection websocket.close(); } else if (response.type === "account_deleted") { // Handle account deletion - silent logout - const { logout } = useAppState.getState(); + const { logout } = useUserStore.getState(); logout(); // Close WebSocket connection websocket.close(); diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index 9f1569f..c4c51cc 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { useAppState } from "./chat/state"; +import { useUserStore } from "@/state/user"; import { Navigate } from "react-router-dom"; interface ProtectedRouteProps { @@ -7,7 +7,7 @@ interface ProtectedRouteProps { } export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { user } = useAppState(); + const { user } = useUserStore(); return !user.authToken ? : children; } diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 4d3a930..2f74010 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; import type { LoginRequest } from "@/core/types"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; @@ -53,7 +53,7 @@ interface LoginFormProps { export function LoginForm({ onSwitchMode }: LoginFormProps) { const [isLoading, setIsLoading] = useState(false); const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); + const setUser = useUserStore(state => state.setUser); const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { @@ -119,7 +119,7 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { } } catch (error: any) { if (error.message && error.message.includes("suspension")) { - const setSuspended = useAppState.getState().setSuspended; + const setSuspended = useUserStore.getState().setSuspended; setSuspended(error.message || "No reason provided"); return; } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index 9ef19b5..29961ed 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { motion, type Transition, type Variants } from "motion/react"; import { useImmer } from "use-immer"; import type { RegisterRequest } from "@/core/types"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; @@ -51,7 +51,7 @@ interface RegisterFormProps { export function RegisterForm({ onSwitchMode }: RegisterFormProps) { const [isLoading, setIsLoading] = useState(false); const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); + const setUser = useUserStore(state => state.setUser); const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { diff --git a/frontend/src/pages/chat/hooks/useCall.ts b/frontend/src/pages/chat/hooks/useCall.ts index 71e14c8..6372c31 100644 --- a/frontend/src/pages/chat/hooks/useCall.ts +++ b/frontend/src/pages/chat/hooks/useCall.ts @@ -1,4 +1,5 @@ -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; +import { useUserStore } from "@/state/user"; import * as WebRTC from "@/core/calls/webrtc"; import { CallSignalingHandler } from "@/core/calls/signaling"; import { setCallSignalingHandler } from "@/core/websocket"; @@ -15,7 +16,7 @@ let globalRemoteScreenShareRef = createRef(); export default function useCall() { const { - chat, + call, startCall, endCall, setCallStatus, @@ -26,8 +27,9 @@ export default function useCall() { setCallSessionKeyHash, setRemoteVideoEnabled, setRemoteScreenSharing, - user - } = useAppState(); + receiveCall + } = useCallStore(); + const { user } = useUserStore(); const remoteAudioRef = globalRemoteAudioRef; const localVideoRef = globalLocalVideoRef; @@ -40,8 +42,7 @@ export default function useCall() { const signalingHandler = new CallSignalingHandler(() => ({ receiveCall: (userId: number, username: string) => { // Use the receiveCall function from state - const state = useAppState.getState(); - state.receiveCall(userId, username); + receiveCall(userId, username); }, endCall, setCallSessionKeyHash, @@ -52,8 +53,8 @@ export default function useCall() { // Set up call state change handler WebRTC.callbacks.onCallStateChange = (userId: number, state: string) => { - const call = chat.call; - if (call.remoteUserId === userId) { + const currentCall = call; + if (currentCall.remoteUserId === userId) { switch (state) { case "connecting": setCallStatus("connecting"); @@ -183,15 +184,15 @@ export default function useCall() { WebRTC.cleanup(); setCallSignalingHandler(null); }; - }, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]); + }, [user.authToken, call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]); // Watch for session key hash changes and generate emojis useEffect(() => { - if (chat.call.sessionKeyHash && chat.call.encryptionEmojis.length === 0) { - const emojis = generateCallEmojis(chat.call.sessionKeyHash); - setCallEncryption(chat.call.sessionKeyHash, emojis); + if (call.sessionKeyHash && call.encryptionEmojis.length === 0) { + const emojis = generateCallEmojis(call.sessionKeyHash); + setCallEncryption(call.sessionKeyHash, emojis); } - }, [chat.call.sessionKeyHash, chat.call.encryptionEmojis.length, setCallEncryption]); + }, [call.sessionKeyHash, call.encryptionEmojis.length, setCallEncryption]); async function requestAudioPermissions(): Promise { try { @@ -249,12 +250,12 @@ export default function useCall() { } async function acceptCall() { - if (!chat.call.remoteUserId) { + if (!call.remoteUserId) { return; } setCallStatus("connecting"); - const success = await WebRTC.acceptCall(chat.call.remoteUserId); + const success = await WebRTC.acceptCall(call.remoteUserId); if (!success) { endCall(); @@ -262,46 +263,46 @@ export default function useCall() { } async function rejectCall() { - if (!chat.call.remoteUserId) { + if (!call.remoteUserId) { return; } - await WebRTC.rejectCall(chat.call.remoteUserId); + await WebRTC.rejectCall(call.remoteUserId); endCall(); } async function handleEndCall() { - if (chat.call.remoteUserId) { - await WebRTC.endCall(chat.call.remoteUserId); + if (call.remoteUserId) { + await WebRTC.endCall(call.remoteUserId); } endCall(); } function handleToggleMute() { - if (chat.call.remoteUserId) { - const isMuted = WebRTC.toggleMute(chat.call.remoteUserId); + if (call.remoteUserId) { + const isMuted = WebRTC.toggleMute(call.remoteUserId); // Update mute state in store - if (isMuted !== chat.call.isMuted) { + if (isMuted !== call.isMuted) { toggleMute(); } } } async function handleToggleVideo() { - if (chat.call.remoteUserId) { - const isEnabled = await WebRTC.toggleVideo(chat.call.remoteUserId); + if (call.remoteUserId) { + const isEnabled = await WebRTC.toggleVideo(call.remoteUserId); // Update video state in store - if (isEnabled !== chat.call.isVideoEnabled) { + if (isEnabled !== call.isVideoEnabled) { toggleVideo(); } } } async function handleToggleScreenShare() { - if (chat.call.remoteUserId) { - const isEnabled = await WebRTC.toggleScreenShare(chat.call.remoteUserId); + if (call.remoteUserId) { + const isEnabled = await WebRTC.toggleScreenShare(call.remoteUserId); // Update screen share state in store - if (isEnabled !== chat.call.isSharingScreen) { + if (isEnabled !== call.isSharingScreen) { toggleScreenShare(); } } @@ -336,7 +337,7 @@ export default function useCall() { } return { - call: chat.call, + call: call, initiateCall, acceptCall, rejectCall, diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index e652b18..92fb749 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { fetchUserPublicKey, fetchDMHistory, @@ -44,7 +45,8 @@ export function formatDMMessageContent( } export function useDM() { - const { user, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); + const { user } = useUserStore(); + const { setDmUsers, setActiveDm, addMessage, clearMessages } = useChatStore(); const [dmUsers, setDmUsersState] = useState([]); const [isLoadingUsers, setIsLoadingUsers] = useState(false); const [isLoadingHistory, setIsLoadingHistory] = useState(false); @@ -295,7 +297,7 @@ export function useDM() { // If conversation no longer exists, remove the user from the list setDmUsersState(prev => prev.filter(u => u.id !== userId)); // Get current dmUsers and filter out the removed user - const currentDmUsers = useAppState.getState().chat.dmUsers; + const currentDmUsers = useChatStore.getState().dmUsers; setDmUsers(currentDmUsers.filter((u: User) => u.id !== userId)); } } catch (error) { diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index aa3f57b..9a8abff 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,10 +1,10 @@ import { useState, useCallback, useEffect } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { - const { user } = useAppState(); + const { user } = useUserStore(); const [profileData, setProfileData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isUpdating, setIsUpdating] = useState(false); diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts deleted file mode 100644 index 7aea4c1..0000000 --- a/frontend/src/pages/chat/state.ts +++ /dev/null @@ -1,728 +0,0 @@ -import { create } from "zustand"; -import type { Message, User } from "@/core/types"; -import { request } from "@/core/websocket"; -import { MessagePanel } from "./ui/right/panels/MessagePanel"; -import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel"; -import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel"; -import { restoreKeys } from "@/core/api/account"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/account"; -import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; -import { isElectron } from "@/core/electron/electron"; -import { onlineStatusManager } from "@/core/onlineStatusManager"; -import { typingManager } from "@/core/typingManager"; - -export type ChatTabs = "chats" | "channels" | "contacts"; - -export type CallStatus = "calling" | "connecting" | "active" | "ended"; - -export interface ProfileDialogData { - userId?: number; - username?: string; - display_name?: string; - profilePicture?: string; - bio?: string; - memberSince?: string; - online?: boolean; - isOwnProfile: boolean; - verified?: boolean; - suspended?: boolean; - suspension_reason?: string | null; - deleted?: boolean; -} - -interface ActiveDM { - userId: number; - username: string; - publicKey: string | null -} - -interface CallState { - isActive: boolean; - status: CallStatus; - startTime: number | null; - isMuted: boolean; - remoteUserId: number | null; - remoteUsername: string | null; - isInitiator: boolean; - isMinimized: boolean; - sessionKeyHash: string | null; - encryptionEmojis: string[]; - isVideoEnabled: boolean; - isRemoteVideoEnabled: boolean; - isSharingScreen: boolean; - isRemoteScreenSharing: boolean; -} - -interface ChatState { - messages: Message[]; - currentChat: string; - activeTab: ChatTabs; - dmUsers: User[]; - activeDm: ActiveDM | null; - isSwitching: boolean; - setIsSwitching: (value: boolean) => void; - activePanel: MessagePanel | null; - publicChatPanel: PublicChatPanel | null; - dmPanel: DMPanel | null; - pendingPanel?: MessagePanel | null; - call: CallState; - profileDialog: ProfileDialogData | null; - onlineStatuses: Map; - typingUsers: Map; // userId -> username - dmTypingUsers: Map; -} - -export interface UserState { - currentUser: User | null; - authToken: string | null; - isSuspended: boolean; - suspensionReason: string | null; -} - -interface AppState { - // Chat state - chat: ChatState; - addMessage: (message: Message) => void; - updateMessage: (messageId: number, updatedMessage: Partial) => void; - removeMessage: (messageId: number) => void; - setCurrentChat: (chat: string) => void; - setActiveTab: (tab: ChatState["activeTab"]) => void; - setDmUsers: (users: User[]) => void; - setActiveDm: (dm: ChatState["activeDm"]) => void; - clearMessages: () => void; - setActivePanel: (panel: MessagePanel | null) => void; - setPendingPanel: (panel: MessagePanel | null) => void; - applyPendingPanel: () => void; - switchToPublicChat: (chatName: string) => Promise; - switchToDM: (dmData: DMPanelData) => Promise; - - // Call state - startCall: (userId: number, username: string) => void; - endCall: () => void; - setCallStatus: (status: CallStatus) => void; - toggleMute: () => void; - toggleCallMinimize: () => void; - receiveCall: (userId: number, username: string) => void; - setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; - setCallSessionKeyHash: (sessionKeyHash: string) => void; - toggleVideo: () => void; - toggleScreenShare: () => void; - setRemoteVideoEnabled: (enabled: boolean) => void; - setRemoteScreenSharing: (enabled: boolean) => void; - toggleCallMinimized: () => void; - - // User state - user: UserState; - setUser: (token: string, user: User) => void; - logout: () => void; - restoreUserFromStorage: () => Promise; - setSuspended: (reason: string) => void; - - // Profile dialog state - setProfileDialog: (data: ProfileDialogData | null) => void; - closeProfileDialog: () => void; - - // Online status and typing state - updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void; - addTypingUser: (userId: number, username: string) => void; - removeTypingUser: (userId: number) => void; - setDmTypingUser: (userId: number, isTyping: boolean) => void; -} - -export const useAppState = create((set, get) => ({ - // Chat state - chat: { - messages: [], - currentChat: "Общий чат", - activeTab: "chats", - dmUsers: [], - activeDm: null, - isSwitching: false, - setIsSwitching: (value: boolean) => set((state) => ({ - chat: { - ...state.chat, - isSwitching: value - } - })), - activePanel: null, - publicChatPanel: null, - dmPanel: null, - pendingPanel: null, - profileDialog: null, - call: { - isActive: false, - status: "ended", - startTime: null, - isMuted: false, - remoteUserId: null, - remoteUsername: null, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - }, - onlineStatuses: new Map(), - typingUsers: new Map(), - dmTypingUsers: new Map() - }, - addMessage: (message: Message) => set((state) => { - // Check if message already exists to prevent duplicates - const messageExists = state.chat.messages.some(msg => msg.id === message.id); - if (messageExists) { - return state; // Return unchanged state if message already exists - } - - return { - chat: { - ...state.chat, - messages: [...state.chat.messages, message] - } - }; - }), - updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ - chat: { - ...state.chat, - messages: state.chat.messages.map(msg => - msg.id === messageId ? { ...msg, ...updatedMessage } : msg - ) - } - })), - removeMessage: (messageId: number) => set((state) => ({ - chat: { - ...state.chat, - messages: state.chat.messages.filter(msg => msg.id !== messageId) - } - })), - clearMessages: () => set((state) => ({ - chat: { - ...state.chat, - messages: [] - } - })), - setCurrentChat: (chat: string) => set((state) => ({ - chat: { - ...state.chat, - currentChat: chat - } - })), - setActiveTab: (tab: ChatState["activeTab"]) => set((state) => ({ - chat: { - ...state.chat, - activeTab: tab - } - })), - setDmUsers: (users: User[]) => set((state) => ({ - chat: { - ...state.chat, - dmUsers: users - } - })), - setActiveDm: (dm: ChatState["activeDm"]) => set((state) => ({ - chat: { - ...state.chat, - activeDm: dm - } - })), - - // User state - user: { - currentUser: null, - authToken: null, - isSuspended: false, - suspensionReason: null - }, - setUser: (token: string, user: User) => { - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: user.suspended || false, - suspensionReason: user.suspension_reason || null - } - })); - - // Initialize managers with auth token - onlineStatusManager.setAuthToken(token); - typingManager.setAuthToken(token); - - // Store credentials in localStorage - try { - localStorage.setItem('authToken', token); - localStorage.setItem('currentUser', JSON.stringify(user)); - } catch (error) { - console.error('Failed to store credentials in localStorage:', error); - } - - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} - }, - logout: () => { - // Clear localStorage - try { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); - } catch (error) { - console.error('Failed to clear localStorage:', error); - } - - // Cleanup managers - onlineStatusManager.setAuthToken(null); - typingManager.setAuthToken(null); - onlineStatusManager.cleanup(); - typingManager.cleanup(); - - set(() => ({ - user: { - currentUser: null, - authToken: null, - isSuspended: false, - suspensionReason: null - } - })); - }, - restoreUserFromStorage: async () => { - try { - const token = localStorage.getItem('authToken'); - - if (token) { - // Fetch full user profile - const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token, true) - }); - if (fullResponse.ok) { - const user: User = await fullResponse.json(); - restoreKeys(); - - // Check if user is suspended - if (user.suspended) { - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: true, - suspensionReason: user.suspension_reason || null - } - })); - return; // Don't initialize managers or notifications for suspended users - } - - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: false, - suspensionReason: null - } - })); - - // Initialize managers with auth token - onlineStatusManager.setAuthToken(token); - typingManager.setAuthToken(token); - - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} - - // Initialize notifications after successful credential restoration - try { - if (isSupported()) { - const initialized = await initialize(); - if (initialized) { - await subscribe(token); - - // For Electron, start the notification receiver - if (isElectron) { - await startElectronReceiver(); - } - } - } - } catch (e) { - console.error("Notification setup failed (restored):", e); - } - } else { - throw new Error("Unable to authenticate"); - } - } - } catch (error) { - console.error('Failed to restore user from localStorage:', error); - // Clear invalid data - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); - } - }, - - // Panel management - setActivePanel: (panel: MessagePanel | null) => { - const state = get(); - // Deactivate the current panel before switching - if (state.chat.activePanel && state.chat.activePanel !== panel) { - state.chat.activePanel.deactivate(); - } - return set((state) => ({ - chat: { - ...state.chat, - activePanel: panel - } - })); - }, - // Stash a panel to be applied after switch-out animation ends - setPendingPanel: (panel: MessagePanel | null) => set((state) => ({ - chat: { - ...state.chat, - pendingPanel: panel - } - })), - // Apply pending panel atomically and update related fields - applyPendingPanel: () => { - const state = get(); - // Deactivate the current panel before switching - if (state.chat.activePanel) { - state.chat.activePanel.deactivate(); - } - return set((state) => ({ - chat: { - ...state.chat, - activePanel: state.chat.pendingPanel || state.chat.activePanel, - // when switching to public chat, keep reference if type matches - publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel) - ? (state.chat.pendingPanel as PublicChatPanel) - : state.chat.publicChatPanel, - dmPanel: (state.chat.pendingPanel instanceof DMPanel) - ? (state.chat.pendingPanel as DMPanel) - : state.chat.dmPanel, - // update currentChat from panel title if available - currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat, - pendingPanel: null - } - })); - }, - - switchToPublicChat: async (chatName: string) => { - const { user, chat } = get(); - - if (!user.authToken) return; - - // Start chat switching animation - chat.setIsSwitching(true); - - // Create or get public chat panel - let publicChatPanel = chat.publicChatPanel; - if (!publicChatPanel) { - publicChatPanel = new PublicChatPanel(chatName, user); - } else { - publicChatPanel.setChatName(chatName); - publicChatPanel.setAuthToken(user.authToken); - // Reset messages for the new chat - publicChatPanel.clearMessages(); - } - - // Activate panel - await publicChatPanel.activate(); - - // Defer panel swap until animation switch-out completes - set((state) => ({ - chat: { - ...state.chat, - pendingPanel: publicChatPanel, - activeTab: "chats" - } - })); - - // Let MessagePanelRenderer handle the animation timing completely - // It will set isChatSwitching to false when the fadeInDown animation completes - }, - - switchToDM: async (dmData: DMPanelData) => { - const { user, chat } = get(); - - if (!user.authToken) return; - - // Start chat switching animation - chat.setIsSwitching(true); - - // Create or get DM panel - let dmPanel = chat.dmPanel; - if (!dmPanel) { - dmPanel = new DMPanel(user); - } else { - dmPanel.setAuthToken(user.authToken); - // Reset messages for the new DM - dmPanel.clearMessages(); - } - - // Set DM data - dmPanel.setDMData(dmData); - - // Activate panel - await dmPanel.activate(); - - // Defer panel swap until animation switch-out completes - set((state) => ({ - chat: { - ...state.chat, - pendingPanel: dmPanel, - activeDm: { - userId: dmData.userId, - username: dmData.username, - publicKey: dmData.publicKey - }, - activeTab: "chats" - } - })); - - // Let MessagePanelRenderer handle the animation timing completely - // It will set isChatSwitching to false when the fadeInDown animation completes - }, - - // Call state management - startCall: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: true, - status: "calling", - startTime: null, - isMuted: false, - remoteUserId: userId, - remoteUsername: username, - isInitiator: true, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - endCall: () => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: false, - status: "ended", - startTime: null, - isMuted: false, - remoteUserId: null, - remoteUsername: null, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - setCallStatus: (status: CallStatus) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - status, - startTime: status === "active" && !state.chat.call.startTime ? Date.now() : state.chat.call.startTime - } - } - })), - - toggleMute: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMuted: !state.chat.call.isMuted - } - } - })), - - toggleCallMinimize: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMinimized: !state.chat.call.isMinimized - } - } - })), - - receiveCall: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: true, - status: "calling", - startTime: null, - isMuted: false, - remoteUserId: userId, - remoteUsername: username, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - sessionKeyHash, - encryptionEmojis - } - } - })), - - setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - sessionKeyHash - } - } - })), - - toggleVideo: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isVideoEnabled: !state.chat.call.isVideoEnabled - } - } - })), - - toggleScreenShare: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isSharingScreen: !state.chat.call.isSharingScreen - } - } - })), - - setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isRemoteVideoEnabled: enabled - } - } - })), - - setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isRemoteScreenSharing: enabled - } - } - })), - toggleCallMinimized: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMinimized: !state.chat.call.isMinimized - } - } - })), - - // Profile dialog state management - setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({ - chat: { - ...state.chat, - profileDialog: data - } - })), - - closeProfileDialog: () => set((state) => ({ - chat: { - ...state.chat, - profileDialog: null - } - })), - - // Online status and typing state management - updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({ - chat: { - ...state.chat, - onlineStatuses: new Map(state.chat.onlineStatuses).set(userId, { online, lastSeen }) - } - })), - - addTypingUser: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - typingUsers: new Map(state.chat.typingUsers).set(userId, username) - } - })), - - removeTypingUser: (userId: number) => set((state) => { - const newTypingUsers = new Map(state.chat.typingUsers); - newTypingUsers.delete(userId); - return { - chat: { - ...state.chat, - typingUsers: newTypingUsers - } - }; - }), - - setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => { - const newDmTypingUsers = new Map(state.chat.dmTypingUsers); - if (isTyping) { - newDmTypingUsers.set(userId, true); - } else { - newDmTypingUsers.delete(userId); - } - return { - chat: { - ...state.chat, - dmTypingUsers: newDmTypingUsers - } - }; - }), - - setSuspended: (reason: string) => set((state) => ({ - user: { - ...state.user, - isSuspended: true, - suspensionReason: reason - } - })) -})); \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 33f8ade..3c489a7 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -4,7 +4,8 @@ import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { CallWindow } from "./right/calls/CallWindow"; import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import styles from "@/pages/chat/css/layout.module.scss"; @@ -12,7 +13,8 @@ export default function ChatPage() { const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const location = useLocation(); const navigate = useNavigate(); - const { user, setProfileDialog } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const processedProfile = useRef(null); // Handle profile links ONLY from navigation state (from SmartCatchAll) @@ -64,7 +66,7 @@ export default function ChatPage() { } handleProfileLink(); - }, [location.state, user.authToken, user.currentUser?.id, setProfileDialog, navigate, location.pathname]); + }, [location.state, user.authToken, user.currentUser?.id, navigate, location.pathname]); if (navigateDownloadApp) return navigateDownloadApp; diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 11546bb..34234d0 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; -import { useAppState } from "@/pages/chat/state"; -import type { ProfileDialogData } from "@/pages/chat/state"; +import { useProfileStore } from "@/state/profile"; +import { useUserStore } from "@/state/user"; +import type { ProfileDialogData } from "@/state/types"; import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { prompt } from "mdui/functions/prompt"; @@ -70,7 +71,8 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol } export function ProfileDialog() { - const { chat, user, closeProfileDialog, setUser } = useAppState(); + const { profileDialog, closeProfileDialog } = useProfileStore(); + const { user, setUser } = useUserStore(); const [isOpen, setIsOpen] = useState(false); const [originalData, setOriginalData] = useState(null); const [currentData, setCurrentData] = useState(null); @@ -80,13 +82,13 @@ export function ProfileDialog() { // Handle dialog open/close based on state useEffect(() => { - if (chat.profileDialog && !isOpen) { + if (profileDialog && !isOpen) { // Fetch fresh data when opening dialog - fetchFreshProfileData(chat.profileDialog); - } else if (!chat.profileDialog && isOpen) { + fetchFreshProfileData(profileDialog); + } else if (!profileDialog && isOpen) { setIsOpen(false); } - }, [chat.profileDialog, isOpen]); + }, [profileDialog, isOpen]); async function fetchFreshProfileData(profileData: ProfileDialogData) { if (!user.authToken) return; diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index 67c476d..d975b0b 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -2,14 +2,16 @@ import { PRODUCT_NAME } from "@/core/config"; import useProfile from "@/pages/chat/hooks/useProfile"; import defaultAvatar from "@/images/default-avatar.png"; import { useState } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; import styles from "@/pages/chat/css/left-panel.module.scss"; import logoIcon from "@/images/logo.svg"; export function ChatHeader({ headerRef }: { headerRef?: React.RefObject }) { const { profileData } = useProfile(); - const { setProfileDialog, user } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); function handleProfileClick() { diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index 3287784..1f0fb9d 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -1,4 +1,4 @@ -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { useRef, useState } from "react"; import { SettingsDialog } from "./settings/SettingsDialog"; import { UsernameSearch } from "./UsernameSearch"; @@ -9,7 +9,7 @@ import styles from "@/pages/chat/css/left-panel.module.scss"; function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject }) { const [settingsOpen, onSettingsOpenChange] = useState(false); - const { logout } = useAppState(); + const { logout } = useUserStore(); return ( <> diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 0994cdb..a58c17e 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { fetchMessages } from "@/core/api/messaging"; import { fetchUserPublicKey } from "@/core/api/dm"; @@ -42,7 +43,8 @@ const PUBLIC_CHAT: PublicChat = { }; export function UnifiedChatsList() { - const { user, switchToPublicChat, switchToDM, chat } = useAppState(); + const { user } = useUserStore(); + const { switchToPublicChat, switchToDM, activeTab } = useChatStore(); const { dmUsers, isLoadingUsers, loadUsers } = useDM(); const [lastMessages, setLastMessages] = useState>({}); @@ -61,11 +63,11 @@ export function UnifiedChatsList() { }, [user.authToken]); useEffect(() => { - if (chat.activeTab === "chats") { + if (activeTab === "chats") { loadUsers(); loadLastMessages(); } - }, [chat.activeTab, loadUsers, loadLastMessages]); + }, [activeTab, loadUsers, loadLastMessages]); const allChats = useMemo(() => { return [ @@ -155,7 +157,7 @@ export function UnifiedChatsList() { async function handleDMClick(dmConversation: DMConversation) { if (!dmConversation.publicKey) { - const authToken = useAppState.getState().user.authToken; + const authToken = useUserStore.getState().user.authToken; if (!authToken) return; const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index b37d0e1..fc7d60b 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; @@ -23,7 +24,8 @@ export interface UsernameSearchProps { } export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) { - const { user, switchToDM, chat } = useAppState(); + const { user } = useUserStore(); + const { switchToDM, activeDm } = useChatStore(); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); @@ -68,7 +70,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use // Subscribe to online status for all search results useEffect(() => { - const activeDmUserId = chat.activeDm?.userId; + const activeDmUserId = activeDm?.userId; const switchingToUserId = switchingToUserIdRef.current; const currentSearchResultIds = new Set(searchResults.map(u => u.id)); const previousSearchResultIds = new Set(previousSearchResultIdsRef.current); @@ -101,12 +103,12 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use // Clear the ref if the user is now the active DM (state has updated) const finalSwitchingToUserId = switchingToUserIdRef.current; - const finalActiveDmUserId = chat.activeDm?.userId; + const finalActiveDmUserId = activeDm?.userId; if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) { switchingToUserIdRef.current = null; } }; - }, [searchResults, chat.activeDm?.userId]); + }, [searchResults, activeDm?.userId]); async function handleUserClick(searchUser: SearchUser) { diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 9061e34..db428f3 100644 --- a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -1,5 +1,5 @@ import { MaterialList, MaterialListItem } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { deleteAccount } from "@/core/api/account"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; @@ -9,7 +9,7 @@ interface AccountPanelProps { } export function AccountPanel({ onClose }: AccountPanelProps) { - const { user, logout } = useAppState(); + const { user, logout } = useUserStore(); const authToken = user?.authToken; async function handleDeleteAccount() { diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index 78a9da6..cc3c52c 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; import { StyledDialog } from "@/core/components/StyledDialog"; import type { DialogProps } from "@/core/types"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { changePassword } from "@/core/api/account"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) { - const { user } = useAppState(); + const { user } = useUserStore(); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index b6ccc4f..3a5e252 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -1,13 +1,13 @@ import { useState, useEffect } from "react"; import { useImmer } from "use-immer"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function DevicesPanel() { - const { user } = useAppState(); + const { user } = useUserStore(); const authToken = user?.authToken ?? null; const [devices, updateDevices] = useImmer([]); const [devicesLoading, setDevicesLoading] = useState(false); diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index ba5c8bf..3b60c2d 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -1,13 +1,13 @@ import { useState, useRef } from "react"; import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; import { unsubscribeFromPush } from "@/core/api/push"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { - const { user } = useAppState(); + const { user } = useUserStore(); const authToken = user?.authToken ?? null; const [pushEnabled, setPushEnabled] = useState(false); const [loading, setLoading] = useState(false); diff --git a/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx b/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx index 09b5635..fe034da 100644 --- a/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx @@ -1,8 +1,8 @@ -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; import defaultAvatar from "@/images/default-avatar.png"; export function ChatMainHeader() { - const { currentChat } = useAppState().chat; + const { currentChat } = useChatStore(); return (
diff --git a/frontend/src/pages/chat/ui/right/ChatMessages.tsx b/frontend/src/pages/chat/ui/right/ChatMessages.tsx index 8447153..b3c9421 100644 --- a/frontend/src/pages/chat/ui/right/ChatMessages.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMessages.tsx @@ -1,5 +1,5 @@ import { Message } from "./Message"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import type { Message as MessageType } from "@/core/types"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { useState, type ReactNode } from "react"; @@ -20,7 +20,7 @@ interface ChatMessagesProps { } export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) { - const { user } = useAppState(); + const { user } = useUserStore(); // Context menu state const [contextMenu, setContextMenu] = useState({ diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 137318a..a87837e 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -8,7 +8,8 @@ import { useEffect, useState, useRef, useMemo } from "react"; import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; @@ -25,7 +26,7 @@ interface MessageReactionsProps { } function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) { - const { user } = useAppState(); + const { user } = useUserStore(); const [visibleReactions, setVisibleReactions] = useState([]); const [animatingReactions, setAnimatingReactions] = useState>(new Set()); const [isVisible, setIsVisible] = useState(false); @@ -162,7 +163,8 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD endRect: Rect; } | null>(null); const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); - const { user, setProfileDialog } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const imageRefs = useRef>(new Map()); const dmEnvelope = message.runtimeData?.dmEnvelope; diff --git a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx index 8323483..099cafc 100644 --- a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx +++ b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import type { Message, Size2D } from "@/core/types"; import { EmojiMenu } from "./EmojiMenu"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import styles from "@/pages/chat/css/MessageContextMenu.module.scss"; interface MessageContextMenuProps { @@ -35,7 +35,7 @@ export function MessageContextMenu({ isOpen, onOpenChange }: MessageContextMenuProps) { - const { user } = useAppState(); + const { user } = useUserStore(); // Internal state for closing animation const [isClosing, setIsClosing] = useState(false); const [reactionBarPosition, setReactionBarPosition] = useState({ x: 0, y: 0 }); diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index 7d00e6b..aeb2abc 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { motion, AnimatePresence } from "motion/react"; -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; +import { useUserStore } from "@/state/user"; +import { usePresenceStore } from "@/state/presence"; +import { useProfileStore } from "@/state/profile"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; @@ -23,19 +26,20 @@ interface MessagePanelRendererProps { } function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { - const { chat, user } = useAppState(); + const { typingUsers, dmTypingUsers } = usePresenceStore(); + const { user } = useUserStore(); const otherTypingUsers = useMemo(() => { return Array - .from(chat.typingUsers.entries()) + .from(typingUsers.entries()) .filter(([userId, username]) => userId !== user.currentUser?.id && username) .map(([, username]) => username!); - }, [chat.typingUsers, user.currentUser?.id]); + }, [typingUsers, user.currentUser?.id]); let content: ReactNode; if (panel instanceof DMPanel) { const recipientId = panel.getRecipientId()!; - const isTyping = chat.dmTypingUsers.get(recipientId); + const isTyping = dmTypingUsers.get(recipientId); content = isTyping ? : ; } else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) { @@ -48,7 +52,8 @@ function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { } export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { - const { applyPendingPanel, chat, setProfileDialog } = useAppState(); + const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching } = useChatStore(); + const { setProfileDialog } = useProfileStore(); const messagePanelRef = useRef(null); const [panelState, setPanelState] = useState(null); const messagesEndRef = useRef(null); @@ -121,30 +126,30 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Handle chat switching animation useEffect(() => { - if (chat.isSwitching && chat.pendingPanel) { + if (isSwitching && pendingPanel) { // Apply pending panel when animation starts applyPendingPanel(); // End switching state after a brief delay to allow animation setTimeout(() => { - chat.setIsSwitching(false); + setIsSwitching(false); }, 200); } - }, [chat.isSwitching, chat.pendingPanel, applyPendingPanel]); + }, [isSwitching, pendingPanel, applyPendingPanel, setIsSwitching]); // Load messages when panel changes and animation is not running useEffect(() => { - if (!chat.activePanel || chat.isSwitching) return; + if (!activePanel || isSwitching) return; - const panelState = chat.activePanel.getState(); + const panelState = activePanel.getState(); if (panelState.messages.length === 0 && !panelState.isLoading) { - chat.activePanel.loadMessages(); + activePanel.loadMessages(); } - }, [chat.activePanel, chat.isSwitching]); + }, [activePanel, isSwitching]); // Scroll to bottom only when new messages are added useEffect(() => { - if (!panelState || chat.isSwitching) return; + if (!panelState || isSwitching) return; const currentMessageCount = panelState.messages.length; const previousMessageCount = previousMessageCountRef.current; @@ -168,7 +173,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Update the previous message count previousMessageCountRef.current = currentMessageCount; - }, [panelState?.messages, panelState?.isLoading, chat.isSwitching]); + }, [panelState?.messages, panelState?.isLoading, isSwitching]); function handleCallClick() { if (panel && panelState && panel.isDm()) { @@ -195,7 +200,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } } - const panelKey = chat.activePanel?.getState().title || "empty"; + const panelKey = activePanel?.getState().title || "empty"; return (
diff --git a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx index 49d05f8..cc5da49 100644 --- a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx +++ b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx @@ -5,7 +5,7 @@ * @version 1.0.0 */ -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; import styles from "@/pages/chat/css/TypingIndicators.module.scss"; interface OnlineIndicatorProps { @@ -14,8 +14,8 @@ interface OnlineIndicatorProps { } export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) { - const { chat } = useAppState(); - const status = chat.onlineStatuses.get(userId); + const { onlineStatuses } = usePresenceStore(); + const status = onlineStatuses.get(userId); // Only show indicator when user is online if (!status || !status.online) { diff --git a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx index 4b8c028..6aa6615 100644 --- a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx +++ b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx @@ -5,7 +5,8 @@ * @version 1.0.0 */ -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; +import { useUserStore } from "@/state/user"; import styles from "@/pages/chat/css/TypingIndicators.module.scss"; interface OnlineStatusProps { @@ -14,8 +15,9 @@ interface OnlineStatusProps { } export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) { - const { chat, user } = useAppState(); - const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId); + const { onlineStatuses } = usePresenceStore(); + const { user } = useUserStore(); + const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : onlineStatuses.get(userId); function formatLastSeen(lastSeen: string): string { const date = new Date(lastSeen); diff --git a/frontend/src/pages/chat/ui/right/RightPanel.tsx b/frontend/src/pages/chat/ui/right/RightPanel.tsx index caf5f2b..aeaf173 100644 --- a/frontend/src/pages/chat/ui/right/RightPanel.tsx +++ b/frontend/src/pages/chat/ui/right/RightPanel.tsx @@ -1,8 +1,8 @@ -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; import { MessagePanelRenderer } from "./MessagePanelRenderer"; export function RightPanel() { - const { chat } = useAppState(); + const { activePanel } = useChatStore(); - return + return } \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx index 001bf3c..7bd404f 100644 --- a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx +++ b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; +import { useUserStore } from "@/state/user"; import useCall from "@/pages/chat/hooks/useCall"; import defaultAvatar from "@/images/default-avatar.png"; import { createPortal } from "react-dom"; @@ -9,8 +10,8 @@ import { motion, AnimatePresence } from "motion/react"; import styles from "@/pages/chat/css/callWindow.module.scss"; export function CallWindow() { - const { chat, toggleCallMinimize, user } = useAppState(); - const { call } = chat; + const { call, toggleCallMinimized } = useCallStore(); + const { user } = useUserStore(); const { acceptCall, rejectCall, @@ -158,7 +159,7 @@ export function CallWindow() {
diff --git a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx index 16124bf..376a34d 100644 --- a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx +++ b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx @@ -1,11 +1,10 @@ -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; import useCall from "@/pages/chat/hooks/useCall"; import defaultAvatar from "@/images/default-avatar.png"; import { MaterialIconButton } from "@/utils/material"; export function MinimizedCallBar() { - const { chat, toggleCallMinimize } = useAppState(); - const { call } = chat; + const { call, toggleCallMinimized } = useCallStore(); const { endCall, toggleMute } = useCall(); function getGradientClass() { @@ -39,7 +38,7 @@ export function MinimizedCallBar() { } return ( -
+
Avatar
diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index dc841f4..1591e5b 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -9,7 +9,7 @@ import { } from "@/core/api/dm"; import { fetchUserProfileById } from "@/core/api/account/profile"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { onlineStatusManager } from "@/core/onlineStatusManager"; import { typingManager } from "@/core/typingManager"; diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index 805ed8b..4b38f0d 100644 --- a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts @@ -1,5 +1,5 @@ import type { Message, WebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; export interface MessagePanelState { id: string; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index cc89906..3e9e216 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -1,7 +1,7 @@ import { MessagePanel } from "./MessagePanel"; import { request } from "@/core/websocket"; import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; export class PublicChatPanel extends MessagePanel { diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index 43ca68e..3aafdb6 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import styles from "./home.module.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { MaterialButton, MaterialIcon } from "@/utils/material"; @@ -18,7 +18,7 @@ function SupportLink({ children }: { children: React.ReactNode }) { export default function HomePage() { const navigate = useNavigate(); - const { user } = useAppState(); + const { user } = useUserStore(); const { isMobile } = useDownloadAppScreen(); const isLoggedIn = user.authToken && user.currentUser; diff --git a/frontend/src/state/call.ts b/frontend/src/state/call.ts new file mode 100644 index 0000000..688285d --- /dev/null +++ b/frontend/src/state/call.ts @@ -0,0 +1,123 @@ +import { create } from "zustand"; +import type { CallStatus, CallState } from "./types"; + +interface CallStore { + call: CallState; + startCall: (userId: number, username: string) => void; + endCall: () => void; + setCallStatus: (status: CallStatus) => void; + toggleMute: () => void; + toggleCallMinimize: () => void; + receiveCall: (userId: number, username: string) => void; + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; + setCallSessionKeyHash: (sessionKeyHash: string) => void; + toggleVideo: () => void; + toggleScreenShare: () => void; + setRemoteVideoEnabled: (enabled: boolean) => void; + setRemoteScreenSharing: (enabled: boolean) => void; + toggleCallMinimized: () => void; +} + +const initialCallState: CallState = { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false +}; + +export const useCallStore = create((set) => ({ + call: initialCallState, + startCall: (userId: number, username: string) => set({ + call: { + ...initialCallState, + isActive: true, + status: "calling", + remoteUserId: userId, + remoteUsername: username, + isInitiator: true + } + }), + endCall: () => set({ call: initialCallState }), + setCallStatus: (status: CallStatus) => set((state) => ({ + call: { + ...state.call, + status, + startTime: status === "active" && !state.call.startTime ? Date.now() : state.call.startTime + } + })), + toggleMute: () => set((state) => ({ + call: { + ...state.call, + isMuted: !state.call.isMuted + } + })), + toggleCallMinimize: () => set((state) => ({ + call: { + ...state.call, + isMinimized: !state.call.isMinimized + } + })), + receiveCall: (userId: number, username: string) => set({ + call: { + ...initialCallState, + isActive: true, + status: "calling", + remoteUserId: userId, + remoteUsername: username, + isInitiator: false + } + }), + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ + call: { + ...state.call, + sessionKeyHash, + encryptionEmojis + } + })), + setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ + call: { + ...state.call, + sessionKeyHash + } + })), + toggleVideo: () => set((state) => ({ + call: { + ...state.call, + isVideoEnabled: !state.call.isVideoEnabled + } + })), + toggleScreenShare: () => set((state) => ({ + call: { + ...state.call, + isSharingScreen: !state.call.isSharingScreen + } + })), + setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ + call: { + ...state.call, + isRemoteVideoEnabled: enabled + } + })), + setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ + call: { + ...state.call, + isRemoteScreenSharing: enabled + } + })), + toggleCallMinimized: () => set((state) => ({ + call: { + ...state.call, + isMinimized: !state.call.isMinimized + } + })) +})); diff --git a/frontend/src/state/chat.ts b/frontend/src/state/chat.ts new file mode 100644 index 0000000..74e5218 --- /dev/null +++ b/frontend/src/state/chat.ts @@ -0,0 +1,150 @@ +import { create } from "zustand"; +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; +import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; +import type { DMPanelData } from "@/pages/chat/ui/right/panels/DMPanel"; +import type { ChatTabs, ActiveDM } from "./types"; +import { useUserStore } from "./user"; + +interface ChatStore { + messages: Message[]; + currentChat: string; + activeTab: ChatTabs; + dmUsers: User[]; + activeDm: ActiveDM | null; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; + addMessage: (message: Message) => void; + updateMessage: (messageId: number, updatedMessage: Partial) => void; + removeMessage: (messageId: number) => void; + setCurrentChat: (chat: string) => void; + setActiveTab: (tab: ChatTabs) => void; + setDmUsers: (users: User[]) => void; + setActiveDm: (dm: ActiveDM | null) => void; + clearMessages: () => void; + setActivePanel: (panel: MessagePanel | null) => void; + setPendingPanel: (panel: MessagePanel | null) => void; + applyPendingPanel: () => void; + switchToPublicChat: (chatName: string) => Promise; + switchToDM: (dmData: DMPanelData) => Promise; +} + +export const useChatStore = create((set, get) => ({ + messages: [], + currentChat: "Общий чат", + activeTab: "chats", + dmUsers: [], + activeDm: null, + isSwitching: false, + setIsSwitching: (value: boolean) => set({ isSwitching: value }), + activePanel: null, + publicChatPanel: null, + dmPanel: null, + pendingPanel: null, + addMessage: (message: Message) => set((state) => { + const messageExists = state.messages.some(msg => msg.id === message.id); + if (messageExists) { + return state; + } + return { + messages: [...state.messages, message] + }; + }), + updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ + messages: state.messages.map(msg => + msg.id === messageId ? { ...msg, ...updatedMessage } : msg + ) + })), + removeMessage: (messageId: number) => set((state) => ({ + messages: state.messages.filter(msg => msg.id !== messageId) + })), + clearMessages: () => set({ messages: [] }), + setCurrentChat: (chat: string) => set({ currentChat: chat }), + setActiveTab: (tab: ChatTabs) => set({ activeTab: tab }), + setDmUsers: (users: User[]) => set({ dmUsers: users }), + setActiveDm: (dm: ActiveDM | null) => set({ activeDm: dm }), + setActivePanel: (panel: MessagePanel | null) => { + const state = get(); + if (state.activePanel && state.activePanel !== panel) { + state.activePanel.deactivate(); + } + return set({ activePanel: panel }); + }, + setPendingPanel: (panel: MessagePanel | null) => set({ pendingPanel: panel }), + applyPendingPanel: () => { + const state = get(); + if (state.activePanel) { + state.activePanel.deactivate(); + } + return set((state) => ({ + activePanel: state.pendingPanel || state.activePanel, + publicChatPanel: (state.pendingPanel instanceof PublicChatPanel) + ? (state.pendingPanel as PublicChatPanel) + : state.publicChatPanel, + dmPanel: (state.pendingPanel instanceof DMPanel) + ? (state.pendingPanel as DMPanel) + : state.dmPanel, + currentChat: state.pendingPanel ? state.pendingPanel.getState().title || state.currentChat : state.currentChat, + pendingPanel: null + })); + }, + switchToPublicChat: async (chatName: string) => { + const { user } = useUserStore.getState(); + const state = get(); + + if (!user.authToken) return; + + state.setIsSwitching(true); + + let publicChatPanel = state.publicChatPanel; + if (!publicChatPanel) { + publicChatPanel = new PublicChatPanel(chatName, user); + } else { + publicChatPanel.setChatName(chatName); + publicChatPanel.setAuthToken(user.authToken); + publicChatPanel.clearMessages(); + } + + await publicChatPanel.activate(); + + set({ + pendingPanel: publicChatPanel, + activeTab: "chats" + }); + }, + switchToDM: async (dmData: DMPanelData) => { + const { user } = useUserStore.getState(); + const state = get(); + + if (!user.authToken) return; + + state.setIsSwitching(true); + + let dmPanel = state.dmPanel; + if (!dmPanel) { + dmPanel = new DMPanel(user); + } else { + dmPanel.setAuthToken(user.authToken); + dmPanel.clearMessages(); + } + + dmPanel.setDMData(dmData); + + await dmPanel.activate(); + + set({ + pendingPanel: dmPanel, + activeDm: { + userId: dmData.userId, + username: dmData.username, + publicKey: dmData.publicKey + }, + activeTab: "chats" + }); + } +})); diff --git a/frontend/src/state/presence.ts b/frontend/src/state/presence.ts new file mode 100644 index 0000000..9220f00 --- /dev/null +++ b/frontend/src/state/presence.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +interface PresenceStore { + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void; + addTypingUser: (userId: number, username: string) => void; + removeTypingUser: (userId: number) => void; + setDmTypingUser: (userId: number, isTyping: boolean) => void; +} + +export const usePresenceStore = create((set) => ({ + onlineStatuses: new Map(), + typingUsers: new Map(), + dmTypingUsers: new Map(), + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({ + onlineStatuses: new Map(state.onlineStatuses).set(userId, { online, lastSeen }) + })), + addTypingUser: (userId: number, username: string) => set((state) => ({ + typingUsers: new Map(state.typingUsers).set(userId, username) + })), + removeTypingUser: (userId: number) => set((state) => { + const newTypingUsers = new Map(state.typingUsers); + newTypingUsers.delete(userId); + return { + typingUsers: newTypingUsers + }; + }), + setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => { + const newDmTypingUsers = new Map(state.dmTypingUsers); + if (isTyping) { + newDmTypingUsers.set(userId, true); + } else { + newDmTypingUsers.delete(userId); + } + return { + dmTypingUsers: newDmTypingUsers + }; + }) +})); diff --git a/frontend/src/state/profile.ts b/frontend/src/state/profile.ts new file mode 100644 index 0000000..35a6314 --- /dev/null +++ b/frontend/src/state/profile.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; +import type { ProfileDialogData } from "./types"; + +interface ProfileStore { + profileDialog: ProfileDialogData | null; + setProfileDialog: (data: ProfileDialogData | null) => void; + closeProfileDialog: () => void; +} + +export const useProfileStore = create((set) => ({ + profileDialog: null, + setProfileDialog: (data: ProfileDialogData | null) => set({ profileDialog: data }), + closeProfileDialog: () => set({ profileDialog: null }) +})); diff --git a/frontend/src/state/types.ts b/frontend/src/state/types.ts new file mode 100644 index 0000000..eb251f2 --- /dev/null +++ b/frontend/src/state/types.ts @@ -0,0 +1,73 @@ +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; +import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; + +export type ChatTabs = "chats" | "channels" | "contacts"; + +export type CallStatus = "calling" | "connecting" | "active" | "ended"; + +export interface ProfileDialogData { + userId?: number; + username?: string; + display_name?: string; + profilePicture?: string; + bio?: string; + memberSince?: string; + online?: boolean; + isOwnProfile: boolean; + verified?: boolean; + suspended?: boolean; + suspension_reason?: string | null; + deleted?: boolean; +} + +export interface ActiveDM { + userId: number; + username: string; + publicKey: string | null; +} + +export interface CallState { + isActive: boolean; + status: CallStatus; + startTime: number | null; + isMuted: boolean; + remoteUserId: number | null; + remoteUsername: string | null; + isInitiator: boolean; + isMinimized: boolean; + sessionKeyHash: string | null; + encryptionEmojis: string[]; + isVideoEnabled: boolean; + isRemoteVideoEnabled: boolean; + isSharingScreen: boolean; + isRemoteScreenSharing: boolean; +} + +export interface ChatState { + messages: Message[]; + currentChat: string; + activeTab: ChatTabs; + dmUsers: User[]; + activeDm: ActiveDM | null; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; + call: CallState; + profileDialog: ProfileDialogData | null; + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; +} + +export interface UserState { + currentUser: User | null; + authToken: string | null; + isSuspended: boolean; + suspensionReason: string | null; +} + diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts new file mode 100644 index 0000000..bffd7ca --- /dev/null +++ b/frontend/src/state/user.ts @@ -0,0 +1,159 @@ +import { create } from "zustand"; +import type { User } from "@/core/types"; +import { request } from "@/core/websocket"; +import { restoreKeys } from "@/core/api/account"; +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "@/core/api/account"; +import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; +import { isElectron } from "@/core/electron/electron"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { typingManager } from "@/core/typingManager"; +import type { UserState } from "./types"; + +interface UserStore { + user: UserState; + setUser: (token: string, user: User) => void; + logout: () => void; + restoreFromStorage: () => Promise; + setSuspended: (reason: string) => void; +} + +export const useUserStore = create((set) => ({ + user: { + currentUser: null, + authToken: null, + isSuspended: false, + suspensionReason: null + }, + setUser: (token: string, user: User) => { + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: user.suspended || false, + suspensionReason: user.suspension_reason || null + } + }); + + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + + try { + localStorage.setItem('authToken', token); + localStorage.setItem('currentUser', JSON.stringify(user)); + } catch (error) { + console.error('Failed to store credentials in localStorage:', error); + } + + try { + request({ + type: "ping", + credentials: { + scheme: "Bearer", + credentials: token + }, + data: {} + }) + } catch {} + }, + logout: () => { + try { + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + } catch (error) { + console.error('Failed to clear localStorage:', error); + } + + onlineStatusManager.setAuthToken(null); + typingManager.setAuthToken(null); + onlineStatusManager.cleanup(); + typingManager.cleanup(); + + set({ + user: { + currentUser: null, + authToken: null, + isSuspended: false, + suspensionReason: null + } + }); + }, + restoreFromStorage: async () => { + try { + const token = localStorage.getItem('authToken'); + + if (token) { + const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) + }); + if (fullResponse.ok) { + const user: User = await fullResponse.json(); + restoreKeys(); + + if (user.suspended) { + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: true, + suspensionReason: user.suspension_reason || null + } + }); + return; + } + + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: false, + suspensionReason: null + } + }); + + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + + try { + request({ + type: "ping", + credentials: { + scheme: "Bearer", + credentials: token + }, + data: {} + }) + } catch {} + + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(token); + + if (isElectron) { + await startElectronReceiver(); + } + } + } + } catch (e) { + console.error("Notification setup failed (restored):", e); + } + } else { + throw new Error("Unable to authenticate"); + } + } + } catch (error) { + console.error('Failed to restore user from localStorage:', error); + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + } + }, + setSuspended: (reason: string) => set((state) => ({ + user: { + ...state.user, + isSuspended: true, + suspensionReason: reason + } + })) +}));