mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-24 12:05:05 +03:00
Merge branch 'feature/react'
This commit is contained in:
@@ -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.
|
||||
Vendored
+61
-11
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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 {}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
Vendored
+13
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="mdui/jsx.en.d.ts" />
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user