Implement usernames and profile sections

This commit is contained in:
2025-10-20 00:18:34 +03:00
Unverified
parent f0be91c0e7
commit 818b7c1b46
21 changed files with 425 additions and 122 deletions
+3
View File
@@ -13,6 +13,7 @@ class User(Base):
id = Column(Integer, primary_key=True, 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)
profile_picture = Column(String(255), nullable=True)
bio = Column(Text, nullable=True)
@@ -149,6 +150,7 @@ class LoginRequest(BaseModel):
class RegisterRequest(BaseModel):
username: str
display_name: str
password: str
confirm_password: str
@@ -178,6 +180,7 @@ class PushSubscriptionRequest(BaseModel):
class UserProfileResponse(BaseModel):
id: int
username: str
display_name: str
profile_picture: str | None
bio: str | None
online: bool
+11 -2
View File
@@ -6,7 +6,7 @@ from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
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()
@@ -17,6 +17,7 @@ def convert_user(user: User) -> dict:
"last_seen": user.last_seen.isoformat(),
"online": user.online,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": user.username == OWNER_USERNAME
@@ -58,6 +59,7 @@ def login(request: LoginRequest, db: Session = Depends(get_db)):
@router.post("/register")
def register(request: RegisterRequest, db: Session = Depends(get_db)):
username = request.username.strip()
display_name = request.display_name.strip()
password = request.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):
raise HTTPException(
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):
@@ -107,6 +115,7 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
hashed_password = get_password_hash(password)
new_user = User(
username=username,
display_name=display_name,
password_hash=hashed_password,
online=True,
last_seen=datetime.now()
+4 -3
View File
@@ -48,16 +48,17 @@ def convert_message(msg: Message) -> dict:
reactions_dict[emoji]["count"] += 1
reactions_dict[emoji]["users"].append({
"id": reaction.user_id,
"username": reaction.user.username
"username": reaction.user.display_name
})
return {
"id": msg.id,
"user_id": msg.author.id,
"content": msg.content,
"timestamp": msg.timestamp.isoformat(),
"is_read": msg.is_read,
"is_edited": msg.is_edited,
"username": msg.author.username,
"username": msg.author.display_name,
"profile_picture": msg.author.profile_picture,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"reactions": list(reactions_dict.values()),
@@ -88,7 +89,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
reactions_dict[emoji]["count"] += 1
reactions_dict[emoji]["users"].append({
"id": reaction.user_id,
"username": reaction.user.username
"username": reaction.user.display_name
})
return {
+53 -10
View File
@@ -11,12 +11,14 @@ import io
from dependencies import get_db, get_current_user
from models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel
from validation import is_valid_username, is_valid_display_name
router = APIRouter()
# Request models
class UpdateProfileRequest(BaseModel):
nickname: str | None = None
username: str | None = None
display_name: str | None = None
description: str | None = None
# Create uploads directory if it doesn't exist
@@ -102,6 +104,7 @@ async def get_user_profile(
return {
"id": current_user.id,
"username": current_user.username,
"display_name": current_user.display_name,
"profile_picture": current_user.profile_picture,
"bio": current_user.bio,
"online": current_user.online,
@@ -121,19 +124,32 @@ async def update_user_profile(
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")
if request.username is not None:
username = request.username.strip()
if not is_valid_username(username):
raise HTTPException(
status_code=400,
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
)
# 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:
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
# Update bio if provided
@@ -150,12 +166,14 @@ async def update_user_profile(
return {
"message": "Profile updated successfully",
"username": current_user.username,
"display_name": current_user.display_name,
"bio": current_user.bio
}
else:
return {
"message": "No changes made",
"username": current_user.username,
"display_name": current_user.display_name,
"bio": current_user.bio
}
@@ -197,6 +215,31 @@ async def get_user_by_username(
return UserProfileResponse(
id=user.id,
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,
bio=user.bio,
online=user.online,
+11 -1
View File
@@ -3,7 +3,17 @@ import re
def is_valid_username(username: str) -> bool:
if len(username) < 3 or len(username) > 20:
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 True
+26 -3
View File
@@ -4,7 +4,8 @@ import type { UserProfile } from "@/core/types";
export interface ProfileData {
profile_picture?: string;
nickname?: string;
username?: string;
display_name?: string;
description?: string;
}
@@ -26,7 +27,8 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
// Map backend fields to frontend fields
return {
profile_picture: data.profile_picture,
nickname: data.username,
username: data.username,
display_name: data.display_name,
description: data.bio
};
}
@@ -69,7 +71,8 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
try {
// Map frontend fields to backend fields
const backendData = {
nickname: data.nickname,
username: data.username,
display_name: data.display_name,
description: data.description
};
@@ -126,3 +129,23 @@ export async function fetchUserProfile(token: string, username: string): Promise
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;
}
}
+4
View File
@@ -62,6 +62,7 @@ export interface Reaction {
export interface Message {
id: number;
user_id: number;
username: string;
content: string;
is_read: boolean;
@@ -111,6 +112,7 @@ export interface User {
last_seen: string;
online: boolean;
username: string;
display_name: string;
admin?: boolean;
bio?: string;
profile_picture: string;
@@ -130,6 +132,7 @@ export interface User {
export interface UserProfile {
id: number;
username: string;
display_name: string;
profile_picture?: string;
bio?: string;
online: boolean;
@@ -163,6 +166,7 @@ export interface LoginRequest {
*/
export interface RegisterRequest {
username: string;
display_name: string;
password: string;
confirm_password: string;
}
+1 -1
View File
@@ -105,7 +105,7 @@ export default function LoginPage() {
}
}}>
<MaterialTextField
label="Имя пользователя"
label="@Имя пользователя"
id="login-username"
name="username"
variant="outlined"
+27 -2
View File
@@ -23,6 +23,7 @@ export default function RegisterPage() {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const displayNameElement = useRef<TextField>(null);
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
const confirmPasswordElement = useRef<TextField>(null);
@@ -36,11 +37,12 @@ export default function RegisterPage() {
<form onSubmit={async (e) => {
e.preventDefault();
const displayName = displayNameElement.current!.value.trim();
const username = usernameElement.current!.value.trim();
const password = passwordElement.current!.value.trim();
const confirmPassword = confirmPasswordElement.current!.value.trim();
if (!username || !password || !confirmPassword) {
if (!displayName || !username || !password || !confirmPassword) {
showAlert("danger", "Пожалуйста, заполните все поля");
return;
}
@@ -50,11 +52,22 @@ export default function RegisterPage() {
return;
}
if (displayName.length < 1 || displayName.length > 64) {
showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
return;
}
if (username.length < 3 || username.length > 20) {
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
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) {
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
return;
@@ -62,6 +75,7 @@ export default function RegisterPage() {
try {
const request: RegisterRequest = {
display_name: displayName,
username: username,
password: password,
confirm_password: confirmPassword
@@ -97,7 +111,18 @@ export default function RegisterPage() {
}
}}>
<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"
name="username"
variant="outlined"
@@ -55,6 +55,11 @@
flex-direction: column;
align-items: center;
.error-message {
color: $color-dark-error;
font-size: small;
}
.profile-picture-section {
position: relative;
display: flex;
@@ -134,15 +139,15 @@
.profile-sections {
margin: 16px;
border-radius: 24px;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
width: calc(100% - (16px * 2));
box-sizing: border-box;
.section {
$edge-radius: 24px;
background: $color-dark-surface-container-high;
border-radius: 10px;
padding: 8px 16px;
@@ -150,6 +155,9 @@
flex-direction: row;
gap: 16px;
align-items: center;
transition: outline 0.1s ease;
outline: 0px solid transparent;
outline-offset: -1px;
.content-container {
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;
}
}
}
}
}
+1
View File
@@ -174,6 +174,7 @@ export function useDM() {
decryptedMessages.push({
id: env.id,
user_id: env.senderId,
content: text,
username: username,
timestamp: env.timestamp,
+1
View File
@@ -19,6 +19,7 @@ export type CallStatus = "calling" | "connecting" | "active" | "ended";
export interface ProfileDialogData {
userId?: number;
username?: string;
display_name?: string;
profilePicture?: string;
bio?: string;
memberSince?: string;
+226 -71
View File
@@ -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 { useAppState } from "@/pages/chat/state";
import type { ProfileDialogData } from "@/pages/chat/state";
import defaultAvatar from "@/images/default-avatar.png";
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 { onlineStatusManager } from "@/core/onlineStatusManager";
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() {
const { chat, user, closeProfileDialog } = useAppState();
const { chat, user, closeProfileDialog, setUser } = useAppState();
const [isOpen, setIsOpen] = useState(false);
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{[key: string]: string}>({});
const fileInputRef = useRef<HTMLInputElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
@@ -40,23 +98,19 @@ export function ProfileDialog() {
}
}, [chat.profileDialog, isOpen]);
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
async function fetchFreshProfileData(profileData: ProfileDialogData) {
if (!user.authToken) return;
try {
let freshData = profileData;
// If it's not the public chat and has a username, fetch fresh data
if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) {
const userProfile = await fetchUserProfile(user.authToken, profileData.username);
// If it's not the public chat and has a user ID, fetch fresh data
if (profileData.userId && profileData.username !== "Общий чат") {
const userProfile = await fetchUserProfileById(user.authToken, profileData.userId);
if (userProfile) {
freshData = {
userId: userProfile.id,
username: userProfile.username,
profilePicture: userProfile.profile_picture,
bio: userProfile.bio,
...userProfile,
memberSince: userProfile.created_at,
online: userProfile.online,
isOwnProfile: profileData.isOwnProfile
};
}
@@ -72,7 +126,7 @@ export function ProfileDialog() {
setCurrentData(profileData);
setIsOpen(true);
}
};
}
// Trigger transition after component mounts
useEffect(() => {
@@ -90,13 +144,13 @@ export function ProfileDialog() {
// Handle ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
handleClose();
}
};
if (isOpen) {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") {
handleClose();
}
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}
@@ -117,6 +171,13 @@ export function ProfileDialog() {
}
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
// Validate fields when data changes
useEffect(() => {
if (currentData && isOpen) {
validateFields();
}
}, [currentData, isOpen]);
const hasChanges = useMemo(() => {
if (!originalData || !currentData) return false;
@@ -127,13 +188,14 @@ export function ProfileDialog() {
};
return (
normalizeValue(originalData.display_name) !== normalizeValue(currentData.display_name) ||
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
originalData.profilePicture !== currentData.profilePicture
);
}, [originalData, currentData]);
const handleClose = async () => {
async function handleClose() {
if (hasChanges) {
try {
await confirm({
@@ -151,7 +213,7 @@ export function ProfileDialog() {
}
};
const triggerCloseAnimation = () => {
function triggerCloseAnimation() {
if (backdropRef.current && dialogRef.current) {
backdropRef.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) {
handleClose();
}
};
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
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;
setCurrentData({ ...currentData, bio: newBio });
};
const handleProfilePictureClick = () => {
function handleProfilePictureClick() {
if (currentData?.isOwnProfile) {
fileInputRef.current?.click();
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file && file.type.startsWith("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;
// Validate fields first
if (!validateFields()) {
return;
}
setIsSaving(true);
try {
// Update profile data
const updateData: any = {};
if (originalData.display_name !== currentData.display_name) {
updateData.display_name = currentData.display_name;
}
if (originalData.username !== currentData.username) {
updateData.nickname = currentData.username;
updateData.username = currentData.username;
}
if (originalData.bio !== currentData.bio) {
updateData.description = currentData.bio;
@@ -233,22 +352,51 @@ export function ProfileDialog() {
// Update the original data to match current data
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
triggerCloseAnimation();
} catch (error) {
console.error("Failed to save profile:", error);
// Handle API errors
if (error instanceof Error && error.message.includes("уже занято")) {
setErrors({ username: "Это имя пользователя уже занято" });
} else {
setErrors({ general: "Ошибка при сохранении профиля" });
}
} finally {
setIsSaving(false);
}
};
}
const formatDate = (dateString: string) => {
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
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;
@@ -281,57 +429,64 @@ export function ProfileDialog() {
)}
</div>
{/* Username */}
{currentData.username && (
<div className="username-section">
<input
className="username-input"
type="text"
value={currentData.username}
onChange={handleUsernameChange}
readOnly={!currentData.isOwnProfile}
placeholder="Имя пользователя"
/>
</div>
)}
{/* Display Name */}
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
<input
className="username-input"
type="text"
value={currentData.display_name}
onChange={handleDisplayNameChange}
readOnly={!currentData.isOwnProfile}
placeholder="Имя"
/>
{errors.display_name && (
<div className="error-message">{errors.display_name}</div>
)}
</div>
{/* Online Status */}
{currentData?.userId && (
{(currentData?.userId || currentData?.isOwnProfile) && (
<div className="online-status-section">
<OnlineStatus userId={currentData.userId} />
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
</div>
)}
<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 */}
{currentData.bio !== undefined && (
<div className="section bio">
<mdui-icon name="info--filled" />
<div className="content-container">
<label className="label">О себе:</label>
<RichTextArea
text={currentData.bio || ""}
onTextChange={handleBioChange}
placeholder="Нет информации о себе"
className="value"
rows={1}
readOnly={!currentData.isOwnProfile}
/>
</div>
</div>
<Section
type="bio"
icon="info--filled"
label="О себе:"
value={currentData.bio}
onChange={handleBioChange}
readOnly={!currentData.isOwnProfile}
placeholder="Нет информации о себе"
textArea
/>
)}
{/* Member Since */}
{currentData.memberSince && (
<div className="section member-since">
<mdui-icon name="calendar_month--filled" />
<div className="content-container">
<span className="label">Участник с:</span>
<span className="value">
{formatDate(currentData.memberSince)}
</span>
</div>
</div>
<Section
type="member-since"
icon="calendar_month--filled"
label="Участник с:"
value={formatDate(currentData.memberSince)}
readOnly={true}
placeholder="Участник с:"
/>
)}
</div>
</div>
@@ -340,7 +495,7 @@ export function ProfileDialog() {
{currentData.isOwnProfile && (
<mdui-fab
icon="check"
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
onClick={handleSave}
disabled={isSaving}
/>
@@ -13,7 +13,8 @@ export function ChatHeader() {
const handleProfileClick = () => {
setProfileDialog({
userId: user.currentUser?.id,
username: profileData?.nickname || "Пользователь",
username: profileData?.username || "Пользователь",
display_name: profileData?.display_name || "Пользователь",
profilePicture: profileData?.profile_picture,
bio: profileData?.description,
memberSince: user.currentUser?.created_at,
+1 -6
View File
@@ -1,21 +1,16 @@
import { useAppState, type ChatTabs } from "@/pages/chat/state";
import { UnifiedChatsList } from "./UnifiedChatsList";
import type { FormEvent } from "react";
import type { Tabs } from "mdui/components/tabs";
export function ChatTabs() {
const { chat, setActiveTab } = useAppState();
function handleChange(e: FormEvent<Tabs> & CustomEvent<{ value: string }>) {
setActiveTab(e.detail.value as ChatTabs);
}
return (
<div className="chat-tabs">
<mdui-tabs
value={chat.activeTab}
full-width
onChange={handleChange}>
onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}>
<mdui-tab value="chats">
Чаты
</mdui-tab>
@@ -7,7 +7,7 @@ import { fetchUserPublicKey } from "@/core/api/dmApi";
import type { Message } from "@/core/types";
import { websocket } from "@/core/websocket";
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";
interface PublicChat {
@@ -20,6 +20,7 @@ interface PublicChat {
interface DMConversation {
id: number;
username: string;
display_name: string;
profile_picture?: string;
online?: boolean;
type: "dm";
@@ -84,6 +85,7 @@ export function UnifiedChatsList() {
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
id: user.id,
username: user.username,
display_name: user.display_name,
profile_picture: user.profile_picture,
online: user.online,
type: "dm" as const,
@@ -172,13 +174,13 @@ export function UnifiedChatsList() {
};
}, [allChats]);
const formatPublicChatMessage = (chatId: string): string => {
function formatPublicChatMessage(chatId: string): string {
const lastMessage = lastMessages[chatId];
if (!lastMessage) {
return "";
}
const isCurrentUser = lastMessage.username === user.currentUser?.username;
const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
const maxContentLength = 50 - prefix.length;
@@ -187,14 +189,13 @@ export function UnifiedChatsList() {
: lastMessage.content;
return prefix + content;
};
}
const handlePublicChatClick = async (chatName: string) => {
async function handlePublicChatClick(chatName: string) {
await switchToPublicChat(chatName);
};
}
const handleDMClick = async (dmConversation: DMConversation) => {
async function handleDMClick(dmConversation: DMConversation) {
if (!dmConversation.publicKey) {
const authToken = useAppState.getState().user.authToken;
if (!authToken) return;
@@ -215,7 +216,7 @@ export function UnifiedChatsList() {
profilePicture: dmConversation.profile_picture,
online: dmConversation.online || false
});
};
}
if (isLoadingUsers) {
return (
@@ -256,7 +257,7 @@ export function UnifiedChatsList() {
return (
<mdui-list-item
key={`dm-${chat.id}`}
headline={chat.username}
headline={chat.display_name}
onClick={() => handleDMClick(chat)}
style={{ cursor: "pointer" }}
>
@@ -266,7 +267,7 @@ export function UnifiedChatsList() {
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
src={chat.profile_picture || defaultAvatar}
alt={chat.username}
alt={chat.display_name}
style={{
width: "40px",
height: "40px",
@@ -133,7 +133,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
message={message}
isAuthor={isDm ?
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(message.username === user.currentUser?.username)
(message.user_id === user.currentUser?.id)
}
onContextMenu={handleContextMenu}
onReactionClick={handleReactionClick}
@@ -158,7 +158,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
message={contextMenu.message}
isAuthor={isDm ?
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
(contextMenu.message.username === user.currentUser?.username)
(contextMenu.message.user_id === user.currentUser?.id)
}
onEdit={handleEdit}
onReply={handleReply}
+3 -3
View File
@@ -10,7 +10,7 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "@/pages/chat/state";
import { fetchUserProfile } from "@/core/api/profileApi";
import { fetchUserProfileById } from "@/core/api/profileApi";
import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
@@ -389,10 +389,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
};
async function handleProfileClick() {
if (!user.authToken || !message.username) return;
if (!user.authToken || !message.user_id) return;
try {
const userProfile = await fetchUserProfile(user.authToken, message.username);
const userProfile = await fetchUserProfileById(user.authToken, message.user_id);
if (userProfile) {
setProfileDialog({
...userProfile,
@@ -7,7 +7,7 @@ import {
editDmEnvelope,
deleteDmEnvelope
} 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 { UserState, ProfileDialogData } from "@/pages/chat/state";
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
@@ -84,6 +84,7 @@ export class DMPanel extends MessagePanel {
const dmMsg: Message = {
id: env.id,
user_id: env.senderId,
content: content,
username: username,
timestamp: env.timestamp,
@@ -353,12 +354,13 @@ export class DMPanel extends MessagePanel {
if (!this.dmData || !this.currentUser.authToken) return null;
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;
return {
userId: userProfile.id,
username: userProfile.username,
display_name: userProfile.display_name,
profilePicture: userProfile.profile_picture,
bio: userProfile.bio,
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 tempMessage: Message = {
id: -1, // Temporary negative ID
user_id: this.currentUser.currentUser?.id ?? -1,
username: this.currentUser.currentUser?.username ?? "You",
content: content.trim(),
is_read: false,
@@ -121,7 +121,7 @@ export class PublicChatPanel extends MessagePanel {
const newMsg = response.data;
// 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) {
// 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);
@@ -199,7 +199,8 @@ export class PublicChatPanel extends MessagePanel {
async getProfile(): Promise<ProfileDialogData | null> {
return {
username: "Общий чат",
username: "general",
display_name: "Общий чат",
bio: "Общаемся со всеми пользователями FromChat!",
isOwnProfile: false
};