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)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function LoginPage() {
|
||||
}
|
||||
}}>
|
||||
<MaterialTextField
|
||||
label="Имя пользователя"
|
||||
label="@Имя пользователя"
|
||||
id="login-username"
|
||||
name="username"
|
||||
variant="outlined"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useDM() {
|
||||
|
||||
decryptedMessages.push({
|
||||
id: env.id,
|
||||
user_id: env.senderId,
|
||||
content: text,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
if (isOpen) {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
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">
|
||||
{/* Display Name */}
|
||||
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
|
||||
<input
|
||||
className="username-input"
|
||||
type="text"
|
||||
value={currentData.username}
|
||||
onChange={handleUsernameChange}
|
||||
value={currentData.display_name}
|
||||
onChange={handleDisplayNameChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Имя пользователя"
|
||||
placeholder="Имя"
|
||||
/>
|
||||
</div>
|
||||
{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}
|
||||
<Section
|
||||
type="bio"
|
||||
icon="info--filled"
|
||||
label="О себе:"
|
||||
value={currentData.bio}
|
||||
onChange={handleBioChange}
|
||||
readOnly={!currentData.isOwnProfile}
|
||||
placeholder="Нет информации о себе"
|
||||
textArea
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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,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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user