mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 19:45:05 +03:00
Merge branch 'refactor'
This commit is contained in:
@@ -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<typeof optimize>[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 && (
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -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<DeviceInfo[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Uint8Array | null> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<CheckAuthResponse> {
|
||||
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<LoginResponse> {
|
||||
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<LoginResponse> {
|
||||
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<LogoutResponse> {
|
||||
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<string> {
|
||||
// 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<UserKeyPairMemory> {
|
||||
// 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<string> {
|
||||
// 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 changePassword(
|
||||
token: string,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
logoutAllExceptCurrent: boolean
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<ProfileData | null> {
|
||||
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<UploadResponse | null> {
|
||||
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<ProfileData>): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<UserProfile | null> {
|
||||
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<UserProfile | null> {
|
||||
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<number, {isSimilar: boolean, similarTo?: string} | null>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Uint8Array | null> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<DmEnvelope[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DMConversationResponse[]> {
|
||||
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 || [];
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<User[]> {
|
||||
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<string | null> {
|
||||
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<DmEnvelope[]> {
|
||||
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<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
@@ -185,13 +176,3 @@ export async function fetchDMConversations(token: string): Promise<DMConversatio
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
||||
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 || [];
|
||||
}
|
||||
|
||||
@@ -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<Blob> {
|
||||
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<Blob> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<Message[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<BlocklistResponse> {
|
||||
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<BlocklistUpdateResponse> {
|
||||
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<BlocklistUpdateResponse> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PushSubscriptionResponse> {
|
||||
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<PushSubscriptionResponse> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<User[]> {
|
||||
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<User[]> {
|
||||
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 || [];
|
||||
}
|
||||
|
||||
@@ -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<IceServersResponse> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<RTCIceServer[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { checkUserSimilarity } from "@/core/api/profileApi";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { checkUserSimilarity } from "@/core/api/account/profile";
|
||||
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}`;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { verifyUser } from "@/core/api/profileApi";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { verifyUser } from "@/core/api/account/profile";
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAppState } from "./chat/state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { useUserStore } from "@/state/user";
|
||||
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();
|
||||
const { user } = useUserStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user.authToken) {
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
}, [user.authToken, user.currentUser, navigate]);
|
||||
|
||||
return <>{children}</>;
|
||||
return !user.authToken ? <Navigate to="/login" /> : children;
|
||||
}
|
||||
|
||||
@@ -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 { useAppState } from "@/pages/chat/state";
|
||||
import type { LoginRequest } from "@/core/types";
|
||||
import { useUserStore } from "@/state/user";
|
||||
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";
|
||||
@@ -54,7 +53,7 @@ interface LoginFormProps {
|
||||
export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
@@ -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");
|
||||
const setSuspended = useAppState.getState().setSuspended;
|
||||
setSuspended(suspensionReason || "No reason provided");
|
||||
} catch (error: any) {
|
||||
if (error.message && error.message.includes("suspension")) {
|
||||
const setSuspended = useUserStore.getState().setSuspended;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 { useAppState } from "@/pages/chat/state";
|
||||
import type { RegisterRequest } from "@/core/types";
|
||||
import { useUserStore } from "@/state/user";
|
||||
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";
|
||||
@@ -52,7 +51,7 @@ interface RegisterFormProps {
|
||||
export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useAppState(state => state.setUser);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<HTMLVideoElement>();
|
||||
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
@@ -7,7 +8,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";
|
||||
|
||||
@@ -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<DMUser[]>([]);
|
||||
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) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi";
|
||||
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<ProfileData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
@@ -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 { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { restoreKeys } from "@/core/api/authApi";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
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<number, {online: boolean, lastSeen: string}>;
|
||||
typingUsers: Map<number, string>; // userId -> username
|
||||
dmTypingUsers: Map<number, boolean>;
|
||||
}
|
||||
|
||||
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<Message>) => 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<void>;
|
||||
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||
|
||||
// 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<void>;
|
||||
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<AppState>((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<Message>) => 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) {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user: User = await response.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
|
||||
}
|
||||
}))
|
||||
}));
|
||||
@@ -4,15 +4,17 @@ 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 { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
|
||||
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";
|
||||
|
||||
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<string | null>(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;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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";
|
||||
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";
|
||||
@@ -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<ProfileDialogData | null>(null);
|
||||
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(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;
|
||||
@@ -349,37 +351,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 +383,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
|
||||
|
||||
@@ -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<HTMLElement | null> }) {
|
||||
const { profileData } = useProfile();
|
||||
const { setProfileDialog, user } = useAppState();
|
||||
const { user } = useUserStore();
|
||||
const { setProfileDialog } = useProfileStore();
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
|
||||
|
||||
function handleProfileClick() {
|
||||
|
||||
@@ -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<MDUIBottomAppBar | null> }) {
|
||||
const [settingsOpen, onSettingsOpenChange] = useState(false);
|
||||
const { logout } = useAppState();
|
||||
const { logout } = useUserStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { 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";
|
||||
@@ -43,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<Record<string, Message | undefined>>({});
|
||||
|
||||
@@ -51,16 +52,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);
|
||||
@@ -68,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<ChatItem[]>(() => {
|
||||
return [
|
||||
@@ -162,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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
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";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
@@ -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<SearchUser[]>([]);
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MaterialList, MaterialListItem } from "@/utils/material";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { deleteAccount } from "@/core/api/securityApi";
|
||||
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() {
|
||||
|
||||
@@ -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 { changePassword } from "@/core/api/securityApi";
|
||||
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("");
|
||||
|
||||
@@ -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 { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
|
||||
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<DeviceInfo[]>([]);
|
||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||
|
||||
@@ -1,14 +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 { 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() {
|
||||
const { user } = useAppState();
|
||||
const { user } = useUserStore();
|
||||
const authToken = user?.authToken ?? null;
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div className="chat-header">
|
||||
|
||||
@@ -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<ContextMenuState>({
|
||||
|
||||
@@ -5,12 +5,12 @@ 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 { 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";
|
||||
import { useImmer } from "use-immer";
|
||||
@@ -26,7 +26,7 @@ interface MessageReactionsProps {
|
||||
}
|
||||
|
||||
function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
|
||||
const { user } = useAppState();
|
||||
const { user } = useUserStore();
|
||||
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
||||
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -163,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<Map<string, HTMLImageElement>>(new Map());
|
||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||
|
||||
|
||||
@@ -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<Size2D>({ x: 0, y: 0 });
|
||||
|
||||
@@ -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 ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
|
||||
} 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<HTMLDivElement>(null);
|
||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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 (
|
||||
<div className={styles.chatContainer}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <MessagePanelRenderer panel={chat.activePanel} />
|
||||
return <MessagePanelRenderer panel={activePanel} />
|
||||
}
|
||||
@@ -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() {
|
||||
<div className={styles.callHeader}>
|
||||
<div className={styles.windowControls}>
|
||||
<MaterialIconButton
|
||||
onClick={toggleCallMinimize}
|
||||
onClick={toggleCallMinimized}
|
||||
icon={call.isMinimized ? "open_in_full" : "close_fullscreen"}
|
||||
className={styles.windowControlBtn}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimize}>
|
||||
<div className={`minimized-call-bar ${getGradientClass()}`} onClick={toggleCallMinimized}>
|
||||
<div className="call-info">
|
||||
<img src={defaultAvatar} alt="Avatar" className="avatar" />
|
||||
<div className="user-details">
|
||||
|
||||
@@ -6,10 +6,10 @@ 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 type { UserState, ProfileDialogData } from "@/state/types";
|
||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||
import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types";
|
||||
import type { UserState, ProfileDialogData } from "@/state/types";
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<CallStore>((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
|
||||
}
|
||||
}))
|
||||
}));
|
||||
@@ -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<Message>) => 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<void>;
|
||||
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((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<Message>) => 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"
|
||||
});
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface PresenceStore {
|
||||
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
|
||||
typingUsers: Map<number, string>; // userId -> username
|
||||
dmTypingUsers: Map<number, boolean>;
|
||||
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<PresenceStore>((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
|
||||
};
|
||||
})
|
||||
}));
|
||||
@@ -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<ProfileStore>((set) => ({
|
||||
profileDialog: null,
|
||||
setProfileDialog: (data: ProfileDialogData | null) => set({ profileDialog: data }),
|
||||
closeProfileDialog: () => set({ profileDialog: null })
|
||||
}));
|
||||
@@ -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<number, {online: boolean, lastSeen: string}>;
|
||||
typingUsers: Map<number, string>; // userId -> username
|
||||
dmTypingUsers: Map<number, boolean>;
|
||||
}
|
||||
|
||||
export interface UserState {
|
||||
currentUser: User | null;
|
||||
authToken: string | null;
|
||||
isSuspended: boolean;
|
||||
suspensionReason: string | null;
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
setSuspended: (reason: string) => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserStore>((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
|
||||
}
|
||||
}))
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user