Merge branch 'feature/react'

This commit is contained in:
2025-09-06 12:39:38 +03:00
Unverified
81 changed files with 4397 additions and 2825 deletions
+9 -2
View File
@@ -9,5 +9,12 @@ When working with this project, follow these rules:
- Do NOT "test the implementation" when you are done. The only exception is when you
need to typecheck or build the app, in that case:
- To typecheck, run "npm run frontend:typecheck".
- To build, run "npm run frontend:build".
- To typecheck, run `npm run frontend:typecheck`.
- To build, run `npm run frontend:build`.
Do NOT execute other commands like "cd".
- Do NOT "cd" to the project directory.
- If possible, try to update files in a single edit.
- When you need a delay, use `await delay(millis);` in an async function. If the current function is not async,
make it async. The import is `<project>/frontend/src/utils/utils`.
- When you complete your task, remove unused imports if there are any.
+61 -11
View File
@@ -2,9 +2,9 @@
"version": "2.0.0",
"tasks": [
{
"label": "Run",
"type": "shell",
"command": "npm run dev",
"label": "Backend",
"type": "npm",
"script": "backend:run",
"options": {
"cwd": "${workspaceFolder}"
},
@@ -16,23 +16,73 @@
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "Frontend (Web)",
"type": "npm",
"script": "frontend:dev",
"options": {
"cwd": "${workspaceFolder}"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"group": {
"kind": "build",
},
"isBackground": true
},
{
"label": "Frontend (Electron)",
"type": "npm",
"script": "frontend:electron:dev",
"options": {
"cwd": "${workspaceFolder}"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "Web",
"dependsOn": ["Backend", "Frontend (Web)"],
"dependsOrder": "parallel",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
}
},
{
"label": "Run (Electron)",
"type": "shell",
"command": "npm run dev:electron",
"options": {
"cwd": "${workspaceFolder}"
"label": "Electron",
"dependsOn": ["Backend", "Frontend (Electron)"],
"dependsOrder": "parallel",
"group": {
"kind": "build"
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared"
},
"group": {
"kind": "build"
}
}
]
+2
View File
@@ -17,6 +17,8 @@ def convert_user(user: User) -> dict:
"last_seen": user.last_seen.isoformat(),
"online": user.online,
"username": user.username,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": user.username == OWNER_USERNAME
}
+7 -3
View File
@@ -304,7 +304,8 @@ class MessaggingSocketManager:
db.add(env)
db.commit()
db.refresh(env)
await self.send_to_user(env.recipient_id, {
payload = {
"type": "dmNew",
"data": {
"id": env.id,
@@ -317,8 +318,11 @@ class MessaggingSocketManager:
"wrappedMk": env.wrapped_mk_b64,
"timestamp": env.timestamp.isoformat(),
}
})
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}})
}
await self.send_to_user(env.recipient_id, payload);
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
await self.send_to_user(env.sender_id, payload);
except HTTPException as e:
await self.send_error(websocket, type, e)
elif type == "editMessage":
+56
View File
@@ -8,9 +8,15 @@ import io
from dependencies import get_db, get_current_user
from models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel
router = APIRouter()
# Request models
class UpdateProfileRequest(BaseModel):
nickname: str | None = None
description: str | None = None
# Create uploads directory if it doesn't exist
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
@@ -98,6 +104,56 @@ async def get_user_profile(
"created_at": current_user.created_at
}
@router.put("/user/profile")
async def update_user_profile(
request: UpdateProfileRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Update current user's profile information
"""
updated = False
# Update username if provided
if request.nickname is not None:
nickname = request.nickname.strip()
if len(nickname) < 3:
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long")
if len(nickname) > 50:
raise HTTPException(status_code=400, detail="Username must be 50 characters or less")
# Check if username is already taken by another user
existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first()
if existing_user:
raise HTTPException(status_code=400, detail="Username already taken")
current_user.username = nickname
updated = True
# Update bio if provided
if request.description is not None:
bio = request.description.strip()
if len(bio) > 500:
raise HTTPException(status_code=400, detail="Bio must be 500 characters or less")
current_user.bio = bio
updated = True
if updated:
db.commit()
return {
"message": "Profile updated successfully",
"username": current_user.username,
"bio": current_user.bio
}
else:
return {
"message": "No changes made",
"username": current_user.username,
"bio": current_user.bio
}
@router.put("/user/bio")
async def update_user_bio(
+5 -121
View File
@@ -7,124 +7,8 @@
<link rel="icon" href="./src/resources/images/logo.png" />
</head>
<body>
<div id="electron-title-bar">
<div class="macos-padding"></div>
<div id="window-title"></div>
<!-- <div class="window-controls">
<mdui-button-icon icon="remove" id="window-minimize"></mdui-button-icon>
<mdui-button-icon icon="stack--outlined" id="window-restore" class="hidden"></mdui-button-icon>
<mdui-button-icon icon="ad--outlined" id="window-maximize"></mdui-button-icon>
<mdui-button-icon icon="close" id="window-close"></mdui-button-icon>
</div> -->
</div>
<!--
<div id="main-wrapper">
<!-- Login Form -->
<div id="login-form" class="auth-container">
<div class="auth-card fade-in">
<div class="auth-header">
<h2>
<span class="material-symbols filled large">login</span>
Добро пожаловать!
</h2>
<p>Войдите в свой аккаунт</p>
</div>
<div class="auth-body">
<div id="login-alerts"></div>
<form id="login-form-element">
<mdui-text-field
label="Имя пользователя"
id="login-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
required>
</mdui-text-field>
<mdui-text-field
label="Пароль"
id="login-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="current-password"
required>
</mdui-text-field>
<mdui-button type="submit">Войти</mdui-button>
</form>
<div class="text-center">
<p>Ещё нет аккаунта? <a href="#" id="register-link" class="link">Зарегистрируйтесь</a></p>
</div>
</div>
</div>
</div>
<!-- Register Form -->
<div id="register-form" class="auth-container" style="display: none;">
<div class="auth-card fade-in">
<div class="auth-header">
<h2>
<span class="material-symbols filled large">person_add</span>
Регистрация
</h2>
<p>Создайте новый аккаунт</p>
</div>
<div class="auth-body">
<div id="register-alerts"></div>
<form id="register-form-element">
<mdui-text-field
label="Имя пользователя"
id="register-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
maxlength="20"
counter
required>
</mdui-text-field>
<mdui-text-field
label="Пароль"
id="register-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required>
</mdui-text-field>
<mdui-text-field
label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required>
</mdui-text-field>
<mdui-button type="submit">Зарегистрироваться</mdui-button>
</form>
<div class="text-center">
<p>
Уже есть аккаунт?
<a href="#" id="login-link" class="link">Войдите</a>
</p>
</div>
</div>
</div>
</div>
<!-- Chat Interface -->
<div id="chat-interface" style="display: none;">
<div class="all-container">
<div class="chat-list" id="chat-list">
@@ -192,7 +76,6 @@
</div>
<div class="chat-messages" id="chat-messages">
<!-- Messages will be loaded here dynamically -->
</div>
<div class="chat-input-wrapper">
@@ -239,7 +122,6 @@
</div>
</mdui-dialog>
<!-- Profile Picture Cropper Dialog -->
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
<div class="cropper-dialog-content">
<div class="cropper-header">
@@ -425,8 +307,10 @@
</div>
</div>
</div>
</mdui-dialog>
</mdui-dialog> -->
<script src="src/main.ts" type="module"></script>
<div id="root"></div>
<script src="src/main.tsx" type="module"></script>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
import { API_BASE_URL } from "../core/config";
import { getAuthHeaders } from "../auth/api";
import { ecdhSharedSecret, deriveWrappingKey } from "../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/symmetric";
import { randomBytes } from "../utils/crypto/kdf";
import { getCurrentKeys } from "../auth/crypto";
import { request } from "../core/websocket";
import type { FetchDMResponse, SendDMRequest, DmEnvelope, User } from "../core/types";
import { b64, ub64 } from "../utils/utils";
export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string, 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 = 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(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(token, true),
body: JSON.stringify({
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
})
});
}
export async function fetchDm(since: number | undefined, token: string): Promise<DmEnvelope[]> {
const url = new URL(`${API_BASE_URL}/dm/fetch`);
if (since) url.searchParams.set("since", String(since));
const response = await fetch(url, {
headers: getAuthHeaders(token, true)
});
if (response.ok) {
const data: FetchDMResponse = await response.json();
return data.messages ?? [];
} else {
return [];
}
}
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 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)
});
if (!response.ok) return [];
const data = await response.json();
return data.messages || [];
}
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string): 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)
};
await request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: payload
});
}
+128
View File
@@ -0,0 +1,128 @@
import { getAuthHeaders } from "../auth/api";
import { API_BASE_URL } from "../core/config";
import type { UserProfile } from "../core/types";
export interface ProfileData {
profile_picture?: string;
nickname?: 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)
});
if (response.ok) {
const data = await response.json();
// Map backend fields to frontend fields
return {
profile_picture: data.profile_picture,
nickname: data.username,
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 = {
nickname: data.nickname,
description: data.description
};
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'PUT',
headers: {
...getAuthHeaders(token),
'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),
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)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error fetching user profile:', error);
return null;
}
}
+4 -73
View File
@@ -1,88 +1,19 @@
import { API_BASE_URL } from "../core/config";
import { showLogin } from "../navigation";
import type { Headers, User, WebSocketMessage } from "../core/types";
import { clearAlerts } from "./auth";
import { request } from "../websocket";
/**
* Current authenticated user information
* @type {User | null}
*/
export let currentUser: User | null = null;
/**
* JWT authentication token
* @type {string | null}
*/
export let authToken: string | null = null;
/**
* Sets the current user to the values provided.
* @param token The authentication JWT token.
* @param user The current authenticated user.
*/
export function setUser(token: string, user: User) {
authToken = token
currentUser = user
try {
const payload: WebSocketMessage = {
type: "ping",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: {}
}
request(payload).then(() => {
console.log("Ping succeeded")
})
} catch {}
}
import type { Headers } from "../core/types";
/**
* Generates authentication headers for API requests
* @param {boolean} json - Whether to include JSON content type header
* @returns {Headers} Headers object with authentication and content type
*/
export function getAuthHeaders(json: boolean = true): Headers {
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
const headers: Headers = {};
if (json) {
headers["Content-Type"] = "application/json";
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Checks authentication status on page load
*/
export async function checkAuthStatus(): Promise<void> {
// For JWT, we don't have a persistent token on page load
// So we'll just show the login form
showLogin();
}
/**
* Logs out the current user and clears session data
*/
export async function logout(): Promise<void> {
try {
await fetch(`${API_BASE_URL}/logout`, {
method: 'GET',
headers: getAuthHeaders()
});
} catch (error) {
console.error('Logout error:', error);
}
currentUser = null;
authToken = null;
showLogin();
clearAlerts();
}
-169
View File
@@ -1,169 +0,0 @@
/**
* @fileoverview Authentication system implementation
* @description Handles user authentication, registration, and session management
* @author Cursor
* @version 1.0.0
*/
import { initializeProfile } from "../userPanel/profile/profile";
import type { ErrorResponse, LoginResponse, LoginRequest, RegisterRequest } from "../core/types";
import { API_BASE_URL } from "../core/config";
import { showChat, showLogin, showRegister } from "../navigation";
import { setUser } from "./api";
import { ensureKeysOnLogin } from "./crypto";
import { id } from "../utils/utils";
/**
* Clears all alert messages from authentication forms
* @private
*/
export function clearAlerts(): void {
id('login-alerts').innerHTML = '';
id('register-alerts').innerHTML = '';
}
/**
* Shows an alert message in the specified container
* @param {string} containerId - ID of the container to show the alert in
* @param {string} message - Alert message to display
* @param {'success' | 'danger'} type - Type of alert (success or danger)
*/
export function showAlert(containerId: string, message: string, type: "success" | "danger" = 'danger'): void {
const container = id(containerId);
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type}`;
alertDiv.textContent = message;
container.appendChild(alertDiv);
}
/**
* Handles login form submission
* @param {Event} e - Form submission event
*/
async function handleLogin(e: Event): Promise<void> {
e.preventDefault();
const usernameElement = id<HTMLInputElement>('login-username');
const passwordElement = id<HTMLInputElement>('login-password');
const username = usernameElement.value.trim();
const password = passwordElement.value.trim();
if (!username || !password) {
showAlert('login-alerts', 'Пожалуйста, заполните все поля', 'danger');
return;
}
try {
const request: LoginRequest = {
username: username,
password: password
}
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();
// Store the JWT token
setUser(data.token, data.user)
try {
await ensureKeysOnLogin(password);
} catch (e) {
console.error("Key setup failed:", e);
}
showChat();
initializeProfile(); // Initialize profile after login
} else {
const data: ErrorResponse = await response.json();
showAlert('login-alerts', data.message || 'Неверное имя пользователя или пароль', 'danger');
}
} catch (error) {
showAlert('login-alerts', 'Ошибка соединения с сервером', 'danger');
}
}
/**
* Handles registration form submission
* @param {Event} e - Form submission event
* @private
*/
async function handleRegister(e: Event): Promise<void> {
e.preventDefault();
const usernameElement = id<HTMLInputElement>('register-username');
const passwordElement = id<HTMLInputElement>('register-password');
const confirmPasswordElement = id<HTMLInputElement>('register-confirm-password');
const username = usernameElement.value.trim();
const password = passwordElement.value.trim();
const confirmPassword = confirmPasswordElement.value.trim();
if (!username || !password || !confirmPassword) {
showAlert('register-alerts', 'Пожалуйста, заполните все поля', 'danger');
return;
}
if (password !== confirmPassword) {
showAlert('register-alerts', 'Пароли не совпадают', 'danger');
return;
}
if (username.length < 3 || username.length > 20) {
showAlert('register-alerts', 'Имя пользователя должно быть от 3 до 20 символов', 'danger');
return;
}
if (password.length < 5 || password.length > 50) {
showAlert('register-alerts', 'Пароль должен быть от 5 до 50 символов', 'danger');
return;
}
try {
const request: RegisterRequest = {
username: username,
password: password,
confirm_password: confirmPassword
}
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request)
});
if (response.ok) {
// Registration successful
showAlert('register-alerts', 'Регистрация прошла успешно! Теперь вы можете войти.', 'success');
setTimeout(() => {
showLogin();
}, 2000);
} else {
const data: ErrorResponse = await response.json();
showAlert('register-alerts', data.message || 'Ошибка при регистрации', 'danger');
}
} catch (error) {
showAlert('register-alerts', 'Ошибка соединения с сервером', 'danger');
}
}
/**
* Initializes authentication functionality
* @private
*/
function init(): void {
id('login-form-element').addEventListener('submit', handleLogin);
id('register-form-element').addEventListener('submit', handleRegister);
id("login-link").addEventListener("click", showLogin);
id("register-link").addEventListener("click", showRegister);
}
init();
+22 -20
View File
@@ -1,37 +1,40 @@
import { API_BASE_URL } from "../core/config";
import { getAuthHeaders } from "./api";
import { generateX25519KeyPair } from "../crypto/asymmetric";
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../crypto/backup";
import { generateX25519KeyPair } from "../utils/crypto/asymmetric";
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../utils/crypto/backup";
import { b64, ub64 } from "../utils/utils";
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null;
async function fetchPublicKey(): Promise<Uint8Array | null> {
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers: getAuthHeaders(true) });
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);
}
async function uploadPublicKey(publicKey: Uint8Array): Promise<void> {
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: getAuthHeaders(true),
headers,
body: JSON.stringify(payload)
});
}
async function fetchBackupBlob(): Promise<string | null> {
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: getAuthHeaders(true)
headers
});
if (res.ok) {
const response: BackupBlob = await res.json();
@@ -41,12 +44,13 @@ async function fetchBackupBlob(): Promise<string | null> {
}
}
async function uploadBackupBlob(blobJson: string): Promise<void> {
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: getAuthHeaders(true),
headers,
body: JSON.stringify(payload)
});
}
@@ -61,16 +65,16 @@ export function getCurrentKeys(): UserKeyPairMemory | null {
return null;
}
export async function ensureKeysOnLogin(password: string): Promise<UserKeyPairMemory> {
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
// Try to restore from backup
const blobJson = await fetchBackupBlob();
const blobJson = await fetchBackupBlob(token);
if (blobJson) {
const blob = decodeBlob(blobJson);
const bundle = await decryptBackupWithPassword(password, blob);
currentPrivateKey = bundle.privateKey;
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
const serverPub = await fetchPublicKey();
const serverPub = await fetchPublicKey(token);
if (serverPub) {
currentPublicKey = serverPub;
} else {
@@ -78,9 +82,9 @@ export async function ensureKeysOnLogin(password: string): Promise<UserKeyPairMe
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey);
await uploadPublicKey(currentPublicKey, token);
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(newBlob));
await uploadBackupBlob(encodeBlob(newBlob), token);
}
return { publicKey: currentPublicKey!, privateKey: currentPrivateKey! };
}
@@ -89,10 +93,8 @@ export async function ensureKeysOnLogin(password: string): Promise<UserKeyPairMe
const pair = generateX25519KeyPair();
currentPublicKey = pair.publicKey;
currentPrivateKey = pair.privateKey;
await uploadPublicKey(currentPublicKey);
await uploadPublicKey(currentPublicKey, token);
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
await uploadBackupBlob(encodeBlob(encBlob));
await uploadBackupBlob(encodeBlob(encBlob), token);
return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
}
}
-216
View File
@@ -1,216 +0,0 @@
/**
* @fileoverview Chat functionality and message management
* @description Handles message display, loading, sending, and real-time updates
* @author Cursor
* @version 1.0.0
*/
import { API_BASE_URL } from "../core/config";
import { request } from "../websocket";
import type { Message, Messages, WebSocketMessage } from "../core/types";
import { formatTime } from "../utils/utils";
import { show as showContextMenu } from "./contextMenu";
import { show as showUserProfileDialog } from "./profileDialog";
import defaultAvatar from "../resources/images/default-avatar.png";
import { authToken, currentUser, getAuthHeaders } from "../auth/api";
import { ChatPanelController, PublicChatPanel } from "./panel";
/**
* Adds a new message to the chat interface
* @param {Message} message - Message object to display
* @param {boolean} isAuthor - Whether the current user is the message author
*/
export function addMessage(message: Message, isAuthor: boolean): void {
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
const messageDiv = document.createElement('div');
messageDiv.classList.add("message");
if (isAuthor) {
messageDiv.classList.add("sent");
} else {
messageDiv.classList.add("received");
}
messageDiv.dataset.id = `${message.id}`;
const messageInner = document.createElement('div');
messageInner.classList.add('message-inner');
// Add profile picture for received messages
if (!isAuthor) {
const profilePicDiv = document.createElement('div');
profilePicDiv.classList.add('message-profile-pic');
const profileImg = document.createElement('img');
profileImg.src = message.profile_picture || defaultAvatar;
profileImg.alt = message.username;
let errorLock = false;
profileImg.addEventListener("error", () => {
if (!errorLock) {
profileImg.src = defaultAvatar;
errorLock = true;
}
});
// Add click handler to profile picture
profileImg.style.cursor = 'pointer';
profileImg.addEventListener('click', () => {
showUserProfileDialog(message.username);
});
profilePicDiv.appendChild(profileImg);
messageDiv.appendChild(profilePicDiv);
}
if (!isAuthor) {
const usernameDiv = document.createElement('div');
usernameDiv.classList.add('message-username');
usernameDiv.textContent = message.username;
// Add click handler to username
usernameDiv.style.cursor = 'pointer';
usernameDiv.addEventListener('click', () => {
showUserProfileDialog(message.username);
});
messageInner.appendChild(usernameDiv);
}
// Add reply preview if this is a reply
if (message.reply_to) {
const replyDiv = document.createElement('div');
replyDiv.classList.add('message-reply');
replyDiv.innerHTML = `
<div class="reply-content">
<span class="reply-username">${message.reply_to.username}</span>
<span class="reply-text">${message.reply_to.content}</span>
</div>
`;
messageInner.appendChild(replyDiv);
}
const contentDiv = document.createElement('div');
contentDiv.classList.add('message-content');
contentDiv.textContent = message.content;
messageInner.appendChild(contentDiv);
const timeDiv = document.createElement('div');
timeDiv.classList.add('message-time');
let timeText = formatTime(message.timestamp);
if (message.is_edited) {
timeText += ' (edited)';
}
timeDiv.textContent = timeText;
if (isAuthor && message.is_read) {
const checkIcon = document.createElement('span');
checkIcon.classList.add("material-symbols", "outlined");
timeDiv.appendChild(checkIcon);
}
messageInner.appendChild(timeDiv);
messageDiv.appendChild(messageInner);
messagesContainer.appendChild(messageDiv);
// Add right-click context menu
messageDiv.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(message, e.clientX, e.clientY);
});
// Прокрутка к новому сообщению
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
/**
* Sends a message via WebSocket
*/
export async function sendMessage(): Promise<void> {
const input = document.querySelector('.message-input') as HTMLInputElement;
const message = input.value.trim();
if (message) {
const response = await request({
data: {
content: message
},
credentials: {
scheme: "Bearer",
credentials: authToken!
},
type: "sendMessage"
})
console.log(response)
if (!response.error) {
input.value = "";
}
}
}
/**
* Updates an existing message in the chat interface
* @param {Message} message - Updated message object
*/
export function updateMessage(message: Message): void {
const messageElement = document.querySelector(`[data-id="${message.id}"]`) as HTMLElement;
if (!messageElement) return;
const contentDiv = messageElement.querySelector('.message-content') as HTMLElement;
const timeDiv = messageElement.querySelector('.message-time') as HTMLElement;
if (contentDiv) {
contentDiv.textContent = message.content;
}
if (timeDiv) {
let timeText = formatTime(message.timestamp);
if (message.is_edited) {
timeText += ' (edited)';
}
timeDiv.textContent = timeText;
}
}
/**
* Removes a message from the chat interface
* @param {number} messageId - ID of the message to remove
*/
export function removeMessage(messageId: number): void {
const messageElement = document.querySelector(`[data-id="${messageId}"]`) as HTMLElement;
if (messageElement) {
messageElement.remove();
}
}
/**
* Handles WebSocket message updates
* @param {WebSocketMessage} response - WebSocket response
*/
export function handleWebSocketMessage(response: WebSocketMessage): void {
if (ChatPanelController.active == publicChatPanel) {
switch (response.type) {
case 'messageEdited':
if (response.data) {
updateMessage(response.data);
}
break;
case 'messageDeleted':
if (response.data && response.data.message_id) {
removeMessage(response.data.message_id);
}
break;
case 'newMessage':
if (response.data) {
const isAuthor = response.data.username === currentUser?.username;
addMessage(response.data, isAuthor);
}
break;
}
}
}
export const publicChatPanel = new PublicChatPanel();
publicChatPanel.activate();
-321
View File
@@ -1,321 +0,0 @@
/**
* @fileoverview Message context menu functionality
* @description Handles right-click context menu for message actions (edit, delete, reply)
* @author Cursor
* @version 1.0.0
*/
import { request } from "../websocket";
import type { Message } from "../core/types";
import { showSuccess, showError } from "../utils/notification";
import { delay, id } from "../utils/utils";
import type { Dialog } from "mdui/components/dialog";
import type { TextField } from "mdui/components/text-field";
import { currentUser, authToken } from "../auth/api";
let menu = id("message-context-menu")!;
let editDialog = id<Dialog>("edit-message-dialog");
let replyDialog = id<Dialog>("reply-message-dialog");
let currentMessage: Message | null = null;
function init() {
bindEvents();
}
/**
* Binds event listeners
* @private
*/
function bindEvents(): void {
// Context menu events
menu?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const action = target.closest('.context-menu-item')?.getAttribute('data-action');
if (action && currentMessage) {
handleAction(action, currentMessage);
}
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!menu?.contains(e.target as Node)) {
hide();
}
});
// Edit dialog events
const editCancelBtn = editDialog?.querySelector('#edit-cancel');
const editSaveBtn = editDialog?.querySelector('#edit-save');
editCancelBtn?.addEventListener('click', () => hideEditDialog());
editSaveBtn?.addEventListener('click', () => saveEdit());
// Reply dialog events
const replyCancelBtn = replyDialog?.querySelector('#reply-cancel');
const replySendBtn = replyDialog?.querySelector('#reply-send');
replyCancelBtn?.addEventListener('click', () => hideReplyDialog());
replySendBtn?.addEventListener('click', () => sendReply());
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hide();
hideEditDialog();
hideReplyDialog();
}
});
}
/**
* Shows the context menu at the specified position
* @param {Message} message - The message to show menu for
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
*/
export function show(message: Message, x: number, y: number): void {
currentMessage = message;
// Show delete for own messages and for owner on any message
const editItem = menu.querySelector('[data-action="edit"]') as HTMLElement;
const deleteItem = menu.querySelector('[data-action="delete"]') as HTMLElement;
const isAuthor = message.username === currentUser?.username;
const isOwner = currentUser?.admin;
// Check if we're in a DM conversation
const isInDm = document.querySelector('.chat-tabs mdui-tab[value="dms"]')?.getAttribute('active') === 'true';
// Show edit only for own messages
editItem.style.display = isAuthor ? 'flex' : 'none';
// Show delete for own messages, admin on any message, or in DMs for any message
const canDelete = isAuthor || isOwner || isInDm;
deleteItem.style.display = canDelete ? 'flex' : 'none';
// Position the menu properly
menu.style.display = 'block';
let menuWidth = menu.offsetWidth;
let menuHeight = menu.offsetHeight;
let adjustedX = x;
let adjustedY = y;
let vertical = "top";
let horizontal = "right";
// Adjust horizontal position if menu would go off-screen
if (x + menuWidth > window.innerWidth) {
adjustedX = x - menuWidth;
horizontal = "left";
}
// Adjust vertical position if menu would go off-screen
if (y + menuHeight > window.innerHeight) {
adjustedY = y - menuHeight;
vertical = "bottom";
}
// Ensure menu doesn't go off the left or top edges
adjustedX = Math.max(0, adjustedX);
adjustedY = Math.max(0, adjustedY);
menu.style.left = `${adjustedX}px`;
menu.style.top = `${adjustedY}px`;
menu.classList.add(`pos-${vertical}-${horizontal}`, "open");
}
/**
* Hides the context menu
*/
export function hide(): void {
menu.style.display = 'none';
menu.classList.forEach((name) => {
if (name.match(/pos-\w+-\w+/)) {
menu.classList.remove(name);
}
})
currentMessage = null;
}
/**
* Handles context menu actions
* @param {string} action - The action to perform
* @param {Message} message - The message to act on
* @private
*/
function handleAction(action: string, message: Message): void {
hide();
switch (action) {
case 'edit':
showEditDialog(message);
break;
case 'delete':
deleteMessage(message);
break;
case 'reply':
showReplyDialog(message);
break;
}
}
/**
* Shows the edit dialog
* @param {Message} message - The message to edit
* @private
*/
async function showEditDialog(message: Message): Promise<void> {
const textField = editDialog.querySelector('#edit-message-input') as TextField;
textField.value = message.content;
currentMessage = message;
editDialog.open = true;
// Focus the text field
await delay(100);
textField?.focus();
}
/**
* Hides the edit dialog
* @private
*/
function hideEditDialog(): void {
editDialog.open = false;
}
/**
* Saves the edited message
* @private
*/
async function saveEdit(): Promise<void> {
if (!currentMessage) return;
const textField = editDialog.querySelector('#edit-message-input') as TextField;
const newContent = textField?.value?.trim() || '';
if (!newContent) {
showError('Message cannot be empty');
return;
}
const response = await request({
type: "editMessage",
data: {
message_id: currentMessage.id,
content: newContent
},
credentials: {
scheme: "Bearer",
credentials: authToken!
}
});
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message edited successfully');
hideEditDialog();
}
}
/**
* Shows the reply dialog
* @param {Message} message - The message to reply to
* @private
*/
async function showReplyDialog(message: Message): Promise<void> {
const preview = replyDialog.querySelector('#reply-preview') as HTMLElement;
preview.innerHTML = `
<div class="reply-preview-content">
<strong>${message.username}</strong>: ${message.content}
</div>
`;
currentMessage = message;
replyDialog.open = true;
// Focus the text field
await delay(100);
const textField = replyDialog?.querySelector('#reply-message-input') as TextField;
textField?.focus();
}
/**
* Hides the reply dialog
* @private
*/
function hideReplyDialog(): void {
replyDialog.open = false;
}
/**
* Sends the reply message
* @private
*/
async function sendReply(): Promise<void> {
if (!currentMessage) return;
const textField = replyDialog.querySelector('#reply-message-input') as TextField;
const content = textField?.value?.trim() || '';
if (!content) {
showError('Reply cannot be empty');
return;
}
const response = await request({
type: "replyMessage",
data: {
content: content,
reply_to_id: currentMessage.id
},
credentials: {
scheme: "Bearer",
credentials: authToken!
}
});
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Reply sent successfully');
hideReplyDialog();
if (textField) {
textField.value = '';
}
}
}
/**
* Deletes a message
* @param {Message} message - The message to delete
* @private
*/
async function deleteMessage(message: Message): Promise<void> {
if (!confirm('Are you sure you want to delete this message?')) {
return;
}
const response = await request({
type: "deleteMessage",
data: {
message_id: message.id
},
credentials: {
scheme: "Bearer",
credentials: authToken!
}
});
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message deleted successfully');
}
}
init();
-392
View File
@@ -1,392 +0,0 @@
import { API_BASE_URL } from "../core/config";
import { authToken, getAuthHeaders, currentUser } from "../auth/api";
import { DmPanel } from "./panel";
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
import { randomBytes } from "../crypto/kdf";
import { getCurrentKeys } from "../auth/crypto";
import { request, websocket } from "../websocket";
import type { FetchDMResponse, SendDMRequest, WebSocketMessage, User, DmEnvelope } from "../core/types";
import type { Tabs } from "mdui/components/tabs";
import { b64, ub64 } from "../utils/utils";
export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
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);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(true),
body: JSON.stringify({
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
})
});
}
export async function fetchDm(since?: number): Promise<DmEnvelope[]> {
const url = new URL(`${API_BASE_URL}/dm/fetch`);
if (since) url.searchParams.set("since", String(since));
const response = await fetch(url, {
headers: getAuthHeaders(true)
});
if (response.ok) {
const data: FetchDMResponse = await response.json();
return data.messages ?? [];
} else {
return [];
}
}
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);
}
let activeDm: { userId: number; username: string; publicKey: string | null } | null = null;
let usersLoaded = false;
let dmPanel: DmPanel | null = null;
const dmBadgeByUserId: Map<number, HTMLElement> = new Map();
const dmSupportingTextByUserId: Map<number, HTMLElement> = new Map();
function getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
function setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}
async function loadUsers() {
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(true) });
if (!res.ok) return;
const data = await res.json();
const list = document.getElementById("dm-users")!;
list.innerHTML = "";
(data.users || []).forEach((u: User) => {
const item = document.createElement("mdui-list-item");
item.id = `dm-user-${u.id}`;
// Add avatar
const avatar = document.createElement("img");
avatar.src = u.profile_picture || "./src/resources/images/default-avatar.png";
avatar.alt = u.username;
avatar.slot = "icon";
avatar.style.width = "40px";
avatar.style.height = "40px";
avatar.style.borderRadius = "50%";
avatar.style.objectFit = "cover";
// Handle avatar load error
avatar.addEventListener("error", () => {
avatar.src = "./src/resources/images/default-avatar.png";
});
item.appendChild(avatar);
// Set headline (username)
item.setAttribute("headline", u.username);
// Add supporting text container (hidden until loaded)
const lastMessageEl = document.createElement("div");
lastMessageEl.slot = "description";
lastMessageEl.style.fontSize = "12px";
lastMessageEl.style.color = "var(--mdui-color-on-surface-variant)";
lastMessageEl.style.whiteSpace = "pre-line";
lastMessageEl.style.display = "none";
item.appendChild(lastMessageEl);
dmSupportingTextByUserId.set(u.id, lastMessageEl);
// Add unread badge (hidden by default)
const badge = document.createElement("mdui-badge");
badge.setAttribute("slot", "end-icon");
badge.style.display = "none";
item.appendChild(badge);
dmBadgeByUserId.set(u.id, badge);
// Load last message when element becomes visible
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadLastMessage(u.id);
observer.unobserve(entry.target);
}
});
});
observer.observe(item);
item.addEventListener("click", async () => {
activeDm = { userId: u.id, username: u.username, publicKey: null };
if (!dmPanel) {
dmPanel = new DmPanel(
async (text: string) => {
if (activeDm?.publicKey) {
// WebSocket realtime send
const keys = getCurrentKeys();
if (!keys) return;
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(activeDm.publicKey));
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(text));
const wrap = await aesGcmEncrypt(wk, mk);
const payload: SendDMRequest = {
recipientId: activeDm.userId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
}
request({
type: "dmSend",
credentials: {
scheme: "Bearer",
credentials: authToken!
},
data: payload
});
}
},
async () => {
// Load DM history for the active conversation
if (!activeDm?.publicKey || !dmPanel) return;
const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, {
headers: getAuthHeaders(true)
});
if (response.ok) {
const data = await response.json();
const messages: DmEnvelope[] = data.messages || [];
const container = document.getElementById("chat-messages")!;
container.innerHTML = "";
let maxIncomingId = 0;
for (const env of messages) {
try {
// Always use other user's public key for ECDH (our private + their public)
const text = await decryptDm(env, activeDm.publicKey!);
const isAuthor = env.senderId !== activeDm.userId;
const username = isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown");
dmPanel.appendMessageWithId({
id: env.id,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
if (env.senderId === activeDm.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (e) {
console.error("Error while loading message:", e);
}
}
container.scrollTop = container.scrollHeight;
if (maxIncomingId > 0) {
setLastReadId(activeDm.userId, maxIncomingId);
const badgeEl = dmBadgeByUserId.get(activeDm.userId);
if (badgeEl) {
badgeEl.style.display = "none";
badgeEl.textContent = "";
}
}
}
}
);
}
dmPanel.setOtherUser(u.username);
dmPanel.setTitle(u.username);
dmPanel.clearMessages();
// Add profile click functionality to chat header
const chatHeaderAvatar = document.querySelector('.chat-header-avatar') as HTMLElement;
if (chatHeaderAvatar) {
chatHeaderAvatar.style.cursor = 'pointer';
chatHeaderAvatar.onclick = () => dmPanel?.onProfileClicked();
}
const resPk = await fetch(`${API_BASE_URL}/crypto/public-key/of/${u.id}`, { headers: getAuthHeaders(true) });
if (resPk.ok) {
const pkData = await resPk.json();
activeDm!.publicKey = pkData.publicKey;
}
// Only activate after we have the public key so loader can decrypt
dmPanel.activate();
// Clear unread badge on open
const badgeEl = dmBadgeByUserId.get(u.id);
if (badgeEl) {
badgeEl.textContent = "";
badgeEl.style.display = "none";
}
});
list.appendChild(item);
});
}
async function loadLastMessage(userId: number): Promise<void> {
try {
const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(true) });
if (!pkRes.ok) return;
const pkData = await pkRes.json();
const otherPk = pkData.publicKey as string;
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=50`, {
headers: getAuthHeaders(true)
});
if (response.ok) {
const data = await response.json();
const messages: DmEnvelope[] = data.messages || [];
const supporting = dmSupportingTextByUserId.get(userId);
const badgeEl = dmBadgeByUserId.get(userId);
if (!supporting || !badgeEl) return;
let lastPlaintext: string | null = null;
let lastEnv: DmEnvelope | null = null;
for (const env of messages) {
if (!lastEnv || env.id > lastEnv.id) lastEnv = env;
}
if (lastEnv) {
try { lastPlaintext = await decryptDm(lastEnv, otherPk); } catch {}
}
if (lastPlaintext && lastPlaintext.trim().length > 0) {
const lines = lastPlaintext.split(/\r?\n/).slice(0, 2);
supporting.textContent = lines.join("\n");
supporting.style.display = "block";
} else {
supporting.textContent = "";
supporting.style.display = "none";
}
const lastRead = getLastReadId(userId);
let unread = 0;
for (const env of messages) {
if (env.senderId === userId && env.id > lastRead) unread++;
}
if (unread > 0) {
badgeEl.textContent = String(unread);
badgeEl.style.display = "inline-flex";
} else {
badgeEl.textContent = "";
badgeEl.style.display = "none";
}
} else {
const supporting = dmSupportingTextByUserId.get(userId);
if (supporting) {
supporting.textContent = "";
supporting.style.display = "none";
}
}
} catch (error) {
const supporting = dmSupportingTextByUserId.get(userId);
if (supporting) {
supporting.textContent = "";
supporting.style.display = "none";
}
}
}
function init() {
const tabs = document.querySelector(".chat-tabs mdui-tabs") as Tabs;
const dmTab = tabs?.querySelector('mdui-tab[value="dms"]')!;
function ensureUsersLoaded() {
if (!usersLoaded) {
usersLoaded = true;
loadUsers();
}
}
dmTab.addEventListener("click", ensureUsersLoaded);
tabs.addEventListener("change", (e: any) => {
if (e.detail?.value === "dms") {
ensureUsersLoaded();
dmPanel?.activate();
}
});
}
init();
// realtime incoming DMs
websocket.addEventListener("message", async (e) => {
try {
const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data);
if (msg.type === "dmNew") {
if (activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) {
// Always use other user's public key (our private is implied by getCurrentKeys)
const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
if (dmPanel) {
const isAuthor = msg.data.senderId !== activeDm.userId;
dmPanel.appendMessageWithId({
id: msg.data.id,
content: plaintext,
username: isAuthor ? (currentUser?.username || "Unknown") : (activeDm.username || "Unknown"),
timestamp: msg.data.timestamp,
is_read: false,
is_edited: false
});
}
if (msg.data.senderId === activeDm.userId) {
setLastReadId(activeDm.userId, Math.max(getLastReadId(activeDm.userId), msg.data.id));
}
} else {
const otherUserId = msg.data.senderId;
const badgeEl = dmBadgeByUserId.get(otherUserId);
if (badgeEl) {
const current = Number(badgeEl.textContent || 0);
const next = (current || 0) + 1;
badgeEl.textContent = String(next);
badgeEl.style.display = "inline-flex";
}
try {
const pkRes = await fetch(`${API_BASE_URL}/crypto/public-key/of/${otherUserId}`, { headers: getAuthHeaders(true) });
if (pkRes.ok) {
const pkData = await pkRes.json();
const plaintext = await decryptDm(msg.data, pkData.publicKey);
const supporting = dmSupportingTextByUserId.get(otherUserId);
if (supporting && plaintext) {
const lines = plaintext.split(/\r?\n/).slice(0, 2);
supporting.textContent = lines.join("\n");
supporting.style.display = lines.length ? "block" : "none";
}
}
} catch {}
}
}
} catch {}
});
-175
View File
@@ -1,175 +0,0 @@
import { authToken, currentUser, getAuthHeaders } from "../auth/api";
import { API_BASE_URL } from "../core/config";
import type { Message, Messages, WebSocketMessage } from "../core/types";
import { request } from "../websocket";
import { addMessage } from "./chat";
import { show as showContextMenu } from "./contextMenu";
import { show as showProfileDialog } from "./profileDialog";
const titleEl = document.getElementById("chat-name")!;
const messages = document.getElementById("chat-messages")!;
const input = document.getElementById("message-input") as HTMLInputElement;
const form = document.getElementById("message-form") as HTMLFormElement;
export abstract class ChatPanelController {
static active: ChatPanelController | null = null;
static mounted = false;
activate(): void {
ChatPanelController.active = this;
if (currentUser) {
this.loadMessages();
}
}
setTitle(title: string): void {
titleEl.textContent = title;
}
clearMessages(): void {
messages.innerHTML = "";
}
appendSimple(text: string, isAuthor: boolean): void {
const div = document.createElement("div");
div.className = `message ${isAuthor ? "sent" : "received"}`;
const inner = document.createElement("div");
inner.className = "message-inner";
const content = document.createElement("div");
content.className = "message-content";
content.textContent = text;
inner.appendChild(content);
div.appendChild(inner);
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
protected abstract onSubmit(text: string): void | Promise<void>;
protected abstract loadMessages(): void | Promise<void>;
public abstract onProfileClicked(): void;
static mountOnce(): void {
if (this.mounted) return;
this.mounted = true;
if (!form) return;
form.addEventListener(
"submit",
(e) => {
if (!ChatPanelController.active) return; // let others handle
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const text = input.value.trim();
if (!text) return;
Promise.resolve(ChatPanelController.active.onSubmit(text)).finally(() => {
input.value = "";
});
},
true
);
}
}
export class PublicChatPanel extends ChatPanelController {
protected async onSubmit(text: string): Promise<void> {
const payload: WebSocketMessage = {
data: { content: text },
credentials: { scheme: "Bearer", credentials: authToken! },
type: "sendMessage"
};
await request(payload);
}
protected loadMessages(): void {
fetch(`${API_BASE_URL}/get_messages`, {
headers: getAuthHeaders()
})
.then(response => response.json())
.then((data: Messages) => {
if (data.messages && data.messages.length > 0) {
messages.innerHTML = "";
const messagesContainer = document.querySelector('.chat-messages') as HTMLElement;
const lastMessage = messagesContainer.lastElementChild as HTMLElement
let lastMessageId: number = 0
if (lastMessage) {
lastMessageId = Number(lastMessage.dataset.id)
}
// Добавляем только новые сообщения
data.messages.forEach(msg => {
console.log(msg);
if (msg.id > lastMessageId) {
addMessage(msg, msg.username == currentUser!.username);
}
});
}
});
}
public onProfileClicked(): void {
// Public chat doesn't have a specific profile to show
}
}
export class DmPanel extends ChatPanelController {
private sender: (text: string) => Promise<void>;
private loader: () => Promise<void> | void;
private otherUsername: string | null = null;
constructor(sender: (text: string) => Promise<void>, loader: () => Promise<void> | void) {
super();
this.sender = sender;
this.loader = loader;
}
setOtherUser(username: string): void {
this.otherUsername = username;
}
protected async onSubmit(text: string): Promise<void> {
this.appendSimple(text, true);
await this.sender(text);
}
protected loadMessages(): void | Promise<void> {
return this.loader();
}
public onProfileClicked(): void {
if (this.otherUsername) {
// Import and show the profile dialog
showProfileDialog(this.otherUsername!);
}
}
appendMessageWithId(message: Message): void {
const div = document.createElement("div");
const isAuthor = message.username === currentUser?.username;
div.className = `message ${isAuthor ? "sent" : "received"}`;
div.setAttribute("data-message-id", message.id.toString());
div.setAttribute("data-timestamp", message.timestamp);
const inner = document.createElement("div");
inner.className = "message-inner";
const content = document.createElement("div");
content.className = "message-content";
content.textContent = message.content;
inner.appendChild(content);
div.appendChild(inner);
// Add context menu support
div.addEventListener("contextmenu", (e) => {
e.preventDefault();
showContextMenu(message, e.clientX, e.clientY);
});
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
}
ChatPanelController.mountOnce();
-253
View File
@@ -1,253 +0,0 @@
/**
* @fileoverview User profile dialog functionality
* @description Handles displaying user profiles in a modal dialog
* @author Cursor
* @version 1.0.0
*/
import { getAuthHeaders, currentUser } from "../auth/api";
import { API_BASE_URL } from "../core/config";
import type { UserProfile } from "../core/types";
import { showError, showSuccess } from "../utils/notification";
import { delay, formatTime, id } from "../utils/utils";
import defaultAvatar from "../resources/images/default-avatar.png";
import type { Tabs } from "mdui/components/tabs";
import type { Dialog } from "mdui/components/dialog";
import type { TextField } from "mdui/components/text-field";
let dialog = id<Dialog>("user-profile-dialog");
let currentProfile: UserProfile | null = null;
let isOwnProfile: boolean = false;
function init() {
bindEvents();
}
/**
* Binds event listeners
* @private
*/
function bindEvents(): void {
// Edit bio events
const editBioBtn = dialog?.querySelector('#edit-bio-btn');
const saveBioBtn = dialog?.querySelector('#save-bio-btn');
const cancelBioBtn = dialog?.querySelector('#cancel-bio-btn');
editBioBtn?.addEventListener('click', () => startEditBio());
saveBioBtn?.addEventListener('click', () => saveBio());
cancelBioBtn?.addEventListener('click', () => cancelEditBio());
// DM button event
const dmButton = dialog?.querySelector('#dm-button');
dmButton?.addEventListener('click', () => startDm());
}
/**
* Starts a direct message conversation
* @private
*/
async function startDm(): Promise<void> {
if (!currentProfile || isOwnProfile) return;
// Hide the profile dialog
hide();
// Switch to DMs tab
const tabs = document.querySelector('.chat-tabs mdui-tabs') as Tabs;
if (tabs) {
tabs.value = 'dms';
}
// Find and click on the user in the DM users list
await delay(100);
const dmUsersList = document.getElementById("dm-users");
if (dmUsersList) {
const userItems = dmUsersList.querySelectorAll('mdui-list-item');
for (const item of userItems) {
const headline = item.getAttribute('headline');
if (headline === currentProfile?.username) {
(item as HTMLElement).click();
break;
}
}
}
}
/**
* Shows the profile dialog for a specific user
* @param {string} username - Username to show profile for
*/
export async function show(username: string): Promise<void> {
if (!dialog) return;
try {
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load user profile');
}
const profile: UserProfile = await response.json();
currentProfile = profile;
isOwnProfile = profile.username === currentUser?.username;
populateDialog(profile);
dialog.open = true;
} catch (error) {
showError('Failed to load user profile');
console.error('Error loading user profile:', error);
}
}
/**
* Populates the dialog with user data
* @param {UserProfile} profile - User profile data
* @private
*/
function populateDialog(profile: UserProfile): void {
if (!dialog) return;
// Profile picture
const profilePic = dialog.querySelector('.profile-picture') as HTMLImageElement;
profilePic.src = profile.profile_picture || defaultAvatar;
let errorLock = false
profilePic.addEventListener("error", () => {
if (!errorLock) {
profilePic.src = defaultAvatar;
errorLock = true;
}
});
// Username
const usernameEl = dialog.querySelector('.username') as HTMLElement;
usernameEl.textContent = profile.username;
// Online status
const onlineStatus = dialog.querySelector('.online-status') as HTMLElement;
if (profile.online) {
onlineStatus.innerHTML = '<span class="online-indicator"></span> Online';
onlineStatus.classList.add("online-status", "online");
} else {
onlineStatus.innerHTML = `<span class="offline-indicator"></span> Last seen ${formatTime(profile.last_seen)}`;
onlineStatus.classList.add("online-status", "offline");
}
// Bio
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
if (profile.bio) {
bioDisplay.textContent = profile.bio;
} else {
bioDisplay.textContent = isOwnProfile ? 'No bio yet. Click "Edit Bio" to add one!' : 'No bio available.';
}
if (bioEdit) {
bioEdit.value = profile.bio || '';
}
// Stats
const memberSince = dialog.querySelector('.member-since') as HTMLElement;
const lastSeen = dialog.querySelector('.last-seen') as HTMLElement;
memberSince.textContent = formatTime(profile.created_at);
lastSeen.textContent = formatTime(profile.last_seen);
}
/**
* Starts editing the bio
* @private
*/
function startEditBio(): void {
if (!dialog) return;
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
bioDisplay.style.display = 'none';
if (bioEdit) bioEdit.style.display = 'block';
bioActions.style.display = 'flex';
editBioBtn.style.display = 'none';
if (bioEdit) {
bioEdit.focus();
bioEdit.setSelectionRange(bioEdit.value.length, bioEdit.value.length);
}
}
/**
* Saves the bio
* @private
*/
export async function saveBio(): Promise<void> {
if (!dialog || !currentProfile) return;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
const newBio = bioEdit?.value?.trim() || '';
try {
const response = await fetch(`${API_BASE_URL}/user/bio`, {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ bio: newBio })
});
if (!response.ok) {
throw new Error('Failed to update bio');
}
const result = await response.json();
currentProfile.bio = result.bio;
populateDialog(currentProfile);
cancelEditBio();
showSuccess('Bio updated successfully');
} catch (error) {
showError('Failed to update bio');
console.error('Error updating bio:', error);
}
}
/**
* Cancels bio editing
* @private
*/
function cancelEditBio(): void {
if (!dialog) return;
const bioDisplay = dialog.querySelector('.bio-display') as HTMLElement;
const bioEdit = dialog.querySelector('#bio-edit-field') as TextField;
const bioActions = dialog.querySelector('.bio-actions') as HTMLElement;
const editBioBtn = dialog.querySelector('#edit-bio-btn') as HTMLElement;
bioDisplay.style.display = 'block';
if (bioEdit) bioEdit.style.display = 'none';
bioActions.style.display = 'none';
editBioBtn.style.display = isOwnProfile ? 'block' : 'none';
// Reset bio edit to current value
if (bioEdit) {
bioEdit.value = currentProfile?.bio || '';
}
}
/**
* Hides the dialog
*/
export function hide(): void {
dialog.open = false;
currentProfile = null;
isOwnProfile = false;
}
init();
-6
View File
@@ -5,12 +5,6 @@
* @version 1.0.0
*/
import { showLogin } from "../navigation";
import { PRODUCT_NAME } from "./config";
showLogin();
document.querySelectorAll(".product-name").forEach(el => {
el.textContent = PRODUCT_NAME;
});
document.title = PRODUCT_NAME;
+13
View File
@@ -32,6 +32,11 @@ export interface Size2D {
y: number;
}
export interface Rect extends Size2D {
width: number;
height: number;
}
// App types
/**
@@ -223,4 +228,12 @@ export interface WebSocketError {
export interface WebSocketCredentials {
scheme: string;
credentials: string;
}
// -----------
// React types
// -----------
export interface DialogProps {
isOpen: boolean;
onOpenChange: (value: boolean) => void;
}
+108
View File
@@ -0,0 +1,108 @@
/**
* @fileoverview WebSocket connection management for real-time chat
* @description Handles WebSocket connections, message processing, and auto-reconnection
* @author Cursor
* @version 1.0.0
*/
import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types";
import { delay } from "../utils/utils";
/**
* Creates a new WebSocket connection to the chat server
* @returns {WebSocket} New WebSocket instance
* @private
*/
function create(): WebSocket {
let prefix = "ws://";
if (location.protocol.includes("https")) {
prefix = "wss://";
}
return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`);
}
/**
* Global WebSocket instance
* @type {WebSocket}
*/
export let websocket: WebSocket = create();
/**
* Global WebSocket message handler reference
* This will be set by the active panel to handle incoming messages
*/
let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null;
/**
* Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages
*/
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void {
globalMessageHandler = handler;
}
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> {
console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => {
function requestInner() {
let listener: ((e: MessageEvent) => void) | null = null;
listener = (e) => {
resolve(JSON.parse(e.data));
websocket.removeEventListener("message", listener!);
}
websocket.addEventListener("message", listener);
websocket.send(JSON.stringify(payload))
setTimeout(() => reject("Request timed out"), 10000);
}
if (websocket.readyState == 0) {
websocket.addEventListener("open", requestInner);
setTimeout(() => reject("Request timed out"), 10000);
} else {
requestInner();
}
})
}
/**
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
* If it fails, tries again in an endless loop until the connection is established
* again.
*
* @private
*/
async function onError() {
console.warn("WebSocket disconnected, retrying in 3 seconds...");
await delay(3000);
websocket = create();
let listener: () => void | null;
listener = () => {
console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener);
}
websocket.addEventListener("open", listener);
websocket.addEventListener("error", onError);
}
// --------------
// Initialization
// --------------
websocket.addEventListener("message", (e) => {
try {
const response: WebSocketMessage = JSON.parse(e.data);
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
});
websocket.addEventListener("error", onError);
+10 -9
View File
@@ -9,13 +9,14 @@ import './resources/css/style.scss';
import "mdui/mdui.css";
import "./utils/material";
import "./chat/chat";
import "./userPanel/settings";
import "./userPanel/userpanel";
import "./core/init";
import "./userPanel/profile/profile";
import "./chat/contextMenu";
import "./chat/profileDialog";
import "./electron/electron";
import "./chat/panel";
import "./chat/dm";
import { createRoot } from 'react-dom/client';
import App from './ui/App';
import { StrictMode } from 'react';
// Initialize React app
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
-42
View File
@@ -1,42 +0,0 @@
import { clearAlerts } from "./auth/auth";
import { publicChatPanel } from "./chat/chat";
import { id } from "./utils/utils";
const loginForm = id("login-form");
const registerForm = id("register-form");
const chatInterface = id("chat-interface");
const titleBar = id("electron-title-bar");
/**
* Shows the login form and hides other interfaces.
*/
export function showLogin(): void {
loginForm.style.display = 'flex';
registerForm.style.display = 'none';
chatInterface.style.display = 'none';
clearAlerts();
titleBar.classList.add("color-surface");
}
/**
* Shows the registration form and hides other interfaces.
*/
export function showRegister(): void {
loginForm.style.display = 'none';
registerForm.style.display = 'flex';
chatInterface.style.display = 'none';
clearAlerts();
titleBar.classList.add("color-surface");
}
/**
* Shows the chat interface and hides authentication forms.
*/
export function showChat(): void {
loginForm.style.display = 'none';
registerForm.style.display = 'none';
chatInterface.style.display = 'block';
titleBar.classList.remove("color-surface");
publicChatPanel.activate();
}
+79 -15
View File
@@ -321,22 +321,86 @@
}
}
// Reply preview styles
.reply-preview {
background-color: $color-dark-surface;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
border-left: 3px solid $color-dark-primary;
.reply-preview-content {
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
line-height: 1.4;
strong {
color: $color-dark-primary;
.message-profile-pic {
img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid $color-dark-outline;
&.loading {
opacity: 0.6;
cursor: default;
}
}
}
.message-username {
&.loading {
opacity: 0.6;
cursor: default;
}
}
.context-menu {
position: fixed;
background: $color-dark-surface;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: 0.5rem 0;
min-width: 160px;
z-index: 1000;
&.entering {
animation: fadeInDown 0.2s ease forwards;
}
&.entering-left {
animation: fadeInLeft 0.2s ease forwards;
}
&.entering-up {
animation: fadeInUp 0.2s ease forwards;
}
&.entering-up-left {
animation: fadeInUpLeft 0.2s ease forwards;
}
&.closing {
animation: fadeOutUp 0.2s ease forwards;
}
&.closing-left {
animation: fadeOutRight 0.2s ease forwards;
}
&.closing-up {
animation: fadeOutDown 0.2s ease forwards;
}
&.closing-up-left {
animation: fadeOutDownRight 0.2s ease forwards;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
color: $color-dark-on-surface;
font-size: 0.9rem;
transition: background-color 0.2s ease;
&:hover {
background-color: $color-dark-surface-container;
}
.material-symbols {
font-size: 1.1rem;
color: $color-dark-on-surface-variant;
}
}
}
+10
View File
@@ -187,6 +187,16 @@
}
}
}
.profile-actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
mdui-button {
flex: 1;
}
}
}
}
@@ -38,6 +38,78 @@
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUpLeft {
from {
opacity: 0;
transform: translate(10px, 10px);
}
to {
opacity: 1;
transform: translate(0, 0);
}
}
@keyframes fadeOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(10px);
}
}
@keyframes fadeOutDown {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes fadeOutDownRight {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(10px, 10px);
}
}
.chat-switch-out {
animation: fadeOutUp 0.2s ease forwards;
}
@@ -101,12 +101,17 @@ button, input {
margin: 0 0 1rem 0;
color: $color-dark-on-surface;
font-size: 1.2rem;
font-weight: 600;
}
mdui-text-field {
width: 100%;
}
.dialog-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
margin-top: 1rem;
}
}
@@ -0,0 +1,31 @@
@use "../common/material" as *;
.reply-dialog .dialog-content {
width: 300px;
overflow-x:hidden;
.reply-preview-dialog {
margin-bottom: 1rem;
padding: 16px;
background-color: $color-dark-surface-container;
border-radius: 16px;
.reply-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
.reply-username {
font-weight: 600;
color: $color-dark-on-surface;
font-size: 0.85rem;
}
.reply-text {
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
line-height: 1.4;
}
}
}
}
+1
View File
@@ -8,6 +8,7 @@
@use "common/colors" as *;
@use "common/material" as *;
@use "electron";
@use "dialogs/reply";
@use "lib/fonts/montserrat";
@use "lib/fonts/material-symbols";
+41
View File
@@ -0,0 +1,41 @@
import { ElectronTitleBar } from "./components/Electron";
import ChatScreen from "./screen/ChatScreen";
import LoginScreen from "./screen/LoginScreen";
import RegisterScreen from "./screen/RegisterScreen";
import { useAppState } from "./state";
import { useEffect } from "react";
export default function App() {
const { currentPage, restoreUserFromStorage } = useAppState();
// Restore user from localStorage on app initialization
useEffect(() => {
restoreUserFromStorage();
}, [restoreUserFromStorage]);
let page = <LoginScreen />;
switch (currentPage) {
case "login": {
page = <LoginScreen />
break;
}
case "register": {
page = <RegisterScreen />
break;
}
case "chat": {
page = <ChatScreen />
break;
}
}
return (
<>
<ElectronTitleBar />
<div id="main-wrapper">
{page}
</div>
</>
)
}
+16
View File
@@ -0,0 +1,16 @@
export type AlertType = "success" | "danger"
export interface Alert {
type: AlertType;
message: string;
}
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
return (
<div>
{alerts.slice(-3).map((alert, i) => {
return <div className={`alert alert-${alert.type}`} key={i}>{alert.message}</div>
})}
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import type React from "react";
export function AuthContainer({ children }: { children?: React.ReactNode }) {
return (
<div className="auth-container">
<div className="auth-card fade-in">
{children}
</div>
</div>
)
}
export type IconType = "filled" | "outlined";
export interface AuthHeaderIcon {
name: string;
type: IconType
}
export interface AuthHeaderProps {
title: string;
icon: string | AuthHeaderIcon;
subtitle: string;
}
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
const iconType = typeof icon == "string" ? "filled" : icon.type;
const iconName = typeof icon == "string" ? icon : icon.name;
return (
<div className="auth-header">
<h2>
<span className={`material-symbols ${iconType} large`}>{iconName}</span>
{title}
</h2>
<p>{subtitle}</p>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { PRODUCT_NAME } from "../../core/config";
export function ElectronTitleBar() {
if (window.electronInterface !== undefined) {
return (
<div id="electron-title-bar">
{window.electronInterface.platform == "darwin" ? <div className="macos-padding"></div> : undefined}
<div id="window-title">{PRODUCT_NAME}</div>
{/* <div className="window-controls">
<mdui-button-icon icon="remove" id="window-minimize"></mdui-button-icon>
<mdui-button-icon icon="stack--outlined" id="window-restore" className="hidden"></mdui-button-icon>
<mdui-button-icon icon="ad--outlined" id="window-maximize"></mdui-button-icon>
<mdui-button-icon icon="close" id="window-close"></mdui-button-icon>
</div> */}
</div>
)
}
}
@@ -0,0 +1,38 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useProfile } from "../../hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { useState } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
export function ChatHeader() {
const { profileData } = useProfile();
const [isProfileOpen, setIsProfileOpen] = useState(false);
const handleProfileClick = () => {
setIsProfileOpen(true);
};
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
return (
<>
<header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div>
<div className="profile">
<a href="#" id="profile-open" onClick={handleProfileClick}>
<img
src={profilePictureUrl}
alt=""
id="preview1"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</a>
</div>
</header>
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
</>
);
}
@@ -0,0 +1,44 @@
import { useState } from "react";
import { useChat } from "../../hooks/useChat";
interface ChatInputWrapperProps {
onSendMessage?: (message: string) => void;
}
export function ChatInputWrapper({ onSendMessage }: ChatInputWrapperProps) {
const [message, setMessage] = useState("");
const { sendMessage } = useChat();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (message.trim()) {
if (onSendMessage) {
onSendMessage(message);
} else {
await sendMessage(message);
}
setMessage("");
}
};
return (
<div className="chat-input-wrapper">
<div className="chat-input">
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
<input
type="text"
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button type="submit" className="send-btn">
<span className="material-symbols filled">send</span>
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { useChat } from "../../hooks/useChat";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function ChatMainHeader() {
const { currentChat } = useChat();
return (
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{currentChat}</h4>
<p>
<span className="online-status"></span>
Онлайн
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,171 @@
import { useChat } from "../../hooks/useChat";
import { Message } from "./Message";
import { useAppState } from "../../state";
import type { Message as MessageType } from "../../../core/types";
import type { UserProfile } from "../../../core/types";
import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { fetchUserProfile } from "../../../api/profileApi";
import { useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils";
import { request } from "../../../core/websocket";
interface ChatMessagesProps {
messages?: MessageType[];
isDm?: boolean;
children?: ReactNode;
}
export function ChatMessages({ messages: propMessages, children, isDm = false }: ChatMessagesProps) {
const { messages: hookMessages } = useChat();
const { user } = useAppState();
// Use prop messages if provided, otherwise use hook messages
const messages = propMessages || hookMessages;
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
message: null,
position: { x: 0, y: 0 }
});
const handleProfileClick = async (username: string) => {
if (!user.authToken) return;
setIsLoadingProfile(true);
try {
const profile = await fetchUserProfile(user.authToken, username);
if (profile) {
setSelectedUserProfile(profile);
setProfileDialogOpen(true);
}
} catch (error) {
console.error("Failed to fetch user profile:", error);
} finally {
setIsLoadingProfile(false);
}
};
const handleContextMenu = (e: React.MouseEvent, message: MessageType) => {
e.preventDefault();
setContextMenu({
isOpen: true,
message,
position: { x: e.clientX, y: e.clientY }
});
};
const handleContextMenuOpenChange = (isOpen: boolean) => {
setContextMenu(prev => ({
...prev,
isOpen
}));
};
const handleEdit = async (message: MessageType) => {
// This will be called when the edit dialog is saved
if (!user.authToken) return;
try {
await request({
type: "editMessage",
data: {
message_id: message.id,
content: message.content // This should be updated content from the dialog
},
credentials: {
scheme: "Bearer",
credentials: user.authToken
}
});
} catch (error) {
console.error("Failed to edit message:", error);
}
};
const handleReply = async (message: MessageType) => {
// This will be called when the reply dialog is sent
if (!user.authToken) return;
try {
await request({
type: "replyMessage",
data: {
content: message.content, // This should be the reply content from the dialog
reply_to_id: message.id
},
credentials: {
scheme: "Bearer",
credentials: user.authToken
}
});
} catch (error) {
console.error("Failed to send reply:", error);
}
};
const handleDelete = async (message: MessageType) => {
if (!user.authToken) return;
try {
await request({
type: "deleteMessage",
data: { message_id: message.id },
credentials: {
scheme: "Bearer",
credentials: user.authToken
}
});
} catch (error) {
console.error("Failed to delete message:", error);
}
};
return (
<>
<div className="chat-messages" id="chat-messages">
{messages.map((message) => (
<Message
key={message.id}
message={message}
isAuthor={message.username === user.currentUser?.username}
onProfileClick={handleProfileClick}
onContextMenu={handleContextMenu}
isLoadingProfile={isLoadingProfile}
isDm={isDm} />
))}
{children}
</div>
<UserProfileDialog
isOpen={profileDialogOpen}
onOpenChange={async (value) => {
setProfileDialogOpen(value);
if (!value) {
await delay(1000);
setSelectedUserProfile(null);
}
}}
userProfile={selectedUserProfile}
/>
{/* Context Menu */}
{contextMenu.message && (
<MessageContextMenu
message={contextMenu.message}
isAuthor={contextMenu.message.username === user.currentUser?.username}
onEdit={handleEdit}
onReply={handleReply}
onDelete={handleDelete}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange}
/>
)}
</>
);
}
@@ -0,0 +1,56 @@
import { useChat } from "../../hooks/useChat";
export function ChatTabs() {
const { activeTab, setActiveTab, setCurrentChat } = useChat();
const handleChatClick = (chatName: string) => {
setCurrentChat(chatName);
};
return (
<div className="chat-tabs">
<mdui-tabs value={activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
<mdui-tab value="chats">
Чаты
</mdui-tab>
<mdui-tab value="channels">
Каналы
</mdui-tab>
<mdui-tab value="contacts">
Контакты
</mdui-tab>
<mdui-tab value="dms">
ЛС
</mdui-tab>
<mdui-tab-panel slot="panel" value="chats">
<mdui-list>
<mdui-list-item
headline="Общий чат"
description="Вы: Последнее сообщение"
id="chat-list-chat-1"
onClick={() => handleChatClick("Общий чат")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
<mdui-list-item
headline="Общий чат 2"
description="Вы: Последнее сообщение"
id="chat-list-chat-2"
onClick={() => handleChatClick("Общий чат 2")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="dms">
<mdui-list id="dm-users"></mdui-list>
</mdui-tab-panel>
</mdui-tabs>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useState, useEffect, useRef } from "react";
import { useAppState } from "../../state";
import { useDM } from "../../hooks/useDM";
import { ChatMessages } from "./ChatMessages";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function DMPanel() {
const { chat } = useAppState();
const { sendDMMessage, isLoadingHistory } = useDM();
const [message, setMessage] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeDm = chat.activeDm;
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chat.messages]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || !activeDm?.publicKey) return;
try {
await sendDMMessage(activeDm.userId, activeDm.publicKey, message);
setMessage("");
} catch (error) {
console.error("Failed to send DM:", error);
}
};
const handleProfileClick = () => {
// TODO: Implement profile dialog for DM user
console.log("Profile clicked for DM user:", activeDm?.username);
};
if (!activeDm) {
return (
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">Выберите пользователя</h4>
<p>
<span className="online-status"></span>
Выберите пользователя для начала разговора
</p>
</div>
</div>
</div>
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите пользователя из списка для начала личных сообщений
</div>
</div>
</div>
);
}
return (
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img
src={defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={handleProfileClick}
style={{ cursor: "pointer" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{activeDm.username}</h4>
<p>
<span className="online-status"></span>
Личные сообщения
</p>
</div>
<a href="#" id="hide-chat">Свернуть чат</a>
</div>
</div>
<div className="chat-messages" id="chat-messages">
{isLoadingHistory ? (
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Загрузка сообщений...
</div>
) : (
<>
<ChatMessages />
<div ref={messagesEndRef} />
</>
)}
</div>
<div className="chat-input-wrapper">
<div className="chat-input">
<form className="input-group" id="message-form" onSubmit={handleSendMessage}>
<input
type="text"
className="message-input"
id="message-input"
placeholder="Напишите сообщение..."
autoComplete="off"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button type="submit" className="send-btn">
<span className="material-symbols filled">send</span>
</button>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,97 @@
import { useEffect } from "react";
import { useDM } from "../../hooks/useDM";
import { useAppState } from "../../state";
import { fetchUserPublicKey } from "../../../api/dmApi";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function DMUsersList() {
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
const { chat, switchToDM } = useAppState();
useEffect(() => {
if (chat.activeTab === "dms") {
loadUsers();
}
}, [chat.activeTab, loadUsers]);
if (isLoadingUsers) {
return (
<mdui-list>
<mdui-list-item headline="Загрузка..." description="Получение списка пользователей...">
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
);
}
if (dmUsers.length === 0) {
return (
<mdui-list>
<mdui-list-item headline="Нет пользователей" description="Пользователи не найдены">
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
);
}
const handleUserClick = async (user: any) => {
if (!user.publicKey) {
// Get public key if not already loaded
const authToken = useAppState.getState().user.authToken;
if (!authToken) {
console.error("No auth token available");
return;
}
const publicKey = await fetchUserPublicKey(user.id, authToken);
if (publicKey) {
user.publicKey = publicKey;
} else {
console.error("Failed to get public key for user:", user.id);
return;
}
}
await switchToDM({
userId: user.id,
username: user.username,
publicKey: user.publicKey,
profilePicture: user.profile_picture,
online: user.online || false
});
};
return (
<mdui-list>
{dmUsers.map((user) => (
<mdui-list-item
key={user.id}
headline={user.username}
description={user.lastMessage || "Нет сообщений"}
onClick={() => handleUserClick(user)}
style={{ cursor: "pointer" }}
>
<img
src={user.profile_picture || defaultAvatar}
alt={user.username}
slot="icon"
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover"
}}
onError={(e) => {
(e.target as HTMLImageElement).src = defaultAvatar;
}}
/>
{user.unreadCount > 0 && (
<mdui-badge slot="end-icon">
{user.unreadCount}
</mdui-badge>
)}
</mdui-list-item>
))}
</mdui-list>
);
}
@@ -0,0 +1,54 @@
import { useState, useEffect } from "react";
import type { Message } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
interface EditMessageDialogProps {
isOpen: boolean;
onOpenChange: (value: boolean) => void;
message: Message | null;
onSave: (messageId: number, newContent: string) => void;
}
export function EditMessageDialog({ isOpen, onOpenChange, message, onSave }: EditMessageDialogProps) {
const [editContent, setEditContent] = useState("");
useEffect(() => {
if (message) {
setEditContent(message.content);
}
}, [message]);
const handleSave = () => {
if (message && editContent.trim()) {
onSave(message.id, editContent.trim());
onOpenChange(false);
}
};
const handleCancel = () => {
onOpenChange(false);
setEditContent("");
};
if (!message) return null;
return (
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc>
<div className="dialog-content">
<h3>Edit Message</h3>
<mdui-text-field
value={editContent}
onInput={(e) => setEditContent((e.target as HTMLInputElement).value)}
label="Edit Message"
variant="outlined"
placeholder="Edit your message..."
maxlength={1000}>
</mdui-text-field>
<div className="dialog-actions">
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
<mdui-button onClick={handleSave}>Save</mdui-button>
</div>
</div>
</MaterialDialog>
);
}
@@ -0,0 +1,117 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useAppState } from "../../state";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { useState, type FormEvent } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
import { SettingsDialog } from "../settings/SettingsDialog";
import { DMUsersList } from "./DMUsersList";
import type { Tabs } from "mdui";
import type { ChatTabs } from "../../state";
function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false);
const { logout } = useAppState();
const handleLogout = () => {
logout();
};
return (
<>
<mdui-bottom-app-bar>
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
<div style={{ flexGrow: 1 }}></div>
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
onClick={handleLogout}
title="Выйти"
></mdui-button-icon>
<mdui-fab icon="edit--filled"></mdui-fab>
</mdui-bottom-app-bar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</>
);
}
function ChatTabs() {
const { chat, switchToTab, switchToPublicChat } = useAppState();
const { activeTab } = chat;
const handleChatClick = async (chatName: string) => {
await switchToPublicChat(chatName);
};
const handleTabChange = async (e: FormEvent<Tabs>) => {
const tab = (e.target as Tabs).value as ChatTabs;
await switchToTab(tab);
};
return (
<div className="chat-tabs">
<mdui-tabs value={activeTab} full-width onChange={handleTabChange}>
<mdui-tab value="chats">Чаты</mdui-tab>
<mdui-tab value="channels">Каналы</mdui-tab>
<mdui-tab value="contacts">Контакты</mdui-tab>
<mdui-tab value="dms">ЛС</mdui-tab>
<mdui-tab-panel slot="panel" value="chats">
<mdui-list>
<mdui-list-item
headline="Общий чат"
description="Вы: Последнее сообщение"
id="chat-list-chat-1"
onClick={() => handleChatClick("Общий чат")}
style={{ cursor: "pointer" }}
>
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
<mdui-list-item
headline="Общий чат 2"
description="Вы: Последнее сообщение"
id="chat-list-chat-2"
onClick={() => handleChatClick("Общий чат 2")}
style={{ cursor: "pointer" }}
>
<img src={defaultAvatar} alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="dms">
<DMUsersList />
</mdui-tab-panel>
</mdui-tabs>
</div>
);
}
function ChatHeader() {
const [isProfileOpen, setProfileOpen] = useState(false);
return (
<header className="chat-header-left">
<div className="product-name">{PRODUCT_NAME}</div>
<div className="profile">
<a href="#" id="profile-open" onClick={() => setProfileOpen(true)}>
<img src={defaultAvatar} alt="" id="preview1" />
</a>
</div>
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setProfileOpen} />
</header>
);
}
export function LeftPanel() {
return (
<div className="chat-list" id="chat-list">
<ChatHeader />
<ChatTabs />
<BottomAppBar />
</div>
);
}
@@ -0,0 +1,79 @@
import { formatTime } from "../../../utils/utils";
import type { Message as MessageType } from "../../../core/types";
import defaultAvatar from "../../../resources/images/default-avatar.png";
interface MessageProps {
message: MessageType;
isAuthor: boolean;
onProfileClick: (username: string) => void;
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
isLoadingProfile?: boolean;
isDm?: boolean;
}
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) {
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, message);
};
return (
<div
className={`message ${isAuthor ? "sent" : "received"}`}
data-id={message.id}
onContextMenu={handleContextMenu}
>
<div className="message-inner">
{/* Add profile picture for received messages */}
{!isAuthor && !isDm && (
<div className="message-profile-pic">
<img
src={message.profile_picture || defaultAvatar}
alt={message.username}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
className={isLoadingProfile ? "loading" : ""}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</div>
)}
{!isAuthor && !isDm && (
<div
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}> {/* TODO extract to SCSS */}
{message.username}
</div>
)}
{/* Add reply preview if this is a reply */}
{message.reply_to && (
<div className="message-reply">
<div className="reply-content">
<span className="reply-username">{message.reply_to.username}</span>
<span className="reply-text">{message.reply_to.content}</span>
</div>
</div>
)}
<div className="message-content">
{message.content}
</div>
<div className="message-time">
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read ? (
<span className="material-symbols outlined"></span>
) : undefined}
</div>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
import { useState, useEffect } from "react";
import type { Message, Size2D } from "../../../core/types";
import { EditMessageDialog } from "./EditMessageDialog";
import { ReplyMessageDialog } from "./ReplyMessageDialog";
interface MessageContextMenuProps {
message: Message;
isAuthor: boolean;
onEdit: (message: Message) => void;
onReply: (message: Message) => void;
onDelete: (message: Message) => void;
position: Size2D;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export interface ContextMenuState {
isOpen: boolean;
message: Message | null;
position: Size2D;
}
export function MessageContextMenu({
message,
isAuthor,
onEdit,
onReply,
onDelete,
position,
isOpen,
onOpenChange
}: MessageContextMenuProps) {
// Internal state for dialogs and closing animation
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [replyDialogOpen, setReplyDialogOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering');
// Calculate smart positioning when component opens
useEffect(() => {
if (isOpen) {
const menuWidth = 160; // min-width from CSS
const menuHeight = isAuthor ? 120 : 60; // Approximate height based on items
const padding = 10; // Padding from viewport edges
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
let animation = 'entering';
// Check if menu would overflow right edge
if (x + menuWidth + padding > viewportWidth) {
x = viewportWidth - menuWidth - padding;
animation = 'entering-left'; // Animation from left side
}
// Check if menu would overflow bottom edge
if (y + menuHeight + padding > viewportHeight) {
y = viewportHeight - menuHeight - padding;
animation = 'entering-up'; // Animation from bottom
}
// If both edges would overflow, use top-left positioning
if (x + menuWidth + padding > viewportWidth && y + menuHeight + padding > viewportHeight) {
x = Math.max(padding, position.x - menuWidth);
y = Math.max(padding, position.y - menuHeight);
animation = 'entering-up-left';
}
setCalculatedPosition({ x, y });
setAnimationClass(animation);
}
}, [isOpen, position, isAuthor]);
// Effect to handle clicks outside the context menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) {
// Check if the click is on a context menu element
const target = event.target as Element;
if (!target.closest('.context-menu')) {
handleClose();
}
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) {
handleClose();
}
};
const handleWindowBlur = () => {
// Close context menu when browser window loses focus
if (isOpen && !isClosing && !editDialogOpen && !replyDialogOpen) {
handleClose();
}
};
// Add event listeners
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('blur', handleWindowBlur);
// Cleanup
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleWindowBlur);
};
}, [isOpen, isClosing, editDialogOpen, replyDialogOpen]);
const handleAction = (action: string) => {
switch (action) {
case "reply":
setReplyDialogOpen(true);
break;
case "edit":
if (isAuthor) {
setEditDialogOpen(true);
}
break;
case "delete":
if (isAuthor) {
onDelete(message);
handleClose();
}
break;
}
};
const handleClose = () => {
setIsClosing(true);
// Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace('entering', 'closing');
setAnimationClass(closingAnimation);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening
}, 200); // Match the animation duration from _animations.scss
};
const handleEditSave = (_messageId: number, newContent: string) => {
// Create a temporary message object with the updated content
const updatedMessage = { ...message, content: newContent };
onEdit(updatedMessage);
setEditDialogOpen(false);
};
const handleSendReply = (content: string, replyToId: number) => {
// Create a temporary message object with the reply content
const replyMessage = { ...message, content, id: replyToId };
onReply(replyMessage);
setReplyDialogOpen(false);
};
const content = (
<div
className={`context-menu ${animationClass}`}
style={{
position: "fixed",
display: "block",
top: calculatedPosition.y,
left: calculatedPosition.x,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}
>
<div className="context-menu-item" onClick={() => handleAction("reply")}>
<span className="material-symbols">reply</span>
Reply
</div>
{isAuthor && (
<>
<div className="context-menu-item" onClick={() => handleAction("edit")}>
<span className="material-symbols">edit</span>
Edit
</div>
<div className="context-menu-item" onClick={() => handleAction("delete")}>
<span className="material-symbols">delete</span>
Delete
</div>
</>
)}
</div>
)
// Don't render if not open
if (!isOpen && !editDialogOpen && !replyDialogOpen) return null;
return (
<>
{isOpen ? content : null}
{/* Edit Dialog */}
<EditMessageDialog
isOpen={editDialogOpen}
onOpenChange={setEditDialogOpen}
message={message}
onSave={handleEditSave}
/>
{/* Reply Dialog */}
<ReplyMessageDialog
isOpen={replyDialogOpen}
onOpenChange={setReplyDialogOpen}
replyToMessage={message}
onSendReply={handleSendReply}
/>
</>
);
}
@@ -0,0 +1,144 @@
import { useState, useEffect, useRef } from "react";
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "../../../core/websocket";
import defaultAvatar from "../../../resources/images/default-avatar.png";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
isChatSwitching: boolean;
}
export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) {
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Handle panel state changes
useEffect(() => {
if (panel) {
setPanelState(panel.getState());
// Set up state change listener
const handleStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Store the handler for cleanup
panel.onStateChange = handleStateChange;
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler(panel.handleWebSocketMessage);
}
} else {
setPanelState(null);
// Clear global message handler when no panel is active
setGlobalMessageHandler(null);
}
// Cleanup function
return () => {
if (panel && (panel as any).onStateChange) {
(panel as any).onStateChange = null;
}
};
}, [panel]);
// Handle chat switching animation
useEffect(() => {
if (isChatSwitching) {
setSwitchOut(true);
setTimeout(() => {
setSwitchOut(false);
setSwitchIn(true);
setTimeout(() => setSwitchIn(false), 200);
}, 250);
}
}, [isChatSwitching]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [panelState?.messages]);
if (!panel || !panelState) {
return (
<div className="chat-container">
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">Select a chat</h4>
<p>
<span className="online-status"></span>
Choose a chat to start messaging
</p>
</div>
</div>
</div>
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Select a chat from the sidebar to start messaging
</div>
</div>
</div>
</div>
);
}
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img
src={panelState.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={panel.handleProfileClick}
style={{ cursor: "pointer" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState.title}</h4>
<p>
<span className={`online-status ${panelState.online ? "online" : "offline"}`}></span>
{panelState.online ? "Online" : "Offline"}
{panelState.isTyping && " • Typing..."}
</p>
</div>
</div>
</div>
{panelState.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Loading messages...
</div>
</div>
): (
<ChatMessages messages={panelState.messages} isDm={panel.isDm()}>
<div ref={messagesEndRef} />
</ChatMessages>
)}
<ChatInputWrapper onSendMessage={panel.handleSendMessage} />
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import { useState, useEffect } from "react";
import type { Message } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { MaterialTextField } from "../core/TextField";
interface ReplyMessageDialogProps {
isOpen: boolean;
onOpenChange: (value: boolean) => void;
replyToMessage: Message | null;
onSendReply: (content: string, replyToId: number) => void;
}
export function ReplyMessageDialog({ isOpen, onOpenChange, replyToMessage, onSendReply }: ReplyMessageDialogProps) {
const [replyContent, setReplyContent] = useState("");
useEffect(() => {
if (replyToMessage) {
setReplyContent("");
}
}, [replyToMessage]);
const handleSendReply = () => {
if (replyToMessage && replyContent.trim()) {
onSendReply(replyContent.trim(), replyToMessage.id);
onOpenChange(false);
}
};
const handleCancel = () => {
onOpenChange(false);
setReplyContent("");
};
if (!replyToMessage) return null;
return (
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc className="reply-dialog">
<div className="dialog-content">
<h3>Reply to Message</h3>
<div className="reply-preview-dialog">
<div className="reply-content">
<span className="reply-username">{replyToMessage.username}</span>
<span className="reply-text">{replyToMessage.content}</span>
</div>
</div>
<MaterialTextField
value={replyContent}
onInput={(e) => setReplyContent((e.target as HTMLInputElement).value)}
label="Reply"
variant="outlined"
placeholder="Type your reply..."
maxlength={1000} />
<div className="dialog-actions">
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
<mdui-button onClick={handleSendReply}>Send Reply</mdui-button>
</div>
</div>
</MaterialDialog>
);
}
@@ -0,0 +1,13 @@
import { useAppState } from "../../state";
import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { chat } = useAppState();
return (
<MessagePanelRenderer
panel={chat.activePanel}
isChatSwitching={chat.isChatSwitching}
/>
);
}
@@ -0,0 +1,71 @@
import type { DialogProps } from "../../../core/types";
import type { UserProfile } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { formatTime } from "../../../utils/utils";
import defaultAvatar from "../../../resources/images/default-avatar.png";
interface UserProfileDialogProps extends DialogProps {
userProfile: UserProfile | null;
}
export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserProfileDialogProps) {
const content = userProfile ? (
<div className="content">
<div className="profile-picture-section">
<img
className="profile-picture"
alt="Profile Picture"
src={userProfile.profile_picture || defaultAvatar}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
</div>
<div className="profile-info">
<div className="username-section">
<h4 className="username">{userProfile.username}</h4>
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
{userProfile.online ? (
<>
<span className="online-indicator"></span> Online
</>
) : (
<>
<span className="offline-indicator"></span> Last seen {formatTime(userProfile.last_seen)}
</>
)}
</div>
</div>
<div className="bio-section">
<label>Bio:</label>
<div className="bio-display">
{userProfile.bio || "No bio available."}
</div>
</div>
<div className="profile-stats">
<div className="stat">
<span className="stat-label">Member since:</span>
<span className="stat-value member-since">{formatTime(userProfile.created_at)}</span>
</div>
<div className="stat">
<span className="stat-label">Last seen:</span>
<span className="stat-value last-seen">{formatTime(userProfile.last_seen)}</span>
</div>
</div>
<div className="profile-actions">
<mdui-button id="dm-button" variant="filled">
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
Send Message
</mdui-button>
</div>
</div>
</div>
) : null
return (
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc id="user-profile-dialog">
{content}
</MaterialDialog>
);
}
@@ -0,0 +1,43 @@
import type { Dialog as MduiDialog } from "mdui/components/dialog";
import React, { useEffect, useRef } from "react"
import { createPortal } from "react-dom";
import { id } from "../../../utils/utils";
export interface BaseDialogProps {
onOpenChange: (value: boolean) => void;
}
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
export function MaterialDialog(props: FullDialogProps) {
const dialogRef = useRef<MduiDialog>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open") {
const isOpen = dialog.hasAttribute("open");
if (isOpen !== props.open) {
props.onOpenChange(isOpen);
}
}
});
});
// Start observing the dialog element for attribute changes
observer.observe(dialog, {
attributes: true,
attributeFilter: ["open"]
});
// Cleanup observer
return () => {
observer.disconnect();
};
}, [dialogRef.current, props.open, props.onOpenChange]);
return createPortal(<mdui-dialog {...props} ref={dialogRef} />, id("root"));
}
@@ -0,0 +1,9 @@
import type { TextField } from "mdui/components/text-field";
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
return <mdui-text-field
autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
}
@@ -0,0 +1,19 @@
export function CropperDialog() {
return (
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
</div>
<div className="cropper-container">
<div id="cropper-area"></div>
</div>
<div className="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
<mdui-button id="crop-save">Сохранить</mdui-button>
</div>
</div>
</mdui-dialog>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "../../../core/types";
interface ImageCropperProps {
onCrop: (croppedImageData: string) => void;
onCancel: () => void;
imageFile: File | null;
}
export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [src, setSrc] = useState<string | undefined>(undefined);
const [isLoaded, setIsLoaded] = useState(false);
const [cropArea, setCropArea] = useState<Rect>({ x: 0, y: 0, width: 200, height: 200 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
useEffect(() => {
if (imageFile) {
const reader = new FileReader();
function handleImageLoad() {
setIsLoaded(true);
// Initialize crop area to center of image
const img = imageRef.current;
if (img) {
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
setCropArea({
x: (img.naturalWidth - size) / 2,
y: (img.naturalHeight - size) / 2,
width: size,
height: size
});
}
}
function handleReaderLoad() {
if (imageRef.current) {
setSrc(reader.result as string);
imageRef.current.addEventListener("load", handleImageLoad);
}
}
reader.addEventListener("load", handleReaderLoad);
reader.readAsDataURL(imageFile);
return () => {
reader.abort();
reader.removeEventListener("load", handleReaderLoad);
imageRef.current?.removeEventListener("load", handleImageLoad);
}
}
}, [imageFile]);
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if click is within crop area
if (x >= cropArea.x && x <= cropArea.x + cropArea.width &&
y >= cropArea.y && y <= cropArea.y + cropArea.height) {
setIsDragging(true);
setDragStart({ x: x - cropArea.x, y: y - cropArea.y });
}
};
function handleMouseMove(e: React.MouseEvent) {
const rect = canvasRef.current?.getBoundingClientRect();
if (isDragging && isLoaded && rect && imageRef.current) {
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const newX = Math.max(
0,
Math.min(
x - dragStart.x,
imageRef.current.naturalWidth - cropArea.width
)
);
const newY = Math.max(
0,
Math.min(
y - dragStart.y,
imageRef.current.naturalHeight - cropArea.height
)
);
setCropArea(prev => ({ ...prev, x: newX, y: newY }));
}
};
function handleMouseUp() {
setIsDragging(false);
};
function handleCrop() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to crop area
canvas.width = cropArea.width;
canvas.height = cropArea.height;
// Draw cropped portion
ctx.drawImage(
imageRef.current,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, cropArea.width, cropArea.height
);
// Convert to data URL
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
onCrop(croppedImageData);
};
function drawCropArea() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw image
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
// Draw crop overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
// Draw crop border
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
};
useEffect(() => {
drawCropArea();
}, [cropArea, isLoaded]);
if (!imageFile) return null;
return (
<div className="cropper-container">
<canvas
ref={canvasRef}
width={400}
height={400}
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
height: 'auto'
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
<img
ref={imageRef}
src={src}
style={{ display: 'none' }}
alt="Crop source"
/>
<div className="cropper-actions">
<mdui-button onClick={handleCrop} disabled={!isLoaded}>
Обрезать
</mdui-button>
<mdui-button variant="outlined" onClick={onCancel}>
Отмена
</mdui-button>
</div>
</div>
);
}
@@ -0,0 +1,179 @@
import { useState, useEffect, useRef, type FormEvent } from "react";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import type { TextField } from "mdui/components/text-field";
import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { useProfile } from "../../hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
import { MaterialTextField } from "../core/TextField";
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
const [username, setUsername] = useState(profileData?.nickname ?? "");
const [description, setDescription] = useState(profileData?.description ?? "");
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [showCropper, setShowCropper] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Update form fields when profile data changes
useEffect(() => {
if (profileData) {
setUsername(profileData.nickname || "");
setDescription(profileData.description || "");
}
}, [profileData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const success = await updateProfileData({
nickname: username.trim() || undefined,
description: description.trim() || undefined
});
if (success) {
onOpenChange(false);
}
};
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
setSelectedImage(file);
setShowCropper(true);
}
};
const handleCropComplete = async (croppedImageData: string) => {
try {
// Convert data URL to blob
const response = await fetch(croppedImageData);
const blob = await response.blob();
const success = await uploadProfilePictureData(blob);
if (success) {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
} catch (error) {
console.error('Error processing cropped image:', error);
}
};
const handleCropCancel = () => {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
return (
<>
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
<div className="content">
<div className="header-top">
<div className="profile-picture-container">
<img
id="profile-picture"
src={profilePictureUrl}
alt="Ваше фото"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = defaultAvatar;
}}
/>
<mdui-button-icon
icon="camera_alt--filled"
id="upload-pfp-btn"
className="upload-overlay"
variant="filled"
onClick={handleUploadClick}
disabled={isUpdating}
/>
<input
ref={fileInputRef}
type="file"
id="pfp-file-input"
accept="image/*"
style={{ display: "none" }}
onChange={handleImageSelect}
/>
</div>
<MaterialTextField
id="username-field"
label="Имя пользователя"
variant="outlined"
value={username}
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
autocomplete="username"
disabled={isLoading || isUpdating} />
</div>
<form id="profile-form" onSubmit={handleSubmit}>
<MaterialTextField
id="description-field"
label="О себе"
variant="outlined"
value={description}
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
placeholder="Расскажите о себе..."
autocomplete="none"
disabled={isLoading || isUpdating} />
<div className="dialog-actions">
<mdui-button
type="submit"
id="profile-submit"
disabled={isLoading || isUpdating}
>
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
</mdui-button>
<mdui-button
id="profile-dialog-close"
variant="outlined"
onClick={() => onOpenChange(false)}
disabled={isUpdating}
>
Закрыть
</mdui-button>
</div>
</form>
</div>
</MaterialDialog>
{/* Image Cropper Dialog */}
<MaterialDialog
id="cropper-dialog"
close-on-overlay-click
close-on-esc
open={showCropper}
onOpenChange={setShowCropper}
>
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" onClick={handleCropCancel} />
</div>
<div className="cropper-container">
<ImageCropper
imageFile={selectedImage}
onCrop={handleCropComplete}
onCancel={handleCropCancel}
/>
</div>
</div>
</MaterialDialog>
</>
);
}
@@ -0,0 +1,153 @@
import { useState } from "react";
import { PRODUCT_NAME } from "../../../core/config";
import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
const handlePanelChange = (panelId: string) => {
setActivePanel(panelId);
};
return (
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
<div className="fullscreen-wrapper">
<div id="settings-dialog-inner">
<div className="header">
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon>
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
</div>
<div id="settings-menu">
<mdui-list>
<mdui-list-item
icon="notifications--filled"
rounded
active={activePanel === "notifications-settings"}
onClick={() => handlePanelChange("notifications-settings")}
style={{ cursor: "pointer" }}
>
Уведомления
</mdui-list-item>
<mdui-list-item
icon="palette--filled"
rounded
active={activePanel === "appearance-settings"}
onClick={() => handlePanelChange("appearance-settings")}
style={{ cursor: "pointer" }}
>
Внешний вид
</mdui-list-item>
<mdui-list-item
icon="security--filled"
rounded
active={activePanel === "security-settings"}
onClick={() => handlePanelChange("security-settings")}
style={{ cursor: "pointer" }}
>
Безопасность
</mdui-list-item>
<mdui-list-item
icon="language--filled"
rounded
active={activePanel === "language-settings"}
onClick={() => handlePanelChange("language-settings")}
style={{ cursor: "pointer" }}
>
Язык
</mdui-list-item>
<mdui-list-item
icon="storage--filled"
rounded
active={activePanel === "storage-settings"}
onClick={() => handlePanelChange("storage-settings")}
style={{ cursor: "pointer" }}
>
Хранилище
</mdui-list-item>
<mdui-list-item
icon="help--filled"
rounded
active={activePanel === "help-settings"}
onClick={() => handlePanelChange("help-settings")}
style={{ cursor: "pointer" }}
>
Помощь
</mdui-list-item>
<mdui-list-item
icon="info--filled"
rounded
active={activePanel === "about-settings"}
onClick={() => handlePanelChange("about-settings")}
style={{ cursor: "pointer" }}
>
О приложении
</mdui-list-item>
</mdui-list>
<div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3>
<mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch>
<mdui-switch>Уведомления о статусе</mdui-switch>
<mdui-switch checked>Email уведомления</mdui-switch>
</div>
<div id="appearance-settings" className={`settings-panel ${activePanel === "appearance-settings" ? "active" : ""}`}>
<h3>Внешний вид</h3>
<mdui-select label="Тема" variant="outlined">
<mdui-menu-item value="dark">Тёмная</mdui-menu-item>
<mdui-menu-item value="light">Светлая</mdui-menu-item>
<mdui-menu-item value="auto">Авто</mdui-menu-item>
</mdui-select>
<mdui-select label="Размер шрифта" variant="outlined">
<mdui-menu-item value="small">Маленький</mdui-menu-item>
<mdui-menu-item value="medium">Средний</mdui-menu-item>
<mdui-menu-item value="large">Большой</mdui-menu-item>
</mdui-select>
</div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3>
<mdui-button variant="outlined">Изменить пароль</mdui-button>
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button>
<mdui-switch>Автоматический выход</mdui-switch>
</div>
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
<h3>Язык</h3>
<mdui-select label="Выберите язык" variant="outlined">
<mdui-menu-item value="ru">Русский</mdui-menu-item>
<mdui-menu-item value="en">English</mdui-menu-item>
<mdui-menu-item value="es">Español</mdui-menu-item>
</mdui-select>
</div>
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress>
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
<div id="help-settings" className={`settings-panel ${activePanel === "help-settings" ? "active" : ""}`}>
<h3>Помощь</h3>
<mdui-button variant="outlined">Руководство пользователя</mdui-button>
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button>
<mdui-button variant="outlined">FAQ</mdui-button>
</div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<h3>О приложении</h3>
<p>Версия: 1.0.0</p>
<p>© 2025 <span className="product-name">{PRODUCT_NAME}</span>. Все права защищены.</p>
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
<mdui-button variant="outlined">Условия использования</mdui-button>
</div>
</div>
</div>
</div>
</div>
</MaterialDialog>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import { request } from "../../core/websocket";
import { API_BASE_URL } from "../../core/config";
import type { Message } from "../../core/types";
import { getAuthHeaders } from "../../auth/api";
import { delay } from "../../utils/utils";
export function useChat() {
const {
chat,
addMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm,
setIsChatSwitching,
user
} = useAppState();
const messagesLoadedRef = useRef(false);
// Load messages for the current chat
const loadMessages = useCallback(async () => {
if (!user.authToken || messagesLoadedRef.current) 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 && data.messages.length > 0) {
// Clear existing messages and add new ones
clearMessages();
data.messages.forEach((msg: Message) => {
addMessage(msg);
});
}
}
messagesLoadedRef.current = true;
} catch (error) {
console.error("Error loading messages:", error);
}
}, [user.authToken, addMessage, clearMessages]);
// Send a message
const sendMessage = useCallback(async (content: string) => {
if (!user.authToken || !content.trim()) return;
try {
const response = await request({
data: { content: content.trim() },
credentials: {
scheme: "Bearer",
credentials: user.authToken
},
type: "sendMessage"
});
if (response.error) {
console.error("Error sending message:", response.error);
}
} catch (error) {
console.error("Error sending message:", error);
}
}, [user.authToken]);
// WebSocket messages are now handled by the active panel
// No need for duplicate handling here
// Load messages only once when component mounts and user is authenticated
useEffect(() => {
if (user.authToken && !messagesLoadedRef.current) {
loadMessages();
}
}, [user.authToken, loadMessages]);
// Reset messages loaded flag and clear messages when chat changes
useEffect(() => {
(async () => {
setIsChatSwitching(true);
await delay(250);
messagesLoadedRef.current = false;
clearMessages(); // Clear messages when switching chats
loadMessages();
setIsChatSwitching(false);
})();
}, [chat.currentChat, clearMessages]);
return {
messages: chat.messages,
currentChat: chat.currentChat,
activeTab: chat.activeTab,
dmUsers: chat.dmUsers,
activeDm: chat.activeDm,
isChatSwitching: chat.isChatSwitching,
setIsChatSwitching,
sendMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm
};
}
+341
View File
@@ -0,0 +1,341 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import {
fetchUsers,
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
sendDMViaWebSocket
} from "../../api/dmApi";
import type { User, Message } from "../../core/types";
import { websocket } from "../../core/websocket";
interface DMUser extends User {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
}
export function useDM() {
const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState();
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const usersLoadedRef = useRef(false);
// Load last message and unread count for a specific user
const loadUserLastMessage = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
// Get message history
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
if (messages.length === 0) return;
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
lastPlaintext = await decryptDm(lastMessage, publicKey);
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
// Calculate unread count
const lastReadId = getLastReadId(dmUser.id);
let unreadCount = 0;
for (const msg of messages) {
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
unreadCount++;
}
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
}
: u
));
} catch (error) {
console.error("Failed to load last message for user:", dmUser.id, error);
}
}, [user.authToken]);
// Load users when DM tab is active
const loadUsers = useCallback(async () => {
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
usersLoadedRef.current = true;
setIsLoadingUsers(true);
try {
const users = await fetchUsers(user.authToken);
console.log("Fetched users:", users);
const dmUsersWithState: DMUser[] = users.map(user => ({
...user,
unreadCount: 0,
lastMessage: undefined,
publicKey: null
}));
setDmUsersState(dmUsersWithState);
setDmUsers(users);
// Load last messages and unread counts for visible users
// Call loadUserLastMessage directly without dependency
for (const dmUser of dmUsersWithState) {
if (!user.authToken) continue;
try {
// Get public key
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) continue;
// Get message history
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
if (messages.length === 0) continue;
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
lastPlaintext = await decryptDm(lastMessage, publicKey);
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
// Calculate unread count
const lastReadId = getLastReadId(dmUser.id);
let unreadCount = 0;
for (const msg of messages) {
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
unreadCount++;
}
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
}
: u
));
} catch (error) {
console.error("Failed to load last message for user:", dmUser.id, error);
}
}
} catch (error) {
console.error("Failed to load DM users:", error);
} finally {
setIsLoadingUsers(false);
}
}, [user.authToken, isLoadingUsers]);
// Reset users loaded flag when user changes
useEffect(() => {
usersLoadedRef.current = false;
}, [user.authToken]);
// Load DM history for active conversation
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true);
try {
const messages = await fetchDMHistory(userId, user.authToken, 50);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const text = await decryptDm(env, publicKey);
const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({
id: env.id,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
if (env.senderId === userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
console.error("Error decrypting message:", error);
}
}
clearMessages();
decryptedMessages.forEach(msg => addMessage(msg));
// Update last read ID
if (maxIncomingId > 0) {
setLastReadId(userId, maxIncomingId);
// Clear unread count
setDmUsersState(prev => prev.map(u =>
u.id === userId ? { ...u, unreadCount: 0 } : u
));
}
} catch (error) {
console.error("Failed to load DM history:", error);
} finally {
setIsLoadingHistory(false);
}
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
// Send DM message
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
if (!user.authToken) return;
try {
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken);
} catch (error) {
console.error("Failed to send DM:", error);
}
}, [user.authToken]);
// Start DM conversation
const startDMConversation = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key if not already loaded
let publicKey = dmUser.publicKey;
if (!publicKey) {
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
}
// Set active DM
setActiveDm({
userId: dmUser.id,
username: dmUser.username,
publicKey
});
// Load conversation history
await loadDMHistory(dmUser.id, publicKey);
} catch (error) {
console.error("Failed to start DM conversation:", error);
}
}, [user.authToken, setActiveDm, loadDMHistory]);
// WebSocket message handler
useEffect(() => {
const handleWebSocketMessage = async (e: MessageEvent) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "dmNew") {
const { senderId, recipientId, ...envelope } = msg.data;
// If this is for the active DM conversation
if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) {
try {
const plaintext = await decryptDm(envelope, chat.activeDm.publicKey!);
const isAuthor = senderId !== chat.activeDm.userId;
addMessage({
id: envelope.id,
content: plaintext,
username: isAuthor ? (user.currentUser?.username || "Unknown") : (chat.activeDm.username || "Unknown"),
timestamp: envelope.timestamp,
is_read: false,
is_edited: false
});
// Update last read if it's from the other user
if (senderId === chat.activeDm.userId) {
setLastReadId(chat.activeDm.userId, Math.max(getLastReadId(chat.activeDm.userId), envelope.id));
}
} catch (error) {
console.error("Failed to decrypt incoming DM:", error);
}
} else {
// Update unread count for other users
const otherUserId = senderId;
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? { ...u, unreadCount: u.unreadCount + 1 }
: u
));
// Update last message preview
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const plaintext = await decryptDm(envelope, publicKey);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
publicKey
}
: u
));
}
} catch (error) {
console.error("Failed to update last message preview:", error);
}
}
}
} catch (error) {
console.error("Failed to handle WebSocket message:", error);
}
};
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [chat.activeDm, user.currentUser, addMessage]);
// Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => {
usersLoadedRef.current = false;
loadUsers();
}, [loadUsers]);
return {
dmUsers,
isLoadingUsers,
isLoadingHistory,
loadUsers,
reloadUsers,
startDMConversation,
sendDMMessage,
loadUserLastMessage
};
}
// Helper functions for localStorage
function getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
function setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}
+98
View File
@@ -0,0 +1,98 @@
import { useState, useCallback, useEffect } from "react";
import { useAppState } from "../state";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../api/profileApi";
import { showSuccess, showError } from "../../utils/notification";
export function useProfile() {
const { user } = useAppState();
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// Load profile data
const loadProfileData = useCallback(async () => {
if (!user.authToken) return;
setIsLoading(true);
try {
const data = await loadProfile(user.authToken);
if (data) {
setProfileData(data);
}
} catch (error) {
console.error('Error loading profile:', error);
showError('Ошибка при загрузке профиля');
} finally {
setIsLoading(false);
}
}, [user.authToken]);
// Update profile
const updateProfileData = useCallback(async (data: Partial<ProfileData>) => {
if (!user.authToken) return false;
setIsUpdating(true);
try {
const success = await updateProfile(user.authToken, data);
if (success) {
// Reload profile data to get updated information
await loadProfileData();
showSuccess('Профиль обновлен!');
return true;
} else {
showError('Ошибка при обновлении профиля');
return false;
}
} catch (error) {
console.error('Error updating profile:', error);
showError('Ошибка при обновлении профиля');
return false;
} finally {
setIsUpdating(false);
}
}, [user.authToken, loadProfileData]);
// Upload profile picture
const uploadProfilePictureData = useCallback(async (file: Blob) => {
if (!user.authToken) return false;
setIsUpdating(true);
try {
const result = await uploadProfilePicture(user.authToken, file);
if (result) {
// Update profile data with new picture URL
setProfileData(prev => prev ? {
...prev,
profile_picture: result.profile_picture_url
} : null);
showSuccess('Фото профиля обновлено!');
return true;
} else {
showError('Ошибка при загрузке фото');
return false;
}
} catch (error) {
console.error('Error uploading profile picture:', error);
showError('Ошибка при загрузке фото');
return false;
} finally {
setIsUpdating(false);
}
}, [user.authToken]);
// Load profile data when user is authenticated
useEffect(() => {
if (user.authToken) {
loadProfileData();
}
}, [user.authToken, loadProfileData]);
return {
profileData,
isLoading,
isUpdating,
loadProfileData,
updateProfileData,
uploadProfilePictureData
};
}
+182
View File
@@ -0,0 +1,182 @@
import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel";
import {
fetchDMHistory,
decryptDm,
sendDMViaWebSocket
} from "../../api/dmApi";
import type { Message } from "../../core/types";
import type { UserState } from "../state";
export interface DMPanelData {
userId: number;
username: string;
publicKey: string;
profilePicture?: string;
online: boolean;
}
export class DMPanel extends MessagePanel {
private dmData: DMPanelData | null = null;
private messagesLoaded: boolean = false;
constructor(
user: UserState,
callbacks: MessagePanelCallbacks,
onStateChange: (state: any) => void
) {
super("dm", user, callbacks, onStateChange);
}
isDm(): boolean {
return true;
}
async activate(): Promise<void> {
if (this.dmData && !this.messagesLoaded) {
await this.loadMessages();
}
}
deactivate(): void {
// DM doesn't need special cleanup
}
async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
this.setLoading(true);
try {
const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const text = await decryptDm(env, this.dmData!.publicKey);
const isAuthor = env.senderId !== this.dmData!.userId;
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
decryptedMessages.push({
id: env.id,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
console.error("Error decrypting message:", error);
}
}
this.clearMessages();
decryptedMessages.forEach(msg => this.addMessage(msg));
// Update last read ID
if (maxIncomingId > 0) {
this.setLastReadId(this.dmData.userId, maxIncomingId);
}
this.messagesLoaded = true;
} catch (error) {
console.error("Failed to load DM history:", error);
} finally {
this.setLoading(false);
}
}
async sendMessage(content: string): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try {
await sendDMViaWebSocket(
this.dmData.userId,
this.dmData.publicKey,
content,
this.currentUser.authToken
);
} catch (error) {
console.error("Failed to send DM:", error);
}
}
// Set DM conversation data
setDMData(dmData: DMPanelData): void {
this.dmData = dmData;
this.messagesLoaded = false;
this.updateState({
id: `dm-${dmData.userId}`,
title: dmData.username,
profilePicture: dmData.profilePicture,
online: dmData.online
});
}
// Handle incoming WebSocket DM messages
handleWebSocketMessage = async (response: any): Promise<void> => {
if (response.type === "dmNew" && this.dmData) {
const { senderId, recipientId, ...envelope } = response.data;
// If this is for the active DM conversation
if (senderId === this.dmData.userId || recipientId === this.dmData.userId) {
try {
const plaintext = await decryptDm(envelope, this.dmData.publicKey);
const isAuthor = senderId !== this.dmData.userId;
this.addMessage({
id: envelope.id,
content: plaintext,
username: isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData.username,
timestamp: envelope.timestamp,
is_read: false,
is_edited: false
});
// Update last read if it's from the other user
if (senderId === this.dmData.userId) {
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
}
} catch (error) {
console.error("Failed to decrypt incoming DM:", error);
}
}
}
};
// Reset for DM switching
reset(): void {
this.dmData = null;
this.messagesLoaded = false;
this.clearMessages();
this.updateState({
id: "dm",
title: "Select a user",
profilePicture: undefined,
online: false
});
}
// Update auth token
setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken;
}
// Helper functions for localStorage
private getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
private setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}
}
+135
View File
@@ -0,0 +1,135 @@
import type { Message } from "../../core/types";
import type { UserState } from "../state";
export interface MessagePanelState {
id: string;
title: string;
profilePicture?: string;
online: boolean;
messages: Message[];
isLoading: boolean;
isTyping: boolean;
}
export interface MessagePanelCallbacks {
onSendMessage: (content: string) => void;
onEditMessage: (messageId: number, content: string) => void;
onDeleteMessage: (messageId: number) => void;
onReplyToMessage: (messageId: number, content: string) => void;
onProfileClick: () => void;
}
export abstract class MessagePanel {
protected state: MessagePanelState;
protected callbacks: MessagePanelCallbacks;
public onStateChange: (state: MessagePanelState) => void;
protected currentUser: UserState;
constructor(
id: string,
currentUser: UserState,
callbacks: MessagePanelCallbacks,
onStateChange: (state: MessagePanelState) => void
) {
this.state = {
id,
title: "",
online: false,
messages: [],
isLoading: false,
isTyping: false
};
this.currentUser = currentUser;
this.callbacks = callbacks;
this.onStateChange = onStateChange;
}
// Abstract methods that must be implemented by subclasses
abstract activate(): Promise<void>;
abstract deactivate(): void;
abstract loadMessages(): Promise<void>;
abstract sendMessage(content: string): Promise<void>;
abstract isDm(): boolean;
// Optional WebSocket message handler (can be overridden by subclasses)
handleWebSocketMessage?: (response: any) => void;
// Common methods
protected updateState(updates: Partial<MessagePanelState>): void {
this.state = { ...this.state, ...updates };
this.onStateChange(this.state);
}
protected addMessage(message: Message): void {
const messageExists = this.state.messages.some(msg => msg.id === message.id);
if (!messageExists) {
this.updateState({
messages: [...this.state.messages, message]
});
}
}
protected updateMessage(messageId: number, updates: Partial<Message>): void {
this.updateState({
messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, ...updates } : msg
)
});
}
protected removeMessage(messageId: number): void {
this.updateState({
messages: this.state.messages.filter(msg => msg.id !== messageId)
});
}
protected clearMessages(): void {
this.updateState({ messages: [] });
}
protected setLoading(loading: boolean): void {
this.updateState({ isLoading: loading });
}
protected setTyping(typing: boolean): void {
this.updateState({ isTyping: typing });
}
// Getters
getState(): MessagePanelState {
return { ...this.state };
}
getId(): string {
return this.state.id;
}
getTitle(): string {
return this.state.title;
}
getMessages(): Message[] {
return [...this.state.messages];
}
// Event handlers
handleSendMessage = (content: string): void => {
this.sendMessage(content);
};
handleEditMessage = (messageId: number, content: string): void => {
this.callbacks.onEditMessage(messageId, content);
};
handleDeleteMessage = (messageId: number): void => {
this.callbacks.onDeleteMessage(messageId);
};
handleReplyToMessage = (messageId: number, content: string): void => {
this.callbacks.onReplyToMessage(messageId, content);
};
handleProfileClick = (): void => {
this.callbacks.onProfileClick();
};
}
+124
View File
@@ -0,0 +1,124 @@
import { MessagePanel, type MessagePanelCallbacks } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket";
import type { Message, WebSocketMessage } from "../../core/types";
import type { UserState } from "../state";
export class PublicChatPanel extends MessagePanel {
private messagesLoaded: boolean = false;
constructor(
chatName: string,
currentUser: UserState,
callbacks: MessagePanelCallbacks,
onStateChange: (state: any) => void
) {
super(`public-${chatName}`, currentUser, callbacks, onStateChange);
this.updateState({
title: chatName,
online: true // Public chats are always "online"
});
}
isDm(): boolean {
return false;
}
async activate(): Promise<void> {
if (!this.messagesLoaded) {
await this.loadMessages();
}
}
deactivate(): void {
// Public chat doesn't need special cleanup
}
async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || this.messagesLoaded) return;
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);
});
}
}
this.messagesLoaded = true;
} catch (error) {
console.error("Error loading public chat messages:", error);
} finally {
this.setLoading(false);
}
}
async sendMessage(content: string): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return;
try {
const response = await request({
data: { content: content.trim() },
credentials: {
scheme: "Bearer",
credentials: this.currentUser.authToken
},
type: "sendMessage"
});
if (response.error) {
console.error("Error sending message:", response.error);
}
} catch (error) {
console.error("Error sending message:", error);
}
}
// Handle incoming WebSocket messages
handleWebSocketMessage = (response: WebSocketMessage): void => {
switch (response.type) {
case 'messageEdited':
if (response.data) {
this.updateMessage(response.data.id, response.data);
}
break;
case 'messageDeleted':
if (response.data && response.data.message_id) {
this.removeMessage(response.data.message_id);
}
break;
case 'newMessage':
if (response.data) {
this.addMessage(response.data);
}
break;
}
};
// Reset for chat switching
reset(): void {
this.messagesLoaded = false;
this.clearMessages();
}
// Update chat name
setChatName(chatName: string): void {
this.updateState({
id: `public-${chatName}`,
title: chatName
});
}
// Update auth token
setAuthToken(authToken: string): void {
this.currentUser.authToken = authToken;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { LeftPanel } from "../components/chat/LeftPanel";
import { RightPanel } from "../components/chat/RightPanel";
export default function ChatScreen() {
return (
<div id="chat-interface">
<div className="all-container">
<LeftPanel />
<RightPanel />
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useImmer } from "use-immer";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
import { AuthContainer, AuthHeader } from "../components/Auth";
import type { ErrorResponse, LoginRequest, LoginResponse } from "../../core/types";
import { ensureKeysOnLogin } from "../../auth/crypto";
import { API_BASE_URL } from "../../core/config";
// import { initializeProfile } from "../../userPanel/profile/profile";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../state";
import { MaterialTextField } from "../components/core/TextField";
export default function LoginScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setCurrentPage = useAppState(state => state.setCurrentPage);
const setUser = useAppState(state => state.setUser);
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
return (
<AuthContainer>
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form
onSubmit={async (e) => {
e.preventDefault();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
if (!username || !password) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
try {
const request: LoginRequest = {
username: username,
password: password
}
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();
// Store the JWT token first
setUser(data.token, data.user);
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
} catch (e) {
console.error("Key setup failed:", e);
}
setCurrentPage("chat");
// initializeProfile(); // Initialize profile after login
} else {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
}
} catch (error) {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
<MaterialTextField
label="Имя пользователя"
id="login-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="login-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="current-password"
required
ref={passwordElement} />
<mdui-button type="submit">Войти</mdui-button>
</form>
<div className="text-center">
<p>
Ещё нет аккаунта?
<a
href="#"
className="link"
onClick={() => setCurrentPage("register")}>
Зарегистрируйтесь
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { useImmer } from "use-immer";
// import { showLogin } from "../../navigation";
import { AuthContainer, AuthHeader } from "../components/Auth";
import { AlertsContainer, type Alert, type AlertType } from "../components/Alerts";
import { useRef } from "react";
import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest } from "../../core/types";
import { API_BASE_URL } from "../../core/config";
import { delay } from "../../utils/utils";
import { useAppState } from "../state";
import { MaterialTextField } from "../components/core/TextField";
export default function RegisterScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
const setCurrentPage = useAppState(state => state.setCurrentPage);
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
const confirmPasswordElement = useRef<TextField>(null);
return (
<AuthContainer>
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
<div className="auth-body">
<AlertsContainer alerts={alerts} />
<form onSubmit={async (e) => {
e.preventDefault();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
const confirmPassword = confirmPasswordElement.current!.value.trim();
if (!username || !password || !confirmPassword) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
if (password !== confirmPassword) {
showAlert("danger", "Пароли не совпадают");
return;
}
if (username.length < 3 || username.length > 20) {
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
return;
}
if (password.length < 5 || password.length > 50) {
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
return;
}
try {
const request: RegisterRequest = {
username: username,
password: password,
confirm_password: confirmPassword
}
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request)
});
if (response.ok) {
// Registration successful
showAlert("success", "Регистрация прошла успешно! Теперь вы можете войти.");
await delay(2000);
setCurrentPage("login");
} else {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Ошибка при регистрации");
}
} catch (error) {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
<MaterialTextField
label="Имя пользователя"
id="register-username"
name="username"
variant="outlined"
icon="person--filled"
autocomplete="username"
maxlength={20}
counter
required
ref={usernameElement} />
<MaterialTextField
label="Пароль"
id="register-password"
name="password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={passwordElement} />
<MaterialTextField
label="Подтвердите пароль"
id="register-confirm-password"
name="confirm_password"
variant="outlined"
type="password"
toggle-password
icon="password--filled"
autocomplete="new-password"
required
ref={confirmPasswordElement} />
<mdui-button type="submit">Зарегистрироваться</mdui-button>
</form>
<div className="text-center">
<p>
Уже есть аккаунт?
<a
href="#"
id="login-link"
className="link"
onClick={() => setCurrentPage("login")}>
Войдите
</a>
</p>
</div>
</div>
</AuthContainer>
)
}
+346
View File
@@ -0,0 +1,346 @@
import { create } from "zustand";
import type { Message, User, WebSocketMessage } from "../core/types";
import { request } from "../core/websocket";
import { MessagePanel } from "./panels/MessagePanel";
import { PublicChatPanel } from "./panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
type Page = "login" | "register" | "chat"
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
interface ActiveDM {
userId: number;
username: string;
publicKey: string | null
}
interface ChatState {
messages: Message[];
currentChat: string;
activeTab: ChatTabs;
dmUsers: User[];
activeDm: ActiveDM | null;
isChatSwitching: boolean;
activePanel: MessagePanel | null;
publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null;
}
export interface UserState {
currentUser: User | null;
authToken: string | null;
}
interface AppState {
currentPage: Page;
setCurrentPage: (page: Page) => void;
// 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;
setIsChatSwitching: (value: boolean) => void;
setActivePanel: (panel: MessagePanel | null) => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
switchToTab: (tab: ChatTabs) => Promise<void>;
// User state
user: UserState;
setUser: (token: string, user: User) => void;
logout: () => void;
restoreUserFromStorage: () => void;
}
export const useAppState = create<AppState>((set, get) => ({
currentPage: "login", // default page
setCurrentPage: (page: Page) => set({ currentPage: page }),
// Chat state
chat: {
messages: [],
currentChat: "Общий чат",
activeTab: "chats",
dmUsers: [],
activeDm: null,
isChatSwitching: false,
activePanel: null,
publicChatPanel: null,
dmPanel: null
},
setIsChatSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isChatSwitching: value
}
})),
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
},
setUser: (token: string, user: User) => {
set(() => ({
user: {
currentUser: user,
authToken: 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 {
const payload: WebSocketMessage = {
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
}
request(payload).then(() => {
console.log("Ping succeeded")
})
} catch {}
},
logout: () => {
// Clear localStorage
try {
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
} catch (error) {
console.error('Failed to clear localStorage:', error);
}
set(() => ({
user: {
currentUser: null,
authToken: null
},
currentPage: "login"
}));
},
restoreUserFromStorage: () => {
try {
const token = localStorage.getItem('authToken');
const userStr = localStorage.getItem('currentUser');
if (token && userStr) {
const user = JSON.parse(userStr) as User;
set(() => ({
user: {
currentUser: user,
authToken: token
},
currentPage: "chat"
}));
}
} 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) => set((state) => ({
chat: {
...state.chat,
activePanel: panel
}
})),
switchToPublicChat: async (chatName: string) => {
const state = get();
const { user, chat } = state;
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(true);
// Create or get public chat panel
let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) {
const callbacks = {
onSendMessage: (_content: string) => {},
onEditMessage: (_messageId: number, _content: string) => {},
onDeleteMessage: (_messageId: number) => {},
onReplyToMessage: (_messageId: number, _content: string) => {},
onProfileClick: () => {}
};
publicChatPanel = new PublicChatPanel(
chatName,
user,
callbacks,
() => {} // State change handled by MessagePanelRenderer
);
} else {
publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken);
}
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel
await publicChatPanel.activate();
// Update state
set((state) => ({
chat: {
...state.chat,
activePanel: publicChatPanel,
publicChatPanel: publicChatPanel,
currentChat: chatName,
activeTab: "chats"
}
}));
// End animation
state.setIsChatSwitching(false);
},
switchToDM: async (dmData: DMPanelData) => {
const state = get();
const { user, chat } = state;
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(true);
// Create or get DM panel
let dmPanel = chat.dmPanel;
if (!dmPanel) {
const callbacks = {
onSendMessage: (_content: string) => {},
onEditMessage: (_messageId: number, _content: string) => {},
onDeleteMessage: (_messageId: number) => {},
onReplyToMessage: (_messageId: number, _content: string) => {},
onProfileClick: () => {}
};
dmPanel = new DMPanel(
user,
callbacks,
() => {} // State change handled by MessagePanelRenderer
);
} else {
dmPanel.setAuthToken(user.authToken);
}
// Set DM data
dmPanel.setDMData(dmData);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
// Activate panel
await dmPanel.activate();
// Update state
set((state) => ({
chat: {
...state.chat,
activePanel: dmPanel,
dmPanel: dmPanel,
activeDm: {
userId: dmData.userId,
username: dmData.username,
publicKey: dmData.publicKey
},
activeTab: "dms"
}
}));
// End animation
state.setIsChatSwitching(false);
},
switchToTab: async (tab: ChatTabs) => {
const state = get();
state.setActiveTab(tab);
if (tab === "chats") {
await state.switchToPublicChat("Общий чат");
} else if (tab === "dms") {
// DM tab - no specific panel until user is selected
state.setActivePanel(null);
}
}
}));
-125
View File
@@ -1,125 +0,0 @@
/**
* @fileoverview Profile-related API calls
* @description Handles all profile-related HTTP requests to the backend
* @author Cursor
* @version 1.0.0
*/
import { getAuthHeaders } from '../../auth/api';
import type { ProfileData, UploadResponse } from './types';
/**
* Loads user profile data from the server
* @async
* @returns User profile data or null if failed
* @example
* const profile = await loadProfile();
* if (profile) {
* console.log('User nickname:', profile.nickname);
* }
*/
export async function loadProfile(): Promise<ProfileData | null> {
try {
const response = await fetch('/api/user/profile', {
headers: getAuthHeaders()
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error loading profile:', error);
return null;
}
}
/**
* Uploads a profile picture to the server
* @param {Blob} file - The image file to upload
* @returns {Promise<UploadResponse | null>} Upload response with URL or null if failed
* @example
* const fileInput = document.getElementById('file-input');
* const file = fileInput.files[0];
* const result = await uploadProfilePicture(file);
* if (result) {
* console.log('Uploaded to:', result.profile_picture_url);
* }
*/
export async function uploadProfilePicture(file: Blob): Promise<UploadResponse | null> {
try {
const formData = new FormData();
formData.append('profile_picture', file, 'profile_picture.jpg');
const response = await fetch('/api/upload-profile-picture', {
method: 'POST',
body: formData,
headers: getAuthHeaders(false)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Upload error:', error);
return null;
}
}
/**
* Updates user profile information
* @param {Partial<ProfileData>} data - Profile data to update
* @returns {Promise<boolean>} True if update was successful, false otherwise
* @example
* const success = await updateProfile({
* nickname: 'New Name',
* description: 'Updated bio'
* });
* if (success) {
* console.log('Profile updated successfully');
* }
*/
export async function updateProfile(data: Partial<ProfileData>): Promise<boolean> {
try {
const response = await fetch('/api/user/profile', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
});
return response.ok;
} catch (error) {
console.error('Error updating profile:', error);
return false;
}
}
/**
* Updates user bio
* @param {string} bio - New bio text
* @returns {Promise<boolean>} True if update was successful, false otherwise
* @example
* const success = await updateBio('My new bio text');
* if (success) {
* console.log('Bio updated successfully');
* }
*/
export async function updateBio(bio: string): Promise<boolean> {
try {
const response = await fetch('/api/user/bio', {
method: 'PUT',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ bio })
});
return response.ok;
} catch (error) {
console.error('Error updating bio:', error);
return false;
}
}
-122
View File
@@ -1,122 +0,0 @@
/**
* @fileoverview Profile editing functionality
* @description Handles profile form editing and MDUI text field integration
* @author Cursor
* @version 1.0.0
*/
import { updateProfile } from './api';
import { loadProfile } from './api';
import { showSuccess, showError } from '../../utils/notification';
import { TextField } from 'mdui/components/text-field';
import { id } from '../../utils/utils';
let profileForm = id('profile-form')!;
let nicknameField = id<TextField>('username-field');
let descriptionField = id<TextField>('description-field');
/**
* Initialization state flag
* @type {boolean}
*/
let isInitialized = false;
/**
* Sets the username field value
* @param {string} value - The username value to set
*/
export function setUsernameValue(value: string): void {
if (nicknameField && nicknameField.value !== undefined) {
nicknameField.value = value;
}
}
/**
* Sets the description field value
* @param {string} value - The description value to set
*/
export function setDescriptionValue(value: string): void {
if (descriptionField && descriptionField.value !== undefined) {
descriptionField.value = value;
}
}
/**
* Gets the current username field value
* @returns {string} The current username value
*/
export function getUsernameValue(): string {
if (nicknameField && nicknameField.value !== undefined) {
return nicknameField.value;
}
return '';
}
/**
* Gets the current description field value
* @returns {string} The current description value
*/
export function getDescriptionValue(): string {
if (descriptionField && descriptionField.value !== undefined) {
return descriptionField.value;
}
return '';
}
/**
* Loads profile data from the server and populates the form fields
*/
export async function loadProfileData(): Promise<void> {
const userData = await loadProfile();
if (userData) {
if (userData.nickname) {
setUsernameValue(userData.nickname);
}
if (userData.description) {
setDescriptionValue(userData.description);
}
}
}
/**
* Handles profile form submission
* @param {Event} e - Form submission event
* @private
*/
async function handleFormSubmission(e: Event): Promise<void> {
e.preventDefault();
const nickname = getUsernameValue();
const description = getDescriptionValue();
if (nickname || description) {
const success = await updateProfile({
nickname: nickname || undefined,
description: description || undefined
});
if (success) {
showSuccess('Профиль обновлен!');
} else {
showError('Ошибка при обновлении профиля');
}
}
}
/**
* Sets up form submission handler
* @private
*/
function setupFormHandler(): void {
if (!isInitialized) {
profileForm.addEventListener('submit', handleFormSubmission);
isInitialized = true;
}
}
/**
* Initializes profile editor functionality
*/
export function initializeProfileEditor(): void {
setupFormHandler();
}
@@ -1,219 +0,0 @@
/**
* @fileoverview Canvas-based image cropping component
* @description Provides circular image cropping functionality with drag support
* @author Cursor
* @version 1.0.0
*/
import type { Size2D } from "../../core/types";
/**
* Image cropper class for circular profile picture cropping
* @class ImageCropper
*/
export class ImageCropper {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
/**
* Image element to be cropped
* @type {HTMLImageElement}
* @private
*/
private image!: HTMLImageElement;
/**
* Size of the crop area (diameter)
* @type {number}
* @private
*/
private cropSize: number = 200;
private isDragging: boolean = false;
/**
* Starting position of the drag operation
* @type {Size2D}
* @private
*/
private dragStart: Size2D = { x: 0, y: 0 };
/**
* Current position of the crop area
* @type {Size2D}
* @private
*/
private cropPosition: Size2D = { x: 0, y: 0 };
/**
* Creates a new ImageCropper instance
* @param {HTMLElement} container - Container element to append the canvas to
* @constructor
* @example
* const cropper = new ImageCropper(document.getElementById('cropper-area'));
*/
constructor(container: HTMLElement) {
this.canvas = document.createElement('canvas');
this.canvas.width = this.cropSize;
this.canvas.height = this.cropSize;
this.ctx = this.canvas.getContext('2d')!;
container.appendChild(this.canvas);
this.setupEventListeners();
}
/**
* Sets up mouse and touch event listeners
* @private
*/
private setupEventListeners(): void {
this.canvas.addEventListener('mousedown', this.onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.onMouseUp.bind(this));
this.canvas.addEventListener('touchstart', this.onTouchStart.bind(this));
this.canvas.addEventListener('touchmove', this.onTouchMove.bind(this));
this.canvas.addEventListener('touchend', this.onTouchEnd.bind(this));
}
/**
* Handles mouse down events
* @param {MouseEvent} e - Mouse event
* @private
*/
private onMouseDown(e: MouseEvent): void {
this.isDragging = true;
this.dragStart = { x: e.clientX, y: e.clientY };
}
/**
* Handles mouse move events during dragging
* @param {MouseEvent} e - Mouse event
* @private
*/
private onMouseMove(e: MouseEvent): void {
if (!this.isDragging) return;
const deltaX = e.clientX - this.dragStart.x;
const deltaY = e.clientY - this.dragStart.y;
this.cropPosition.x += deltaX;
this.cropPosition.y += deltaY;
this.dragStart = { x: e.clientX, y: e.clientY };
this.render();
}
/**
* Handles mouse up events
* @private
*/
private onMouseUp(): void {
this.isDragging = false;
}
/**
* Handles touch start events
* @param {TouchEvent} e - Touch event
* @private
*/
private onTouchStart(e: TouchEvent): void {
e.preventDefault();
const touch = e.touches[0];
this.isDragging = true;
this.dragStart = { x: touch.clientX, y: touch.clientY };
}
/**
* Handles touch move events during dragging
* @param {TouchEvent} e - Touch event
* @private
*/
private onTouchMove(e: TouchEvent): void {
e.preventDefault();
if (!this.isDragging) return;
const touch = e.touches[0];
const deltaX = touch.clientX - this.dragStart.x;
const deltaY = touch.clientY - this.dragStart.y;
this.cropPosition.x += deltaX;
this.cropPosition.y += deltaY;
this.dragStart = { x: touch.clientX, y: touch.clientY };
this.render();
}
/**
* Handles touch end events
* @private
*/
private onTouchEnd(): void {
this.isDragging = false;
}
/**
* Loads an image file for cropping
* @param {File} file - Image file to load
* @returns {Promise<void>} Promise that resolves when image is loaded
*/
loadImage(file: File): Promise<void> {
return new Promise((resolve) => {
this.image = new Image();
this.image.onload = () => {
this.render();
resolve();
};
this.image.src = URL.createObjectURL(file);
});
}
/**
* Renders the image with circular crop overlay
* @private
*/
private render(): void {
if (!this.image) return;
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Calculate crop area
const scale = Math.max(this.cropSize / this.image.width, this.cropSize / this.image.height);
const scaledWidth = this.image.width * scale;
const scaledHeight = this.image.height * scale;
// Draw image
this.ctx.save();
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.drawImage(
this.image,
this.cropPosition.x,
this.cropPosition.y,
scaledWidth,
scaledHeight
);
this.ctx.restore();
// Draw crop overlay
this.ctx.save();
this.ctx.globalCompositeOperation = 'destination-in';
this.ctx.beginPath();
this.ctx.arc(this.cropSize / 2, this.cropSize / 2, this.cropSize / 2, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.restore();
}
/**
* Gets the cropped image as a data URL
* @returns {string} Data URL of the cropped image
*/
getCroppedImage(): string {
return this.canvas.toDataURL('image/jpeg', 0.8);
}
/**
* Destroys the cropper and removes the canvas from DOM
*/
destroy(): void {
this.canvas.remove();
}
}
-41
View File
@@ -1,41 +0,0 @@
/**
* @fileoverview Profile module entry point and initialization
* @description Coordinates profile system initialization and form handling
* @author Cursor
* @version 1.0.0
*/
import type { Dialog } from "mdui/components/dialog";
import { loadProfileData } from './editor';
import { loadProfilePicture, initializeProfileUpload } from "./upload";
import { initializeProfileEditor } from './editor';
import { id } from "../../utils/utils";
// Handle profile form submission
const form = id("profile-form")!;
const dialog = id<Dialog>("profile-dialog");
form.addEventListener("submit", async (e) => {
e.preventDefault();
// TODO: Process form data if needed
// For now, just close the dialog
dialog.open = false;
});
/**
* Initializes profile functionality after user login
*/
export function initializeProfile(): void {
// Initialize profile modules
initializeProfileUpload();
initializeProfileEditor();
// Load profile data
Promise.all([
loadProfilePicture(),
loadProfileData()
]).catch(error => {
console.error('Error initializing profile:', error);
});
}
-28
View File
@@ -1,28 +0,0 @@
/**
* @fileoverview Profile-specific type definitions
* @description Contains type definitions for profile-related functionality
* @author Cursor
* @version 1.0.0
*/
/**
* User profile data structure
* @interface ProfileData
* @property {string} [profile_picture] - URL to user's profile picture
* @property {string} [nickname] - User's display name
* @property {string} [description] - User's bio or description
*/
export interface ProfileData {
profile_picture?: string;
nickname?: string;
description?: string;
}
/**
* Profile picture upload response structure
* @interface UploadResponse
* @property {string} profile_picture_url - URL to the uploaded profile picture
*/
export interface UploadResponse {
profile_picture_url: string;
}
-150
View File
@@ -1,150 +0,0 @@
/**
* @fileoverview Profile picture upload functionality
* @description Handles file selection, image cropping, and profile picture upload
* @author Cursor
* @version 1.0.0
*/
import type { Dialog } from "mdui/components/dialog";
import { ImageCropper } from './imageCropper';
import { uploadProfilePicture } from './api';
import { loadProfile } from './api';
import { showSuccess, showError } from '../../utils/notification';
import { id } from "../../utils/utils";
/**
* Global image cropper instance
*/
let cropper: ImageCropper | null = null;
/**
* Initialization state flag
*/
let isInitialized = false;
let cropperDialog = id<Dialog>('cropper-dialog');
let fileInput = id<HTMLInputElement>('pfp-file-input');
let uploadBtn = id('upload-pfp-btn');
let cropSaveBtn = id('crop-save');
let cropCancelBtn = id('crop-cancel');
let cropperCloseBtn = id('cropper-close');
let cropperArea = id('cropper-area');
/**
* Opens the image cropper with the selected file
* @param {File} file - The image file to crop
* @private
*/
async function openCropper(file: File): Promise<void> {
// Clear previous cropper
cropperArea.innerHTML = '';
// Create new cropper
cropper = new ImageCropper(cropperArea);
// Load image
await cropper.loadImage(file);
// Open dialog
cropperDialog.open = true;
}
/**
* Closes the image cropper and cleans up resources
* @private
*/
function closeCropper(): void {
cropperDialog.open = false;
cropperArea.innerHTML = '';
if (cropper) {
cropper.destroy();
cropper = null;
}
fileInput.value = '';
}
/**
* Saves the cropped image and uploads it to the server
* @private
*/
async function saveCroppedImage(): Promise<void> {
if (!cropper) return;
const croppedImageData = cropper.getCroppedImage();
// Convert data URL to blob
const response = await fetch(croppedImageData);
const blob = await response.blob();
const result = await uploadProfilePicture(blob);
if (result) {
// Update profile picture display
const profilePicture = id<HTMLInputElement>('profile-picture');
profilePicture.src = `${result.profile_picture_url}?t=${Date.now()}`; // Cache bust
// Close cropper
closeCropper();
// Show success message
showSuccess('Фото профиля обновлено!');
} else {
showError('Ошибка при загрузке фото');
}
}
/**
* Sets up event listeners for upload functionality
* @private
*/
function setupEventListeners(): void {
if (isInitialized) return;
uploadBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
openCropper(file);
}
});
cropSaveBtn.addEventListener('click', () => {
saveCroppedImage();
});
cropCancelBtn.addEventListener('click', () => {
closeCropper();
});
cropperCloseBtn.addEventListener('click', () => {
closeCropper();
});
isInitialized = true;
}
/**
* Loads and displays the user's profile picture
* @async
*/
export async function loadProfilePicture(): Promise<void> {
const userData = await loadProfile();
if (userData?.profile_picture) {
const url = `${userData.profile_picture}?t=${Date.now()}`;
const profilePicture = id<HTMLImageElement>('profile-picture');
const profilePicture2 = id<HTMLImageElement>("preview1");
profilePicture.src = url;
profilePicture2.src = url;
}
}
/**
* Initializes profile upload functionality
*/
export function initializeProfileUpload(): void {
setupEventListeners();
}
-95
View File
@@ -1,95 +0,0 @@
/**
* @fileoverview Settings dialog management and panel navigation
* @description Handles settings dialog functionality and dynamic panel switching
* @author Cursor
* @version 1.0.0
*/
import type { Dialog } from "mdui/components/dialog";
import { id } from "../utils/utils";
const dialog = id<Dialog>('settings-dialog');
const openButton = id('settings-open');
const closeButton = id('settings-close');
// Settings panel management
const settingsList = document.querySelector('#settings-menu mdui-list')!;
const settingsPanels = document.querySelectorAll('.settings-panel');
/**
* Mapping between list item text and their corresponding panel IDs
*/
const panelMapping: {[x: string]: string} = {
'Уведомления': 'notifications-settings',
'Внешний вид': 'appearance-settings',
'Безопасность': 'security-settings',
'Язык': 'language-settings',
'Хранилище': 'storage-settings',
'Помощь': 'help-settings',
'О приложении': 'about-settings'
};
/**
* Handles click events on settings list items
* @param {Element} item - The clicked list item element
* @private
*/
function handleListItemClick(item: Element): void {
// Remove active class from all items and panels
const listItems = settingsList.querySelectorAll('mdui-list-item');
listItems.forEach(li => li.removeAttribute('active'));
settingsPanels.forEach(panel => panel.classList.remove('active'));
// Add active class to clicked item
item.setAttribute('active', '');
// Show corresponding panel using the mapping
const itemText = item.textContent!.trim();
const panelId = panelMapping[itemText];
if (panelId) {
const targetPanel = id(panelId);
if (targetPanel) {
targetPanel.classList.add('active');
}
}
}
/**
* Resets settings dialog to show the first panel
* @private
*/
function resetToFirstPanel(): void {
const firstItem = settingsList.querySelector('mdui-list-item');
const firstPanel = document.querySelector('.settings-panel');
if (firstItem && firstPanel) {
settingsList.querySelectorAll('mdui-list-item').forEach(li => li.removeAttribute('active'));
settingsPanels.forEach(panel => panel.classList.remove('active'));
firstItem.setAttribute('active', '');
firstPanel.classList.add('active');
}
}
/**
* Initializes settings.
* @private
*/
function init() {
// Set up settings navigation
settingsList.querySelectorAll('mdui-list-item').forEach((item) => {
item.addEventListener('click', () => handleListItemClick(item));
});
// Set up dialog listeners
openButton.addEventListener('click', () => {
dialog.open = true;
resetToFirstPanel();
});
closeButton.addEventListener('click', () => {
dialog.open = false;
});
}
init();
-107
View File
@@ -1,107 +0,0 @@
/**
* @fileoverview Left panel UI controls and interactions
* @description Handles chat collapse/expand, chat switching, and profile dialog
* @author Cursor
* @version 1.0.0
*/
import { Dialog } from "mdui/components/dialog";
import { loadProfilePicture } from "./profile/upload";
import { id } from "../utils/utils";
import { publicChatPanel } from "../chat/chat";
// сварачивание и разворачивание чата
const chatCollapseBtn = id('hide-chat')!;
const chat1 = id('chat-list-chat-1')!;
const chat2 = id('chat-list-chat-2')!;
const chatInner = id('chat-inner')!;
const chatContainer = document.querySelector('#chat-interface .chat-container') as HTMLElement;
const chatName = id('chat-name')!;
const profileButton = id('profile-open')!;
const dialog = id<Dialog>("profile-dialog");
const dialogClose = id("profile-dialog-close")!;
/**
* Sets up chat collapse functionality
* @private
*/
function setupChatCollapse(): void {
chatCollapseBtn.addEventListener('click', () => {
chatCollapseBtn.style.display = 'none';
chatInner.style.display = 'none';
});
}
/**
* Sets up chat switching functionality
* @private
*/
function animateChatSwitch(updateFn: () => void): Promise<void> {
return new Promise((resolve, reject) => {
// Ensure panel is visible
chatCollapseBtn.style.display = 'flex';
chatInner.style.display = 'flex';
// Start out animation
if (!chatContainer) {
reject("Chat container is missing");
return;
}
chatContainer.classList.remove('chat-switch-in');
chatContainer.classList.add('chat-switch-out');
const onOutEnd = () => {
chatContainer.removeEventListener('animationend', onOutEnd);
// Update content while hidden
updateFn();
// Then play in animation from the same offset
chatContainer.classList.remove('chat-switch-out');
chatContainer.classList.add('chat-switch-in');
const onInEnd = () => {
chatContainer.removeEventListener("animationend", onInEnd);
resolve();
}
chatContainer.addEventListener("animationend", onInEnd);
};
chatContainer.addEventListener('animationend', onOutEnd);
});
}
function setupChatSwitching(): void {
chat1.addEventListener('click', () => {
animateChatSwitch(() => {
chatName.textContent = 'Общий чат';
publicChatPanel.activate();
});
});
chat2.addEventListener('click', () => {
animateChatSwitch(() => {
chatName.textContent = 'Общий чат 2';
publicChatPanel.activate();
});
});
}
/**
* Sets up profile dialog functionality
* @private
*/
function setupProfileDialog(): void {
profileButton.addEventListener('click', () => {
dialog.open = true;
loadProfilePicture();
});
dialogClose.addEventListener("click", () => {
dialog.open = false;
});
}
setupChatCollapse();
setupChatSwitching();
setupProfileDialog();
-3
View File
@@ -19,9 +19,6 @@ import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title';
import 'mdui/components/dropdown.js';
import 'mdui/components/menu.js';
import 'mdui/components/menu-item.js';
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
+2 -1
View File
@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />
/// <reference types="mdui/jsx.en.d.ts" />
-76
View File
@@ -1,76 +0,0 @@
/**
* @fileoverview WebSocket connection management for real-time chat
* @description Handles WebSocket connections, message processing, and auto-reconnection
* @author Cursor
* @version 1.0.0
*/
import { handleWebSocketMessage } from "./chat/chat";
import { API_WS_BASE_URL } from "./core/config";
import type { WebSocketMessage } from "./core/types";
import { delay } from "./utils/utils";
/**
* Creates a new WebSocket connection to the chat server
* @returns {WebSocket} New WebSocket instance
* @private
*/
function create(): WebSocket {
let prefix = "ws://";
if (location.protocol.includes("https")) {
prefix = "wss://";
}
return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`);
}
/**
* Global WebSocket instance
* @type {WebSocket}
*/
export let websocket: WebSocket = create();
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> {
return new Promise((resolve, reject) => {
let listener: ((e: MessageEvent) => void) | null = null;
listener = (e) => {
resolve(JSON.parse(e.data));
websocket.removeEventListener("message", listener!);
}
websocket.addEventListener("message", listener);
websocket.send(JSON.stringify(payload))
setTimeout(() => reject("Request timed out"), 10000);
})
}
/**
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
* If it fails, tries again in an endless loop until the connection is established
* again.
*
* @private
*/
async function onError() {
console.warn("WebSocket disconnected, retrying in 3 seconds...");
await delay(3000);
websocket = create();
let listener: () => void | null;
listener = () => {
console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener);
}
websocket.addEventListener("open", listener);
websocket.addEventListener("error", onError);
}
// --------------
// Initialization
// --------------
websocket.addEventListener("message", (e) => {
handleWebSocketMessage(JSON.parse(e.data));
});
websocket.addEventListener("error", onError);
+9 -2
View File
@@ -17,7 +17,14 @@
"strict": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
/* React JSX Support */
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src", "electron.d.ts"]
"include": ["src", "electron.d.ts"],
"exclude": ["**/__*/**", "__*"]
}
+2 -25
View File
@@ -2,33 +2,10 @@ import { defineConfig, PluginOption } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
import autoprefixer from "autoprefixer";
import electron from "vite-plugin-electron/simple";
function hmrAutoAcceptPlugin(): PluginOption {
const injectionLine = "if(import.meta.hot){import.meta.hot.accept()}";
const fileRegex = /\.(m?jsx?|tsx?)$/;
return {
name: "hmr-auto-accept",
apply: "serve",
enforce: "post",
transform(code, id) {
if (!fileRegex.test(id)) return null;
if (id.endsWith(".d.ts")) return null;
if (id.includes("node_modules")) return null;
if (id.startsWith("\0") || id.includes("virtual:") || id.includes("/@vite/")) return null;
if (code.includes("import.meta.hot.accept()")) return null;
if (!code.endsWith("\n")) {
code += "\n";
}
code += injectionLine;
return { code: code, map: null };
},
};
}
import react from "@vitejs/plugin-react";
const plugins: PluginOption[] = [
hmrAutoAcceptPlugin(),
react(),
createHtmlPlugin({
minify: {
collapseWhitespace: true,
+8 -1
View File
@@ -29,6 +29,9 @@
"prepare": "husky"
},
"devDependencies": {
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.0",
"dotenv-cli": "^10.0.0",
@@ -45,6 +48,10 @@
},
"dependencies": {
"mdui": "^2.1.4",
"tweetnacl": "^1.0.3"
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tweetnacl": "^1.0.3",
"use-immer": "^0.11.0",
"zustand": "^5.0.8"
}
}