mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement usernames and profile sections
This commit is contained in:
@@ -13,6 +13,7 @@ class User(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
display_name = Column(String(64), nullable=False)
|
||||||
password_hash = Column(String(200), nullable=False)
|
password_hash = Column(String(200), nullable=False)
|
||||||
profile_picture = Column(String(255), nullable=True)
|
profile_picture = Column(String(255), nullable=True)
|
||||||
bio = Column(Text, nullable=True)
|
bio = Column(Text, nullable=True)
|
||||||
@@ -149,6 +150,7 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
|
display_name: str
|
||||||
password: str
|
password: str
|
||||||
confirm_password: str
|
confirm_password: str
|
||||||
|
|
||||||
@@ -178,6 +180,7 @@ class PushSubscriptionRequest(BaseModel):
|
|||||||
class UserProfileResponse(BaseModel):
|
class UserProfileResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
|
display_name: str
|
||||||
profile_picture: str | None
|
profile_picture: str | None
|
||||||
bio: str | None
|
bio: str | None
|
||||||
online: bool
|
online: bool
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from constants import OWNER_USERNAME
|
|||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
||||||
from utils import create_token, get_password_hash, verify_password
|
from utils import create_token, get_password_hash, verify_password
|
||||||
from validation import is_valid_password, is_valid_username
|
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ def convert_user(user: User) -> dict:
|
|||||||
"last_seen": user.last_seen.isoformat(),
|
"last_seen": user.last_seen.isoformat(),
|
||||||
"online": user.online,
|
"online": user.online,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
|
"display_name": user.display_name,
|
||||||
"profile_picture": user.profile_picture,
|
"profile_picture": user.profile_picture,
|
||||||
"bio": user.bio,
|
"bio": user.bio,
|
||||||
"admin": user.username == OWNER_USERNAME
|
"admin": user.username == OWNER_USERNAME
|
||||||
@@ -58,6 +59,7 @@ def login(request: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||||
username = request.username.strip()
|
username = request.username.strip()
|
||||||
|
display_name = request.display_name.strip()
|
||||||
password = request.password.strip()
|
password = request.password.strip()
|
||||||
confirm_password = request.confirm_password.strip()
|
confirm_password = request.confirm_password.strip()
|
||||||
|
|
||||||
@@ -75,7 +77,13 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
if not is_valid_username(username):
|
if not is_valid_username(username):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов"
|
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid_display_name(display_name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_valid_password(password):
|
if not is_valid_password(password):
|
||||||
@@ -107,6 +115,7 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
hashed_password = get_password_hash(password)
|
hashed_password = get_password_hash(password)
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=username,
|
username=username,
|
||||||
|
display_name=display_name,
|
||||||
password_hash=hashed_password,
|
password_hash=hashed_password,
|
||||||
online=True,
|
online=True,
|
||||||
last_seen=datetime.now()
|
last_seen=datetime.now()
|
||||||
|
|||||||
@@ -48,16 +48,17 @@ def convert_message(msg: Message) -> dict:
|
|||||||
reactions_dict[emoji]["count"] += 1
|
reactions_dict[emoji]["count"] += 1
|
||||||
reactions_dict[emoji]["users"].append({
|
reactions_dict[emoji]["users"].append({
|
||||||
"id": reaction.user_id,
|
"id": reaction.user_id,
|
||||||
"username": reaction.user.username
|
"username": reaction.user.display_name
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": msg.id,
|
"id": msg.id,
|
||||||
|
"user_id": msg.author.id,
|
||||||
"content": msg.content,
|
"content": msg.content,
|
||||||
"timestamp": msg.timestamp.isoformat(),
|
"timestamp": msg.timestamp.isoformat(),
|
||||||
"is_read": msg.is_read,
|
"is_read": msg.is_read,
|
||||||
"is_edited": msg.is_edited,
|
"is_edited": msg.is_edited,
|
||||||
"username": msg.author.username,
|
"username": msg.author.display_name,
|
||||||
"profile_picture": msg.author.profile_picture,
|
"profile_picture": msg.author.profile_picture,
|
||||||
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
||||||
"reactions": list(reactions_dict.values()),
|
"reactions": list(reactions_dict.values()),
|
||||||
@@ -88,7 +89,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
|||||||
reactions_dict[emoji]["count"] += 1
|
reactions_dict[emoji]["count"] += 1
|
||||||
reactions_dict[emoji]["users"].append({
|
reactions_dict[emoji]["users"].append({
|
||||||
"id": reaction.user_id,
|
"id": reaction.user_id,
|
||||||
"username": reaction.user.username
|
"username": reaction.user.display_name
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+53
-10
@@ -11,12 +11,14 @@ import io
|
|||||||
from dependencies import get_db, get_current_user
|
from dependencies import get_db, get_current_user
|
||||||
from models import User, UpdateBioRequest, UserProfileResponse
|
from models import User, UpdateBioRequest, UserProfileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from validation import is_valid_username, is_valid_display_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Request models
|
# Request models
|
||||||
class UpdateProfileRequest(BaseModel):
|
class UpdateProfileRequest(BaseModel):
|
||||||
nickname: str | None = None
|
username: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
# Create uploads directory if it doesn't exist
|
# Create uploads directory if it doesn't exist
|
||||||
@@ -102,6 +104,7 @@ async def get_user_profile(
|
|||||||
return {
|
return {
|
||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name,
|
||||||
"profile_picture": current_user.profile_picture,
|
"profile_picture": current_user.profile_picture,
|
||||||
"bio": current_user.bio,
|
"bio": current_user.bio,
|
||||||
"online": current_user.online,
|
"online": current_user.online,
|
||||||
@@ -121,19 +124,32 @@ async def update_user_profile(
|
|||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
# Update username if provided
|
# Update username if provided
|
||||||
if request.nickname is not None:
|
if request.username is not None:
|
||||||
nickname = request.nickname.strip()
|
username = request.username.strip()
|
||||||
if len(nickname) < 3:
|
if not is_valid_username(username):
|
||||||
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long")
|
raise HTTPException(
|
||||||
if len(nickname) > 50:
|
status_code=400,
|
||||||
raise HTTPException(status_code=400, detail="Username must be 50 characters or less")
|
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||||
|
)
|
||||||
|
|
||||||
# Check if username is already taken by another user
|
# Check if username is already taken by another user
|
||||||
existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first()
|
existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(status_code=400, detail="Username already taken")
|
raise HTTPException(status_code=400, detail="Это имя пользователя уже занято")
|
||||||
|
|
||||||
current_user.username = nickname
|
current_user.username = username
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Update display name if provided
|
||||||
|
if request.display_name is not None:
|
||||||
|
display_name = request.display_name.strip()
|
||||||
|
if not is_valid_display_name(display_name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.display_name = display_name
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
# Update bio if provided
|
# Update bio if provided
|
||||||
@@ -150,12 +166,14 @@ async def update_user_profile(
|
|||||||
return {
|
return {
|
||||||
"message": "Profile updated successfully",
|
"message": "Profile updated successfully",
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name,
|
||||||
"bio": current_user.bio
|
"bio": current_user.bio
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"message": "No changes made",
|
"message": "No changes made",
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name,
|
||||||
"bio": current_user.bio
|
"bio": current_user.bio
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +215,31 @@ async def get_user_by_username(
|
|||||||
return UserProfileResponse(
|
return UserProfileResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
|
display_name=user.display_name,
|
||||||
|
profile_picture=user.profile_picture,
|
||||||
|
bio=user.bio,
|
||||||
|
online=user.online,
|
||||||
|
last_seen=user.last_seen,
|
||||||
|
created_at=user.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/user/id/{user_id}")
|
||||||
|
async def get_user_by_id(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get user profile by user ID
|
||||||
|
"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
return UserProfileResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
display_name=user.display_name,
|
||||||
profile_picture=user.profile_picture,
|
profile_picture=user.profile_picture,
|
||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
online=user.online,
|
online=user.online,
|
||||||
|
|||||||
+11
-1
@@ -3,7 +3,17 @@ import re
|
|||||||
def is_valid_username(username: str) -> bool:
|
def is_valid_username(username: str) -> bool:
|
||||||
if len(username) < 3 or len(username) > 20:
|
if len(username) < 3 or len(username) > 20:
|
||||||
return False
|
return False
|
||||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username):
|
# Only allow English letters, numbers, dashes and underscores
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_display_name(display_name: str) -> bool:
|
||||||
|
if len(display_name) < 1 or len(display_name) > 64:
|
||||||
|
return False
|
||||||
|
# Check if not blank (only whitespace)
|
||||||
|
if not display_name.strip():
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { UserProfile } from "@/core/types";
|
|||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
nickname?: string;
|
username?: string;
|
||||||
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
|
|||||||
// Map backend fields to frontend fields
|
// Map backend fields to frontend fields
|
||||||
return {
|
return {
|
||||||
profile_picture: data.profile_picture,
|
profile_picture: data.profile_picture,
|
||||||
nickname: data.username,
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
description: data.bio
|
description: data.bio
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,8 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
|
|||||||
try {
|
try {
|
||||||
// Map frontend fields to backend fields
|
// Map frontend fields to backend fields
|
||||||
const backendData = {
|
const backendData = {
|
||||||
nickname: data.nickname,
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
description: data.description
|
description: data.description
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,3 +129,23 @@ export async function fetchUserProfile(token: string, username: string): Promise
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches user profile data by user ID
|
||||||
|
*/
|
||||||
|
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||||
|
headers: getAuthHeaders(token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile by ID:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+4
@@ -62,6 +62,7 @@ export interface Reaction {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
|
user_id: number;
|
||||||
username: string;
|
username: string;
|
||||||
content: string;
|
content: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
@@ -111,6 +112,7 @@ export interface User {
|
|||||||
last_seen: string;
|
last_seen: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
profile_picture: string;
|
profile_picture: string;
|
||||||
@@ -130,6 +132,7 @@ export interface User {
|
|||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
@@ -163,6 +166,7 @@ export interface LoginRequest {
|
|||||||
*/
|
*/
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
password: string;
|
password: string;
|
||||||
confirm_password: string;
|
confirm_password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<MaterialTextField
|
<MaterialTextField
|
||||||
label="Имя пользователя"
|
label="@Имя пользователя"
|
||||||
id="login-username"
|
id="login-username"
|
||||||
name="username"
|
name="username"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default function RegisterPage() {
|
|||||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayNameElement = useRef<TextField>(null);
|
||||||
const usernameElement = useRef<TextField>(null);
|
const usernameElement = useRef<TextField>(null);
|
||||||
const passwordElement = useRef<TextField>(null);
|
const passwordElement = useRef<TextField>(null);
|
||||||
const confirmPasswordElement = useRef<TextField>(null);
|
const confirmPasswordElement = useRef<TextField>(null);
|
||||||
@@ -36,11 +37,12 @@ export default function RegisterPage() {
|
|||||||
<form onSubmit={async (e) => {
|
<form onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const displayName = displayNameElement.current!.value.trim();
|
||||||
const username = usernameElement.current!.value.trim();
|
const username = usernameElement.current!.value.trim();
|
||||||
const password = passwordElement.current!.value.trim();
|
const password = passwordElement.current!.value.trim();
|
||||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||||
|
|
||||||
if (!username || !password || !confirmPassword) {
|
if (!displayName || !username || !password || !confirmPassword) {
|
||||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,11 +52,22 @@ export default function RegisterPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (displayName.length < 1 || displayName.length > 64) {
|
||||||
|
showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (username.length < 3 || username.length > 20) {
|
if (username.length < 3 || username.length > 20) {
|
||||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate username format (only English letters, numbers, dashes, underscores)
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||||
|
showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 5 || password.length > 50) {
|
if (password.length < 5 || password.length > 50) {
|
||||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||||
return;
|
return;
|
||||||
@@ -62,6 +75,7 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const request: RegisterRequest = {
|
const request: RegisterRequest = {
|
||||||
|
display_name: displayName,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
confirm_password: confirmPassword
|
confirm_password: confirmPassword
|
||||||
@@ -97,7 +111,18 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<MaterialTextField
|
<MaterialTextField
|
||||||
label="Имя пользователя"
|
label="Отображаемое имя"
|
||||||
|
id="register-display-name"
|
||||||
|
name="display_name"
|
||||||
|
variant="outlined"
|
||||||
|
icon="badge--filled"
|
||||||
|
autocomplete="name"
|
||||||
|
maxlength={64}
|
||||||
|
counter
|
||||||
|
required
|
||||||
|
ref={displayNameElement} />
|
||||||
|
<MaterialTextField
|
||||||
|
label="@Имя пользователя"
|
||||||
id="register-username"
|
id="register-username"
|
||||||
name="username"
|
name="username"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -55,6 +55,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: $color-dark-error;
|
||||||
|
font-size: small;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-picture-section {
|
.profile-picture-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -134,15 +139,15 @@
|
|||||||
|
|
||||||
.profile-sections {
|
.profile-sections {
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
border-radius: 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
overflow: hidden;
|
|
||||||
width: calc(100% - (16px * 2));
|
width: calc(100% - (16px * 2));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
$edge-radius: 24px;
|
||||||
|
|
||||||
background: $color-dark-surface-container-high;
|
background: $color-dark-surface-container-high;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
@@ -150,6 +155,9 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
transition: outline 0.1s ease;
|
||||||
|
outline: 0px solid transparent;
|
||||||
|
outline-offset: -1px;
|
||||||
|
|
||||||
.content-container {
|
.content-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -179,6 +187,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First and last section
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: $edge-radius;
|
||||||
|
border-top-right-radius: $edge-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-left-radius: $edge-radius;
|
||||||
|
border-bottom-right-radius: $edge-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
outline: 1px solid $color-dark-error;
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export function useDM() {
|
|||||||
|
|
||||||
decryptedMessages.push({
|
decryptedMessages.push({
|
||||||
id: env.id,
|
id: env.id,
|
||||||
|
user_id: env.senderId,
|
||||||
content: text,
|
content: text,
|
||||||
username: username,
|
username: username,
|
||||||
timestamp: env.timestamp,
|
timestamp: env.timestamp,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
|||||||
export interface ProfileDialogData {
|
export interface ProfileDialogData {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
display_name?: string;
|
||||||
profilePicture?: string;
|
profilePicture?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
memberSince?: string;
|
memberSince?: string;
|
||||||
|
|||||||
@@ -1,20 +1,78 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import type { ProfileDialogData } from "@/pages/chat/state";
|
import type { ProfileDialogData } from "@/pages/chat/state";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import { confirm } from "mdui/functions/confirm";
|
import { confirm } from "mdui/functions/confirm";
|
||||||
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
|
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
|
||||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { OnlineStatus } from "./right/OnlineStatus";
|
import { OnlineStatus } from "./right/OnlineStatus";
|
||||||
|
|
||||||
|
interface SectionProps {
|
||||||
|
type: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
textArea?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ type, icon, label, error, value, onChange, readOnly, placeholder, textArea = false }: SectionProps) {
|
||||||
|
let valueComponent: ReactNode = null;
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
if (textArea) {
|
||||||
|
valueComponent = (
|
||||||
|
<RichTextArea
|
||||||
|
text={value || ""}
|
||||||
|
onTextChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="value"
|
||||||
|
rows={1}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
valueComponent = (
|
||||||
|
<input
|
||||||
|
className="value"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
readOnly={readOnly} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
valueComponent = (
|
||||||
|
<span className="value">{value}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`section ${type} ${error ? 'error' : ''}`}>
|
||||||
|
<mdui-icon name={icon} />
|
||||||
|
<div className="content-container">
|
||||||
|
<label className="label">{label}</label>
|
||||||
|
{valueComponent}
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfileDialog() {
|
export function ProfileDialog() {
|
||||||
const { chat, user, closeProfileDialog } = useAppState();
|
const { chat, user, closeProfileDialog, setUser } = useAppState();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
|
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
|
||||||
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
|
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -40,23 +98,19 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
}, [chat.profileDialog, isOpen]);
|
}, [chat.profileDialog, isOpen]);
|
||||||
|
|
||||||
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
|
async function fetchFreshProfileData(profileData: ProfileDialogData) {
|
||||||
if (!user.authToken) return;
|
if (!user.authToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let freshData = profileData;
|
let freshData = profileData;
|
||||||
|
|
||||||
// If it's not the public chat and has a username, fetch fresh data
|
// If it's not the public chat and has a user ID, fetch fresh data
|
||||||
if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) {
|
if (profileData.userId && profileData.username !== "Общий чат") {
|
||||||
const userProfile = await fetchUserProfile(user.authToken, profileData.username);
|
const userProfile = await fetchUserProfileById(user.authToken, profileData.userId);
|
||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
freshData = {
|
freshData = {
|
||||||
userId: userProfile.id,
|
...userProfile,
|
||||||
username: userProfile.username,
|
|
||||||
profilePicture: userProfile.profile_picture,
|
|
||||||
bio: userProfile.bio,
|
|
||||||
memberSince: userProfile.created_at,
|
memberSince: userProfile.created_at,
|
||||||
online: userProfile.online,
|
|
||||||
isOwnProfile: profileData.isOwnProfile
|
isOwnProfile: profileData.isOwnProfile
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,7 +126,7 @@ export function ProfileDialog() {
|
|||||||
setCurrentData(profileData);
|
setCurrentData(profileData);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Trigger transition after component mounts
|
// Trigger transition after component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,13 +144,13 @@ export function ProfileDialog() {
|
|||||||
|
|
||||||
// Handle ESC key
|
// Handle ESC key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && isOpen) {
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
function handleEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", handleEsc);
|
document.addEventListener("keydown", handleEsc);
|
||||||
return () => document.removeEventListener("keydown", handleEsc);
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
}
|
}
|
||||||
@@ -117,6 +171,13 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
||||||
|
|
||||||
|
// Validate fields when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentData && isOpen) {
|
||||||
|
validateFields();
|
||||||
|
}
|
||||||
|
}, [currentData, isOpen]);
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!originalData || !currentData) return false;
|
if (!originalData || !currentData) return false;
|
||||||
|
|
||||||
@@ -127,13 +188,14 @@ export function ProfileDialog() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
normalizeValue(originalData.display_name) !== normalizeValue(currentData.display_name) ||
|
||||||
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
||||||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
||||||
originalData.profilePicture !== currentData.profilePicture
|
originalData.profilePicture !== currentData.profilePicture
|
||||||
);
|
);
|
||||||
}, [originalData, currentData]);
|
}, [originalData, currentData]);
|
||||||
|
|
||||||
const handleClose = async () => {
|
async function handleClose() {
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
try {
|
try {
|
||||||
await confirm({
|
await confirm({
|
||||||
@@ -151,7 +213,7 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerCloseAnimation = () => {
|
function triggerCloseAnimation() {
|
||||||
if (backdropRef.current && dialogRef.current) {
|
if (backdropRef.current && dialogRef.current) {
|
||||||
backdropRef.current.classList.remove('open');
|
backdropRef.current.classList.remove('open');
|
||||||
dialogRef.current.classList.remove('open');
|
dialogRef.current.classList.remove('open');
|
||||||
@@ -165,29 +227,41 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
function handleBackdropClick(e: React.MouseEvent) {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
if (!currentData) return;
|
if (!currentData) return;
|
||||||
setCurrentData({ ...currentData, username: e.target.value });
|
const newValue = e.target.value;
|
||||||
|
setCurrentData({ ...currentData, display_name: newValue });
|
||||||
|
|
||||||
|
// Validate display name in real-time
|
||||||
|
validateDisplayName(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBioChange = (newBio: string) => {
|
function handleUsernameChange(value: string) {
|
||||||
|
if (!currentData) return;
|
||||||
|
setCurrentData({ ...currentData, username: value });
|
||||||
|
|
||||||
|
// Validate username in real-time
|
||||||
|
validateUsername(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleBioChange(newBio: string) {
|
||||||
if (!currentData) return;
|
if (!currentData) return;
|
||||||
setCurrentData({ ...currentData, bio: newBio });
|
setCurrentData({ ...currentData, bio: newBio });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProfilePictureClick = () => {
|
function handleProfilePictureClick() {
|
||||||
if (currentData?.isOwnProfile) {
|
if (currentData?.isOwnProfile) {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file && file.type.startsWith("image/")) {
|
if (file && file.type.startsWith("image/")) {
|
||||||
// Open cropper dialog here - for now just update the image
|
// Open cropper dialog here - for now just update the image
|
||||||
@@ -202,15 +276,60 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
function validateDisplayName(value: string) {
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
error = "Отображаемое имя не может быть пустым";
|
||||||
|
} else if (value.length > 64) {
|
||||||
|
error = "Отображаемое имя не может быть длиннее 64 символов";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(prev => ({ ...prev, display_name: error }));
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateUsername(value: string) {
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
error = "Имя пользователя не может быть пустым";
|
||||||
|
} else if (value.length < 3) {
|
||||||
|
error = "Имя пользователя должно быть не менее 3 символов";
|
||||||
|
} else if (value.length > 20) {
|
||||||
|
error = "Имя пользователя не может быть длиннее 20 символов";
|
||||||
|
} else if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||||
|
error = "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(prev => ({ ...prev, username: error }));
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateFields() {
|
||||||
|
if (currentData) {
|
||||||
|
validateDisplayName(currentData.display_name || "");
|
||||||
|
validateUsername(currentData.username || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return !errors.display_name && !errors.username;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
if (!currentData || !user.authToken || !originalData) return;
|
if (!currentData || !user.authToken || !originalData) return;
|
||||||
|
|
||||||
|
// Validate fields first
|
||||||
|
if (!validateFields()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
// Update profile data
|
// Update profile data
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
|
if (originalData.display_name !== currentData.display_name) {
|
||||||
|
updateData.display_name = currentData.display_name;
|
||||||
|
}
|
||||||
if (originalData.username !== currentData.username) {
|
if (originalData.username !== currentData.username) {
|
||||||
updateData.nickname = currentData.username;
|
updateData.username = currentData.username;
|
||||||
}
|
}
|
||||||
if (originalData.bio !== currentData.bio) {
|
if (originalData.bio !== currentData.bio) {
|
||||||
updateData.description = currentData.bio;
|
updateData.description = currentData.bio;
|
||||||
@@ -233,22 +352,51 @@ export function ProfileDialog() {
|
|||||||
// Update the original data to match current data
|
// Update the original data to match current data
|
||||||
setOriginalData(currentData);
|
setOriginalData(currentData);
|
||||||
|
|
||||||
|
// If this is the current user's profile and username was changed, update the current user data
|
||||||
|
if (currentData.isOwnProfile && user.currentUser && user.authToken) {
|
||||||
|
const updatedUser = {
|
||||||
|
...user.currentUser,
|
||||||
|
username: currentData.username || user.currentUser.username,
|
||||||
|
display_name: currentData.display_name || user.currentUser.display_name,
|
||||||
|
bio: currentData.bio || user.currentUser.bio,
|
||||||
|
profile_picture: currentData.profilePicture || user.currentUser.profile_picture
|
||||||
|
};
|
||||||
|
setUser(user.authToken, updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
// Close dialog with animation after successful save
|
// Close dialog with animation after successful save
|
||||||
triggerCloseAnimation();
|
triggerCloseAnimation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save profile:", error);
|
console.error("Failed to save profile:", error);
|
||||||
|
// Handle API errors
|
||||||
|
if (error instanceof Error && error.message.includes("уже занято")) {
|
||||||
|
setErrors({ username: "Это имя пользователя уже занято" });
|
||||||
|
} else {
|
||||||
|
setErrors({ general: "Ошибка при сохранении профиля" });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
function formatDate(dateString: string) {
|
||||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric"
|
day: "numeric"
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const fabVisible = useMemo(() => {
|
||||||
|
let hasErrors = false;
|
||||||
|
Object.values(errors).forEach(error => {
|
||||||
|
if (error) {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors;
|
||||||
|
}, [hasChanges, currentData?.isOwnProfile, isSaving, errors]);
|
||||||
|
|
||||||
if (!isOpen || !currentData) return null;
|
if (!isOpen || !currentData) return null;
|
||||||
|
|
||||||
@@ -281,57 +429,64 @@ export function ProfileDialog() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Username */}
|
{/* Display Name */}
|
||||||
{currentData.username && (
|
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
|
||||||
<div className="username-section">
|
<input
|
||||||
<input
|
className="username-input"
|
||||||
className="username-input"
|
type="text"
|
||||||
type="text"
|
value={currentData.display_name}
|
||||||
value={currentData.username}
|
onChange={handleDisplayNameChange}
|
||||||
onChange={handleUsernameChange}
|
readOnly={!currentData.isOwnProfile}
|
||||||
readOnly={!currentData.isOwnProfile}
|
placeholder="Имя"
|
||||||
placeholder="Имя пользователя"
|
/>
|
||||||
/>
|
{errors.display_name && (
|
||||||
</div>
|
<div className="error-message">{errors.display_name}</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Online Status */}
|
{/* Online Status */}
|
||||||
{currentData?.userId && (
|
{(currentData?.userId || currentData?.isOwnProfile) && (
|
||||||
<div className="online-status-section">
|
<div className="online-status-section">
|
||||||
<OnlineStatus userId={currentData.userId} />
|
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="profile-sections">
|
<div className="profile-sections">
|
||||||
|
<Section
|
||||||
|
type="username"
|
||||||
|
error={errors.username}
|
||||||
|
icon="alternate_email--filled"
|
||||||
|
label="Имя пользователя:"
|
||||||
|
value={currentData.username}
|
||||||
|
onChange={handleUsernameChange}
|
||||||
|
readOnly={!currentData.isOwnProfile}
|
||||||
|
placeholder="username" />
|
||||||
|
|
||||||
|
|
||||||
{/* Bio */}
|
{/* Bio */}
|
||||||
{currentData.bio !== undefined && (
|
{currentData.bio !== undefined && (
|
||||||
<div className="section bio">
|
<Section
|
||||||
<mdui-icon name="info--filled" />
|
type="bio"
|
||||||
<div className="content-container">
|
icon="info--filled"
|
||||||
<label className="label">О себе:</label>
|
label="О себе:"
|
||||||
<RichTextArea
|
value={currentData.bio}
|
||||||
text={currentData.bio || ""}
|
onChange={handleBioChange}
|
||||||
onTextChange={handleBioChange}
|
readOnly={!currentData.isOwnProfile}
|
||||||
placeholder="Нет информации о себе"
|
placeholder="Нет информации о себе"
|
||||||
className="value"
|
textArea
|
||||||
rows={1}
|
/>
|
||||||
readOnly={!currentData.isOwnProfile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Member Since */}
|
{/* Member Since */}
|
||||||
{currentData.memberSince && (
|
{currentData.memberSince && (
|
||||||
<div className="section member-since">
|
<Section
|
||||||
<mdui-icon name="calendar_month--filled" />
|
type="member-since"
|
||||||
<div className="content-container">
|
icon="calendar_month--filled"
|
||||||
<span className="label">Участник с:</span>
|
label="Участник с:"
|
||||||
<span className="value">
|
value={formatDate(currentData.memberSince)}
|
||||||
{formatDate(currentData.memberSince)}
|
readOnly={true}
|
||||||
</span>
|
placeholder="Участник с:"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +495,7 @@ export function ProfileDialog() {
|
|||||||
{currentData.isOwnProfile && (
|
{currentData.isOwnProfile && (
|
||||||
<mdui-fab
|
<mdui-fab
|
||||||
icon="check"
|
icon="check"
|
||||||
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
|
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export function ChatHeader() {
|
|||||||
const handleProfileClick = () => {
|
const handleProfileClick = () => {
|
||||||
setProfileDialog({
|
setProfileDialog({
|
||||||
userId: user.currentUser?.id,
|
userId: user.currentUser?.id,
|
||||||
username: profileData?.nickname || "Пользователь",
|
username: profileData?.username || "Пользователь",
|
||||||
|
display_name: profileData?.display_name || "Пользователь",
|
||||||
profilePicture: profileData?.profile_picture,
|
profilePicture: profileData?.profile_picture,
|
||||||
bio: profileData?.description,
|
bio: profileData?.description,
|
||||||
memberSince: user.currentUser?.created_at,
|
memberSince: user.currentUser?.created_at,
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { useAppState, type ChatTabs } from "@/pages/chat/state";
|
import { useAppState, type ChatTabs } from "@/pages/chat/state";
|
||||||
import { UnifiedChatsList } from "./UnifiedChatsList";
|
import { UnifiedChatsList } from "./UnifiedChatsList";
|
||||||
import type { FormEvent } from "react";
|
|
||||||
import type { Tabs } from "mdui/components/tabs";
|
import type { Tabs } from "mdui/components/tabs";
|
||||||
|
|
||||||
export function ChatTabs() {
|
export function ChatTabs() {
|
||||||
const { chat, setActiveTab } = useAppState();
|
const { chat, setActiveTab } = useAppState();
|
||||||
|
|
||||||
function handleChange(e: FormEvent<Tabs> & CustomEvent<{ value: string }>) {
|
|
||||||
setActiveTab(e.detail.value as ChatTabs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-tabs">
|
<div className="chat-tabs">
|
||||||
<mdui-tabs
|
<mdui-tabs
|
||||||
value={chat.activeTab}
|
value={chat.activeTab}
|
||||||
full-width
|
full-width
|
||||||
onChange={handleChange}>
|
onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}>
|
||||||
<mdui-tab value="chats">
|
<mdui-tab value="chats">
|
||||||
Чаты
|
Чаты
|
||||||
</mdui-tab>
|
</mdui-tab>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { fetchUserPublicKey } from "@/core/api/dmApi";
|
|||||||
import type { Message } from "@/core/types";
|
import type { Message } from "@/core/types";
|
||||||
import { websocket } from "@/core/websocket";
|
import { websocket } from "@/core/websocket";
|
||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { OnlineIndicator } from "../right/OnlineIndicator";
|
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
|
|
||||||
interface PublicChat {
|
interface PublicChat {
|
||||||
@@ -20,6 +20,7 @@ interface PublicChat {
|
|||||||
interface DMConversation {
|
interface DMConversation {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
type: "dm";
|
type: "dm";
|
||||||
@@ -84,6 +85,7 @@ export function UnifiedChatsList() {
|
|||||||
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
|
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
profile_picture: user.profile_picture,
|
profile_picture: user.profile_picture,
|
||||||
online: user.online,
|
online: user.online,
|
||||||
type: "dm" as const,
|
type: "dm" as const,
|
||||||
@@ -172,13 +174,13 @@ export function UnifiedChatsList() {
|
|||||||
};
|
};
|
||||||
}, [allChats]);
|
}, [allChats]);
|
||||||
|
|
||||||
const formatPublicChatMessage = (chatId: string): string => {
|
function formatPublicChatMessage(chatId: string): string {
|
||||||
const lastMessage = lastMessages[chatId];
|
const lastMessage = lastMessages[chatId];
|
||||||
if (!lastMessage) {
|
if (!lastMessage) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentUser = lastMessage.username === user.currentUser?.username;
|
const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
|
||||||
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
|
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
|
||||||
|
|
||||||
const maxContentLength = 50 - prefix.length;
|
const maxContentLength = 50 - prefix.length;
|
||||||
@@ -187,14 +189,13 @@ export function UnifiedChatsList() {
|
|||||||
: lastMessage.content;
|
: lastMessage.content;
|
||||||
|
|
||||||
return prefix + content;
|
return prefix + content;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
async function handlePublicChatClick(chatName: string) {
|
||||||
const handlePublicChatClick = async (chatName: string) => {
|
|
||||||
await switchToPublicChat(chatName);
|
await switchToPublicChat(chatName);
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDMClick = async (dmConversation: DMConversation) => {
|
async function handleDMClick(dmConversation: DMConversation) {
|
||||||
if (!dmConversation.publicKey) {
|
if (!dmConversation.publicKey) {
|
||||||
const authToken = useAppState.getState().user.authToken;
|
const authToken = useAppState.getState().user.authToken;
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
@@ -215,7 +216,7 @@ export function UnifiedChatsList() {
|
|||||||
profilePicture: dmConversation.profile_picture,
|
profilePicture: dmConversation.profile_picture,
|
||||||
online: dmConversation.online || false
|
online: dmConversation.online || false
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
if (isLoadingUsers) {
|
if (isLoadingUsers) {
|
||||||
return (
|
return (
|
||||||
@@ -256,7 +257,7 @@ export function UnifiedChatsList() {
|
|||||||
return (
|
return (
|
||||||
<mdui-list-item
|
<mdui-list-item
|
||||||
key={`dm-${chat.id}`}
|
key={`dm-${chat.id}`}
|
||||||
headline={chat.username}
|
headline={chat.display_name}
|
||||||
onClick={() => handleDMClick(chat)}
|
onClick={() => handleDMClick(chat)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
@@ -266,7 +267,7 @@ export function UnifiedChatsList() {
|
|||||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||||
<img
|
<img
|
||||||
src={chat.profile_picture || defaultAvatar}
|
src={chat.profile_picture || defaultAvatar}
|
||||||
alt={chat.username}
|
alt={chat.display_name}
|
||||||
style={{
|
style={{
|
||||||
width: "40px",
|
width: "40px",
|
||||||
height: "40px",
|
height: "40px",
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
message={message}
|
message={message}
|
||||||
isAuthor={isDm ?
|
isAuthor={isDm ?
|
||||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||||
(message.username === user.currentUser?.username)
|
(message.user_id === user.currentUser?.id)
|
||||||
}
|
}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onReactionClick={handleReactionClick}
|
onReactionClick={handleReactionClick}
|
||||||
@@ -158,7 +158,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
message={contextMenu.message}
|
message={contextMenu.message}
|
||||||
isAuthor={isDm ?
|
isAuthor={isDm ?
|
||||||
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||||
(contextMenu.message.username === user.currentUser?.username)
|
(contextMenu.message.user_id === user.currentUser?.id)
|
||||||
}
|
}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
|||||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||||
import { getAuthHeaders } from "@/core/api/authApi";
|
import { getAuthHeaders } from "@/core/api/authApi";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
import { fetchUserProfileById } from "@/core/api/profileApi";
|
||||||
import { ub64 } from "@/utils/utils";
|
import { ub64 } from "@/utils/utils";
|
||||||
import { useImmer } from "use-immer";
|
import { useImmer } from "use-immer";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -389,10 +389,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function handleProfileClick() {
|
async function handleProfileClick() {
|
||||||
if (!user.authToken || !message.username) return;
|
if (!user.authToken || !message.user_id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userProfile = await fetchUserProfile(user.authToken, message.username);
|
const userProfile = await fetchUserProfileById(user.authToken, message.user_id);
|
||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
setProfileDialog({
|
setProfileDialog({
|
||||||
...userProfile,
|
...userProfile,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
editDmEnvelope,
|
editDmEnvelope,
|
||||||
deleteDmEnvelope
|
deleteDmEnvelope
|
||||||
} from "@/core/api/dmApi";
|
} from "@/core/api/dmApi";
|
||||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
import { fetchUserProfileById } from "@/core/api/profileApi";
|
||||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||||
@@ -84,6 +84,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
|
|
||||||
const dmMsg: Message = {
|
const dmMsg: Message = {
|
||||||
id: env.id,
|
id: env.id,
|
||||||
|
user_id: env.senderId,
|
||||||
content: content,
|
content: content,
|
||||||
username: username,
|
username: username,
|
||||||
timestamp: env.timestamp,
|
timestamp: env.timestamp,
|
||||||
@@ -353,12 +354,13 @@ export class DMPanel extends MessagePanel {
|
|||||||
if (!this.dmData || !this.currentUser.authToken) return null;
|
if (!this.dmData || !this.currentUser.authToken) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
|
const userProfile = await fetchUserProfileById(this.currentUser.authToken, this.dmData.userId);
|
||||||
if (!userProfile) return null;
|
if (!userProfile) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: userProfile.id,
|
userId: userProfile.id,
|
||||||
username: userProfile.username,
|
username: userProfile.username,
|
||||||
|
display_name: userProfile.display_name,
|
||||||
profilePicture: userProfile.profile_picture,
|
profilePicture: userProfile.profile_picture,
|
||||||
bio: userProfile.bio,
|
bio: userProfile.bio,
|
||||||
memberSince: userProfile.created_at,
|
memberSince: userProfile.created_at,
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export abstract class MessagePanel {
|
|||||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const tempMessage: Message = {
|
const tempMessage: Message = {
|
||||||
id: -1, // Temporary negative ID
|
id: -1, // Temporary negative ID
|
||||||
|
user_id: this.currentUser.currentUser?.id ?? -1,
|
||||||
username: this.currentUser.currentUser?.username ?? "You",
|
username: this.currentUser.currentUser?.username ?? "You",
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
is_read: false,
|
is_read: false,
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class PublicChatPanel extends MessagePanel {
|
|||||||
const newMsg = response.data;
|
const newMsg = response.data;
|
||||||
|
|
||||||
// Check if this is a confirmation of a message we sent
|
// Check if this is a confirmation of a message we sent
|
||||||
const isOurMessage = newMsg.username === this.currentUser.currentUser?.username;
|
const isOurMessage = newMsg.user_id === this.currentUser.currentUser?.id;
|
||||||
if (isOurMessage) {
|
if (isOurMessage) {
|
||||||
// This is our message being confirmed, find the temp message and replace it
|
// This is our message being confirmed, find the temp message and replace it
|
||||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||||
@@ -199,7 +199,8 @@ export class PublicChatPanel extends MessagePanel {
|
|||||||
|
|
||||||
async getProfile(): Promise<ProfileDialogData | null> {
|
async getProfile(): Promise<ProfileDialogData | null> {
|
||||||
return {
|
return {
|
||||||
username: "Общий чат",
|
username: "general",
|
||||||
|
display_name: "Общий чат",
|
||||||
bio: "Общаемся со всеми пользователями FromChat!",
|
bio: "Общаемся со всеми пользователями FromChat!",
|
||||||
isOwnProfile: false
|
isOwnProfile: false
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user