Merge branch 'feature/usernames'

This commit is contained in:
2025-10-22 22:15:44 +03:00
Unverified
45 changed files with 2271 additions and 419 deletions
+29 -6
View File
@@ -4,14 +4,21 @@ from contextlib import asynccontextmanager
import subprocess
import sys
import os
from constants import DATABASE_URL
from routes import account, messaging, profile, push, webrtc
import logging
from models import User
from constants import OWNER_USERNAME
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
logger = logging.getLogger("uvicorn.error")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup - run migration in separate process to avoid logging interference
try:
print("Starting database migration check...")
logger.info("Starting database migration check...")
# Run migration in a separate process
subprocess.run(
[
@@ -20,17 +27,33 @@ async def lifespan(app: FastAPI):
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
],
cwd=os.path.dirname(os.path.abspath(__file__))
# No capture_output - let it stream to terminal in real-time
# No text=True - let it use the terminal's encoding
)
except Exception as e:
print(f"Failed to run database migrations: {e}")
logger.error(f"Failed to run database migrations: {e}")
raise
try:
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
with SessionLocal() as db:
# Find the owner user
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
if owner and not owner.verified:
owner.verified = True
db.commit()
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
elif owner and owner.verified:
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
else:
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
except Exception as e:
logger.error(f"Failed to ensure owner verification: {e}")
yield
# Shutdown (if needed in the future)
# logger.info("Application shutdown")
# Инициализация FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan)
+16
View File
@@ -35,4 +35,20 @@ def get_current_user(
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is suspended
if user.suspended:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended",
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
)
# Check if user is deleted
if user.deleted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account deleted",
)
return user
+100 -12
View File
@@ -102,6 +102,21 @@ def run_migrations():
logger.info(f"No new migrations needed or error creating migration: {e}")
pass
# Check if database is in an inconsistent state (has alembic_version but no tables)
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import text, inspect
inspector = inspect(connection)
existing_tables = inspector.get_table_names()
# Check if we have alembic_version but no actual tables
if 'alembic_version' in existing_tables and len(existing_tables) == 1:
logger.info("Database has alembic_version but no actual tables - resetting migration state...")
# Clear alembic_version and start fresh
connection.execute(text("DELETE FROM alembic_version"))
connection.commit()
logger.info("Reset migration state - will create fresh migration")
# Run the upgrade command
logger.info("Running database migrations...")
try:
@@ -116,6 +131,40 @@ def run_migrations():
from sqlalchemy import text
connection.execute(text("DELETE FROM alembic_version"))
connection.commit()
# Set the correct revision in alembic_version table
current_dir = os.path.dirname(os.path.abspath(__file__))
versions_dir = os.path.join(current_dir, "alembic", "versions")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if migration_files:
# Get the latest migration file and extract its revision ID
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
with open(migration_path, 'r') as f:
content = f.read()
# Extract revision ID from the file
import re
revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match:
revision_id = revision_match.group(1)
logger.info(f"Setting alembic_version to {revision_id}")
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')"))
connection.commit()
# Try upgrade again
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.")
elif "no such table" in str(upgrade_error).lower():
logger.info("Database tables missing - resetting migration state...")
# Clear the alembic_version table and start fresh
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import text
connection.execute(text("DELETE FROM alembic_version"))
connection.commit()
# Try upgrade again
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.")
@@ -134,19 +183,37 @@ def run_migrations():
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
connection.commit()
# Remove any existing migration files to start fresh
# Check if we have existing migration files
versions_dir = os.path.join(current_dir, "alembic", "versions")
for file in os.listdir(versions_dir):
if file.endswith('.py') and not file.startswith('__'):
os.remove(os.path.join(versions_dir, file))
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
# Create a completely fresh migration with full schema
logger.info("Creating fresh migration with complete schema...")
_create_complete_migration(alembic_cfg)
# Run the migration
command.upgrade(alembic_cfg, "head")
logger.info("Automated recovery completed successfully.")
if migration_files:
# We have migration files, just fix the alembic_version table
logger.info("Found existing migration files, fixing alembic_version table...")
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
with open(migration_path, 'r') as f:
content = f.read()
import re
revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match:
revision_id = revision_match.group(1)
logger.info(f"Setting alembic_version to {revision_id}")
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}')"))
connection.commit()
# Try upgrade again
command.upgrade(alembic_cfg, "head")
logger.info("Automated recovery completed successfully.")
else:
# No migration files, create fresh ones
logger.info("No migration files found, creating fresh migration...")
_create_complete_migration(alembic_cfg)
# Run the migration
command.upgrade(alembic_cfg, "head")
logger.info("Automated recovery completed successfully.")
except Exception as recovery_error:
logger.error(f"Automated recovery failed: {recovery_error}")
@@ -458,7 +525,28 @@ def _create_database_directly():
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)
"""))
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
# Get the correct revision ID from existing migration files
current_dir = os.path.dirname(os.path.abspath(__file__))
versions_dir = os.path.join(current_dir, "alembic", "versions")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
if migration_files:
latest_migration = max(migration_files)
migration_path = os.path.join(versions_dir, latest_migration)
with open(migration_path, 'r') as f:
content = f.read()
import re
revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match:
revision_id = revision_match.group(1)
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
connection.commit()
+13 -2
View File
@@ -13,12 +13,17 @@ 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)
online = Column(Boolean, default=False)
last_seen = Column(DateTime, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
verified = Column(Boolean, default=False)
suspended = Column(Boolean, default=False)
suspension_reason = Column(Text, nullable=True)
deleted = Column(Boolean, default=False)
messages = relationship("Message", back_populates="author", lazy="select")
@@ -149,6 +154,7 @@ class LoginRequest(BaseModel):
class RegisterRequest(BaseModel):
username: str
display_name: str
password: str
confirm_password: str
@@ -178,11 +184,16 @@ class PushSubscriptionRequest(BaseModel):
class UserProfileResponse(BaseModel):
id: int
username: str
display_name: str
profile_picture: str | None
bio: str | None
online: bool
last_seen: datetime
created_at: datetime
last_seen: datetime | None
created_at: datetime | None
verified: bool
suspended: bool
suspension_reason: str | None
deleted: bool
class Config:
from_attributes = True
+22 -4
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,9 +17,14 @@ 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
"admin": user.username == OWNER_USERNAME,
"verified": user.verified,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
"deleted": user.deleted or False
}
@router.get("/check_auth")
@@ -58,6 +63,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 +81,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):
@@ -105,11 +117,17 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
)
hashed_password = get_password_hash(password)
# Set verified=True for the owner (first user to register)
is_owner = not owner_exists and username == OWNER_USERNAME
new_user = User(
username=username,
display_name=display_name,
password_hash=hashed_password,
online=True,
last_seen=datetime.now()
last_seen=datetime.now(),
verified=is_owner
)
db.add(new_user)
+47 -4
View File
@@ -48,17 +48,29 @@ 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
})
# Handle deleted users
if msg.author.deleted:
username = f"Deleted User #{msg.author.id}"
profile_picture = None
verified = False
else:
username = msg.author.display_name
profile_picture = msg.author.profile_picture
verified = msg.author.verified
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,
"profile_picture": msg.author.profile_picture,
"username": username,
"profile_picture": profile_picture,
"verified": verified,
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
"reactions": list(reactions_dict.values()),
"files": [
@@ -88,9 +100,21 @@ 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
})
# Get sender info for verified status
from models import User
from dependencies import get_db
db = next(get_db())
sender = db.query(User).filter(User.id == envelope.sender_id).first()
# Handle deleted users
if sender and sender.deleted:
sender_verified = False
else:
sender_verified = sender.verified if sender else False
return {
"id": envelope.id,
"senderId": envelope.sender_id,
@@ -101,6 +125,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
"iv2": envelope.iv2_b64,
"wrappedMk": envelope.wrapped_mk_b64,
"timestamp": envelope.timestamp.isoformat(),
"verified": sender_verified,
"reactions": list(reactions_dict.values()),
"files": [
{
@@ -1228,6 +1253,24 @@ class MessaggingSocketManager:
if self.user_by_ws.get(websocket) == user_id:
await websocket.send_json(message)
async def send_suspension_to_user(self, user_id: int, reason: str):
"""Send suspension message to user's WebSocket connections"""
message = {
"type": "suspended",
"data": {
"reason": reason
}
}
await self.send_to_user(user_id, message)
async def send_deletion_to_user(self, user_id: int):
"""Send account deletion message to user's WebSocket connections"""
message = {
"type": "account_deleted",
"data": {}
}
await self.send_to_user(user_id, message)
async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str):
"""Broadcast status change to all connections that are subscribed to this user"""
message = {
+291 -10
View File
@@ -3,6 +3,7 @@ import re
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
from PIL import Image
import os
import uuid
@@ -11,12 +12,16 @@ 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
from similarity import is_user_similar_to_verified
from messaging import messagingManager
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 +107,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 +127,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 +169,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,9 +218,269 @@ 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")
# Handle deleted users
if user.deleted:
return UserProfileResponse(
id=user.id,
username="deleted",
display_name="Deleted User",
profile_picture=None,
bio=None,
online=False,
last_seen=None, # Clear last seen timestamp
created_at=None, # Clear member since timestamp
verified=False,
suspended=False,
suspension_reason=None,
deleted=True
)
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,
verified=user.verified,
suspended=user.suspended or False,
suspension_reason=user.suspension_reason,
deleted=user.deleted or False
)
@router.post("/user/{user_id}/verify")
async def verify_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Toggle verification status for a user (owner only)
"""
# Only user with ID 1 (owner) can verify users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only owner can verify users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Toggle verification status
target_user.verified = not target_user.verified
db.commit()
return {
"verified": target_user.verified,
"message": f"User verification {'enabled' if target_user.verified else 'disabled'}"
}
@router.get("/user/check-similarity/{user_id}")
async def check_user_similarity(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Check if a user is similar to any verified user
"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Get all verified users
verified_users = db.query(User).filter(User.verified == True).all()
verified_users_data = [
{"username": user.username, "display_name": user.display_name}
for user in verified_users
]
# Check similarity
is_similar, similar_to = is_user_similar_to_verified(
target_user.username,
target_user.display_name,
verified_users_data
)
return {
"isSimilar": is_similar,
"similarTo": similar_to if is_similar else None
}
# Admin endpoints for user management
class SuspendUserRequest(BaseModel):
reason: str
@router.post("/user/{user_id}/suspend")
async def suspend_user(
user_id: int,
request: SuspendUserRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Suspend a user account (admin only)
"""
# Only user with ID 1 (admin) can suspend users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only admin can suspend users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot suspend admin
if target_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot suspend admin account")
# Suspend the user
target_user.suspended = True
target_user.suspension_reason = request.reason
db.commit()
# Send WebSocket suspension message
try:
await messagingManager.send_suspension_to_user(user_id, request.reason)
except Exception as e:
# Log error but don't fail the request
pass
return {
"status": "success",
"message": f"User {target_user.username} has been suspended",
"reason": request.reason
}
@router.post("/user/{user_id}/unsuspend")
async def unsuspend_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Unsuspend a user account (admin only)
"""
# Only user with ID 1 (admin) can unsuspend users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only admin can unsuspend users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Unsuspend the user
target_user.suspended = False
target_user.suspension_reason = None
db.commit()
return {
"status": "success",
"message": f"User {target_user.username} has been unsuspended"
}
@router.post("/user/{user_id}/delete")
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Delete a user account (admin only) - preserves messages/DMs/reactions/files
"""
# Only user with ID 1 (admin) can delete users
if current_user.id != 1:
raise HTTPException(status_code=403, detail="Only admin can delete users")
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot delete admin
if target_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot delete admin account")
# Mark user as deleted and clear sensitive data
target_user.deleted = True
target_user.display_name = f"Deleted User #{user_id}"
target_user.bio = None
target_user.password_hash = ""
target_user.username = f"deleted_{user_id}"
target_user.profile_picture = None
target_user.last_seen = None # Clear last seen timestamp
target_user.created_at = None # Clear member since timestamp
# Delete profile picture file if exists
if target_user.profile_picture and target_user.profile_picture.startswith("/api/profile-picture/"):
try:
import os
filename = target_user.profile_picture.split("/")[-1]
filepath = os.path.join("data/uploads/pfp", filename)
if os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
# Log error but don't fail the request
pass
# Dynamic deletion of all non-whitelist data
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
try:
inspector = inspect(db.bind)
all_tables = inspector.get_table_names()
for table_name in all_tables:
if table_name in WHITELIST_TABLES or table_name == "user":
continue
# Check if table has user_id column
columns = inspector.get_columns(table_name)
has_user_id = any(col['name'] == 'user_id' for col in columns)
if has_user_id:
# Delete all records for this user
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
db.commit()
except Exception as e:
# Log error and rollback
db.rollback()
raise HTTPException(status_code=500, detail="Failed to delete user data")
# Send WebSocket deletion message
try:
await messagingManager.send_deletion_to_user(user_id)
except Exception as e:
# Log error but don't fail the request
pass
return {
"status": "success",
"message": f"User {target_user.username} has been deleted"
}
+147
View File
@@ -0,0 +1,147 @@
"""
Similarity detection utilities for username and display name comparison.
Implements both edit distance and visual similarity detection.
"""
def levenshtein_distance(s1: str, s2: str) -> int:
"""Calculate Levenshtein distance between two strings."""
if len(s1) < len(s2):
return levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = list(range(len(s2) + 1))
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def check_visual_similarity(s1: str, s2: str) -> bool:
"""
Check if two strings are visually similar using common homoglyphs.
Returns True if strings are visually similar.
"""
if len(s1) != len(s2):
return False
# Common homoglyph mappings
homoglyphs = {
'0': ['O', 'o', 'Q'],
'O': ['0', 'o', 'Q'],
'o': ['0', 'O', 'Q'],
'1': ['l', 'I', '|'],
'l': ['1', 'I', '|'],
'I': ['1', 'l', '|'],
'5': ['S', 's'],
'S': ['5', 's'],
's': ['5', 'S'],
'6': ['G', 'g'],
'G': ['6', 'g'],
'g': ['6', 'G'],
'8': ['B', 'b'],
'B': ['8', 'b'],
'b': ['8', 'B'],
'9': ['g', 'q'],
'g': ['9', 'q'],
'q': ['9', 'g'],
'2': ['Z', 'z'],
'Z': ['2', 'z'],
'z': ['2', 'Z'],
'3': ['E'],
'E': ['3'],
'4': ['A'],
'A': ['4'],
'7': ['T', 't'],
'T': ['7', 't'],
't': ['7', 'T'],
}
for i in range(len(s1)):
c1, c2 = s1[i], s2[i]
if c1 == c2:
continue
# Check if characters are homoglyphs
if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \
(c2 in homoglyphs and c1 in homoglyphs[c2]):
continue
return False
return True
def check_username_similarity(username1: str, username2: str) -> bool:
"""
Check if two usernames are similar using both edit distance and visual similarity.
Returns True if usernames are considered similar.
"""
if username1 == username2:
return False
# Check edit distance (Levenshtein distance <= 2)
edit_distance = levenshtein_distance(username1.lower(), username2.lower())
if edit_distance <= 2:
return True
# Check visual similarity
if check_visual_similarity(username1, username2):
return True
return False
def check_display_name_similarity(display_name1: str, display_name2: str) -> bool:
"""
Check if two display names are similar using both edit distance and visual similarity.
Returns True if display names are considered similar.
"""
if display_name1 == display_name2:
return False
# Check edit distance (Levenshtein distance <= 2)
edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower())
if edit_distance <= 2:
return True
# Check visual similarity
if check_visual_similarity(display_name1, display_name2):
return True
return False
def is_user_similar_to_verified(user_username: str, user_display_name: str,
verified_users: list[dict]) -> tuple[bool, str]:
"""
Check if a user is similar to any verified user.
Args:
user_username: Username to check
user_display_name: Display name to check
verified_users: List of verified user dictionaries with 'username' and 'display_name' keys
Returns:
Tuple of (is_similar, similar_to_username)
"""
for verified_user in verified_users:
verified_username = verified_user.get('username', '')
verified_display_name = verified_user.get('display_name', '')
# Check username similarity
if check_username_similarity(user_username, verified_username):
return True, verified_username
# Check display name similarity
if check_display_name_similarity(user_display_name, verified_display_name):
return True, verified_username
return False, ""
+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
+69 -18
View File
@@ -1,10 +1,12 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { BrowserRouter, Routes, Route, useNavigate, matchRoutes, type RouteObject } from "react-router-dom";
import { ElectronTitleBar } from "./Electron";
import { useAppState } from "./pages/chat/state";
import { useEffect, useState, lazy } from "react";
import ProtectedRoute from "./pages/ProtectedRoute";
import { lazy, useEffect, useState } from "react";
import { parseProfileLink } from "./core/profileLinks";
import NotFoundPage from "./pages/not-found/NotFoundPage";
import ProtectedRoute from "./pages/ProtectedRoute";
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
// Lazy load route components
const HomePage = lazy(() => import("./pages/home/HomePage"));
@@ -12,36 +14,85 @@ const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
const routeConfig: RouteObject[] = [
{ path: "/", element: <HomePage /> },
{ path: "/login", element: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ path: "/download-app", element: <DownloadAppPage /> },
{
path: "/chat",
element: (
<ProtectedRoute>
<ChatPage />
</ProtectedRoute>
)
},
{ path: "*", element: <SmartCatchAll /> }
];
function SmartCatchAll() {
const navigate = useNavigate();
const [showNotFound, setShowNotFound] = useState(false);
function isValidRoute(path: string): boolean {
const validRoutes = routeConfig.filter(route => route.path !== "*");
const matches = matchRoutes(validRoutes, path);
return Boolean(matches && matches.length > 0);
}
useEffect(() => {
if (isValidRoute(location.pathname)) {
setShowNotFound(false);
return;
}
const profileInfo = parseProfileLink(); // No URL specified intentionally to let it use the current URL
if (profileInfo) {
setShowNotFound(false);
navigate("/chat", {
replace: true,
state: { profileInfo }
});
} else {
setShowNotFound(true);
}
}, [navigate]);
// Show 404 page
if (showNotFound) {
return <NotFoundPage />;
}
}
export default function App() {
const { restoreUserFromStorage } = useAppState();
const { restoreUserFromStorage, user } = useAppState();
const [authReady, setAuthReady] = useState(false);
// Restore user from localStorage on app initialization
useEffect(() => {
restoreUserFromStorage().finally(() => {
setAuthReady(true);
});
}, [restoreUserFromStorage]);
return authReady && (
<BrowserRouter>
<ElectronTitleBar />
<div id="main-wrapper">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/download-app" element={<DownloadAppPage />} />
<Route path="/">
<Route path="chat" element={
<ProtectedRoute>
<ChatPage />
</ProtectedRoute>
} />
</Route>
<Route path="*" element={<NotFoundPage />} />
{routeConfig.map((route, index) => (
<Route key={index} path={route.path} element={route.element} />
))}
</Routes>
</div>
{user.isSuspended && (
<SuspensionDialog
reason={user.suspensionReason || "No reason provided"}
open={true}
onOpenChange={() => {}} // Suspended users can't close the dialog
/>
)}
</BrowserRouter>
)
}
+67 -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,64 @@ 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;
}
}
/**
* Toggles verification status for a user (owner only)
*/
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
method: 'POST',
headers: getAuthHeaders(token)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error verifying user:', error);
return null;
}
}
/**
* Checks if a user is similar to any verified user
*/
export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
headers: getAuthHeaders(token)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error checking user similarity:', error);
return null;
}
}
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import useCombinedRefs from '@/core/hooks/useCombinedRefs';
import { id } from '@/utils/utils';
interface AutoResizeInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
autoresizing?: true;
placeholderMinWidth?: boolean;
onAutosize?: (width: number) => void;
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
autoresizing?: false;
placeholderMinWidth?: false;
onAutosize?: undefined;
}
export function Input({
autoresizing = false,
placeholderMinWidth = false,
onAutosize,
style: inputStyle,
...inputProps
}: AutoResizeInputProps | InputProps) {
const [inputWidth, setInputWidth] = useState(0);
const sizerRef = useRef<HTMLDivElement>(null);
const placeholderSizerRef = useRef<HTMLDivElement>(null);
const [inputRef, inputElement] = useCombinedRefs<HTMLInputElement>();
const sizerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
visibility: 'hidden',
height: 0,
overflow: 'scroll',
whiteSpace: 'pre',
};
const copyStyles = useCallback((styles: CSSStyleDeclaration, node: HTMLElement) => {
node.style.fontSize = styles.fontSize;
node.style.fontFamily = styles.fontFamily;
node.style.fontWeight = styles.fontWeight;
node.style.fontStyle = styles.fontStyle;
node.style.letterSpacing = styles.letterSpacing;
node.style.textTransform = styles.textTransform;
}, []);
const updateInputWidth = useCallback(() => {
if (!sizerRef.current || typeof sizerRef.current.scrollWidth === 'undefined') {
return;
}
let newInputWidth: number;
if (inputProps.placeholder && (!inputProps.value || (inputProps.value && placeholderMinWidth))) {
const sizerWidth = sizerRef.current.scrollWidth;
const placeholderWidth = placeholderSizerRef.current?.scrollWidth || 0;
newInputWidth = Math.max(sizerWidth, placeholderWidth) + 2;
} else {
newInputWidth = sizerRef.current.scrollWidth + 2;
}
if (newInputWidth !== inputWidth) {
setInputWidth(newInputWidth);
onAutosize?.(newInputWidth);
}
}, [inputProps.placeholder, inputProps.value, inputProps.type, placeholderMinWidth, inputWidth, onAutosize]);
const copyInputStyles = useCallback(() => {
if (!inputElement.current || !window.getComputedStyle) {
return;
}
const inputStyles = window.getComputedStyle(inputElement.current);
if (!inputStyles) {
return;
}
copyStyles(inputStyles, sizerRef.current!);
if (placeholderSizerRef.current) {
copyStyles(inputStyles, placeholderSizerRef.current);
}
}, [inputElement]);
useEffect(() => {
if (autoresizing) {
copyInputStyles();
updateInputWidth();
}
}, [autoresizing, copyInputStyles, updateInputWidth]);
useEffect(() => {
if (autoresizing) {
updateInputWidth();
}
}, [inputProps.value, inputProps.placeholder, autoresizing, updateInputWidth]);
return (
<>
<input
{...inputProps}
ref={inputRef}
style={{
boxSizing: 'content-box',
width: autoresizing ? `${inputWidth}px` : undefined,
...inputStyle,
}}
/>
{autoresizing && createPortal(
<>
<div ref={sizerRef} style={sizerStyle}>
{inputProps.defaultValue || inputProps.value || ''}
</div>
{inputProps.placeholder && (
<div ref={placeholderSizerRef} style={sizerStyle}>
{inputProps.placeholder}
</div>
)}
</>,
id("root")
)}
</>
);
}
@@ -0,0 +1,9 @@
import type { TextField } from "mdui/components/text-field";
interface TextFieldProps extends React.ComponentPropsWithoutRef<"mdui-text-field"> {
ref?: React.Ref<TextField>
}
export function MaterialTextField({ ref, ...props }: TextFieldProps) {
return <mdui-text-field autocomplete="off" ref={ref as React.Ref<HTMLElement>} {...props} />
}
@@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state";
interface StatusBadgeProps {
verified: boolean;
userId?: number;
size?: "small" | "medium" | "large";
}
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) {
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false);
const { user } = useAppState();
const className = `status-badge ${size}`;
// Check similarity for unverified users
useEffect(() => {
if (!verified && userId && user.authToken) {
checkUserSimilarity(userId, user.authToken)
.then(result => {
setIsSimilarToVerified(result?.isSimilar || false);
})
.catch(error => {
console.error('Error checking similarity:', error);
setIsSimilarToVerified(false);
});
} else {
setIsSimilarToVerified(false);
}
}, [verified, userId, user.authToken]);
if (verified) {
return (
<span className={`${className} verified`} title="Подтверждённый аккаунт">
<mdui-icon name="verified--filled" />
</span>
);
}
if (isSimilarToVerified) {
return (
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
<mdui-icon name="warning" />
</span>
);
}
// Don't show anything if not verified and not similar
return null;
}
@@ -0,0 +1,69 @@
import { createPortal } from "react-dom";
import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence, type Transition } from "motion/react";
interface StyledDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
onBackdropClick?: () => void;
className?: string;
}
export function StyledDialog({
open,
onOpenChange,
children,
onBackdropClick,
className = ""
}: StyledDialogProps) {
const transition: Transition = { duration: 0.3, type: "tween", ease: "easeInOut" };
// Handle ESC key
useEffect(() => {
if (open) {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") {
onOpenChange(false);
}
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}
}, [open, onOpenChange]);
return createPortal(
<AnimatePresence>
{open && (
<motion.div
className={`styled-dialog-backdrop ${className}`}
onClick={(e) => {
if (e.target === e.currentTarget) {
if (onBackdropClick) {
onBackdropClick();
} else {
onOpenChange(false);
}
}
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}>
<motion.div
className={`styled-dialog ${className}`}
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={transition}>
<div className="styled-dialog-content">
{children}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.getElementById("root")!
);
}
@@ -1,9 +0,0 @@
import type { TextField } from "mdui/components/text-field";
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
return <mdui-text-field
autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
}
@@ -0,0 +1,46 @@
import { useState } from "react";
import { verifyUser } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state";
interface VerifyButtonProps {
userId: number;
verified: boolean;
onVerificationChange?: (verified: boolean) => void;
}
export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) {
const [isVerifying, setIsVerifying] = useState(false);
const { user } = useAppState();
// Only show for owner
if (user.currentUser?.id !== 1) {
return null;
}
async function handleVerifyToggle() {
if (!user.authToken || isVerifying) return;
setIsVerifying(true);
try {
const result = await verifyUser(userId, user.authToken);
if (result) {
onVerificationChange?.(result.verified);
}
} catch (error) {
console.error('Error toggling verification:', error);
} finally {
setIsVerifying(false);
}
}
return (
<mdui-button
variant="filled"
loading={isVerifying}
onClick={handleVerifyToggle}
title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"}
>
{verified ? "Отменить подтверждение" : "Подтвердить"}
</mdui-button>
);
}
@@ -0,0 +1,45 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Base Styled Dialog Styles
.styled-dialog-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 30px;
box-sizing: border-box;
.styled-dialog {
width: 100%;
max-width: 500px;
max-height: calc(100vh - 60px);
background: $color-dark-surface-container;
border-radius: 16px;
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
0 9px 46px 8px rgba(0, 0, 0, 0.12),
0 11px 15px -7px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
// Framer Motion handles all animations
// Removed CSS transitions to prevent interference
.styled-dialog-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
width: 100%;
position: relative;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* @fileoverview Utility functions for handling profile links
* @description Functions to parse and handle profile links in markdown content.
* Supports two formats:
* - fromchat.ru/@username (e.g., fromchat.ru/@john_doe)
* - fromchat.ru/?u=<userId> (e.g., fromchat.ru/?u=123)
* @author Cursor
* @version 1.0.0
*/
import escapeStringRegexp from "escape-string-regexp";
/**
* Parses a profile link URL and extracts user information
* @param url - The URL to parse
* @returns Object with user ID and username if it's a valid profile link, null otherwise
*/
export function parseProfileLink(url: string = location.pathname): { userId?: number; username?: string } | null {
try {
let host: string = url.startsWith("@") ? "" : !url.startsWith("/") ? "https://fromchat.ru/" : "/";
// Handle fromchat.ru/@username format
const usernameMatch = url.match(new RegExp(`${escapeStringRegexp(host)}@([a-zA-Z0-9_-]+)`));
if (usernameMatch) {
return { username: usernameMatch[1] };
}
// Handle fromchat.ru/?u=<userId> format
const userIdMatch = url.match(new RegExp(`${escapeStringRegexp(host)}\\?u=(\\d+)`));
if (userIdMatch) {
return { userId: Number(userIdMatch[1]) };
}
return null;
} catch (error) {
console.error('Error parsing profile link:', error);
return null;
}
}
+10
View File
@@ -62,12 +62,14 @@ export interface Reaction {
export interface Message {
id: number;
user_id: number;
username: string;
content: string;
is_read: boolean;
is_edited: boolean;
timestamp: string;
profile_picture?: string;
verified?: boolean;
reply_to?: Message;
files?: Attachment[];
reactions?: Reaction[];
@@ -111,9 +113,14 @@ export interface User {
last_seen: string;
online: boolean;
username: string;
display_name: string;
admin?: boolean;
bio?: string;
profile_picture: string;
verified?: boolean;
suspended?: boolean;
suspension_reason?: string | null;
deleted?: boolean;
}
/**
@@ -130,11 +137,13 @@ export interface User {
export interface UserProfile {
id: number;
username: string;
display_name: string;
profile_picture?: string;
bio?: string;
online: boolean;
last_seen: string;
created_at: string;
verified?: boolean;
}
// ----------
@@ -163,6 +172,7 @@ export interface LoginRequest {
*/
export interface RegisterRequest {
username: string;
display_name: string;
password: string;
confirm_password: string;
}
+14
View File
@@ -11,6 +11,7 @@ import { delay } from "@/utils/utils";
import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager";
import { useAppState } from "@/pages/chat/state";
/**
* Creates a new WebSocket connection to the chat server
@@ -129,6 +130,19 @@ websocket.addEventListener("message", (e) => {
typingManager.handleDmTyping(response as any);
} else if (response.type === "stopDmTyping") {
typingManager.handleStopDmTyping(response as any);
} else if (response.type === "suspended") {
// Handle account suspension
const { setSuspended } = useAppState.getState();
const reason = response.data?.reason || "No reason provided";
setSuspended(reason);
// Close WebSocket connection
websocket.close();
} else if (response.type === "account_deleted") {
// Handle account deletion - silent logout
const { logout } = useAppState.getState();
logout();
// Close WebSocket connection
websocket.close();
}
// Route message to global handler if set
+98
View File
@@ -81,4 +81,102 @@ button, input {
border-left: 3px solid $color-dark-primary;
padding: 0.5rem;
}
}
// Verified badge styles
.verified-badge {
display: inline-flex;
align-items: center;
color: $color-dark-primary;
vertical-align: middle;
user-select: none;
&.small {
font-size: 14px;
width: 14px;
height: 14px;
}
&.medium {
font-size: 18px;
width: 18px;
height: 18px;
}
&.large {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Status badge styles (unified for verified and warning)
.status-badge {
display: inline-flex;
align-items: center;
user-select: none;
&.verified {
color: $color-dark-primary;
}
&.warning {
color: #ff9800; // Orange color for warnings
}
&.small mdui-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&.medium mdui-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&.large mdui-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Profile dialog specific styles
.username-with-badge {
display: flex;
align-items: center;
gap: 8px;
}
.similarity-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background-color: $color-dark-error-container;
color: $color-dark-on-error-container;
border-radius: 8px;
margin: 12px 0;
font-size: 0.9rem;
line-height: 1.4;
}
.verify-section {
margin: 16px 0;
display: flex;
justify-content: center;
}
.search-result-headline {
display: flex;
align-items: center;
gap: 6px;
}
.dm-list-headline {
display: flex;
align-items: center;
gap: 6px;
}
+5
View File
@@ -2,6 +2,7 @@
@use "components";
@use "colors" as *;
@use "material" as *;
@use "../core/components/css/styled-dialog";
@use "fonts/montserrat";
@use "fonts/material-symbols";
@@ -41,4 +42,8 @@ mdui-dialog {
> *:last-child {
margin-block-end: 0;
}
}
mdui-icon {
user-select: none;
}
+11 -2
View File
@@ -7,7 +7,7 @@ import { API_BASE_URL } from "@/core/config";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/TextField";
import { MaterialTextField } from "@/core/components/MaterialTextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { useNavigate } from "react-router-dom";
@@ -98,6 +98,15 @@ export default function LoginPage() {
}
} else {
const data: ErrorResponse = await response.json();
// Check for suspension
if (response.status === 403 && response.headers.get("suspension_reason")) {
const suspensionReason = response.headers.get("suspension_reason");
const setSuspended = useAppState.getState().setSuspended;
setSuspended(suspensionReason || "No reason provided");
return; // Don't show alert, SuspensionDialog will be shown
}
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
}
} catch (error) {
@@ -105,7 +114,7 @@ export default function LoginPage() {
}
}}>
<MaterialTextField
label="Имя пользователя"
label="@Имя пользователя"
id="login-username"
name="username"
variant="outlined"
+28 -3
View File
@@ -6,7 +6,7 @@ import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
import { API_BASE_URL } from "@/core/config";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/TextField";
import { MaterialTextField } from "@/core/components/MaterialTextField";
import { ensureKeysOnLogin } from "@/core/api/authApi";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
@@ -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"
+25
View File
@@ -69,6 +69,9 @@
margin: 10px;
cursor: pointer;
transition: transform 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
&:hover {
transform: scale(1.05);
@@ -375,3 +378,25 @@
justify-content: center;
}
}
// Mention link styling
.message-content {
.mention-link {
color: $color-dark-primary;
text-decoration: none;
font-weight: 500;
border-radius: 4px;
padding: 2px 4px;
transition: all 0.2s ease;
background-color: rgba(145, 206, 244, 0.1); // TODO adjust
&:hover {
background-color: rgba(145, 206, 244, 0.2); // TODO adjust
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
+146 -136
View File
@@ -2,97 +2,58 @@
@use "../../../css/material" as *;
@use "sass:color";
// Profile Dialog Styles
.profile-dialog-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
z-index: 1000;
display: flex;
// Profile Dialog Specific Styles
// Base dialog styles are now in _styled-dialog.scss
.styled-dialog-content {
align-items: center;
justify-content: center;
padding: 30px;
box-sizing: border-box;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
&.open {
opacity: 1;
visibility: visible;
}
}
.profile-dialog {
width: 100%;
max-width: 500px;
max-height: calc(100vh - 60px);
background: $color-dark-surface-container;
border-radius: 16px;
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
0 9px 46px 8px rgba(0, 0, 0, 0.12),
0 11px 15px -7px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
transform: scale(0.9);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
&.open {
transform: scale(1);
opacity: 1;
.error-message {
color: $color-dark-error;
font-size: small;
}
.profile-dialog-content {
flex: 1;
overflow-y: auto;
.profile-picture-section {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 16px;
.profile-picture-section {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 16px;
.profile-picture {
width: 120px;
height: 120px;
border-radius: 60px;
object-fit: cover;
border: 3px solid $color-dark-outline;
}
.profile-picture-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 60px;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.profile-picture {
width: 120px;
height: 120px;
border-radius: 60px;
object-fit: cover;
border: 3px solid $color-dark-outline;
}
.username-section {
text-align: center;
.profile-picture-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 60px;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
.username-section {
text-align: center;
.username-with-badge {
gap: 0;
.username-input {
background: none;
@@ -108,81 +69,106 @@
cursor: text;
}
}
}
.online-status-section {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.online-status-section {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-dark-primary;
.online-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color-dark-primary;
&.offline {
background: $color-dark-on-surface-variant;
}
}
.status-text {
font-size: 0.875rem;
color: $color-dark-on-surface;
&.offline {
background: $color-dark-on-surface-variant;
}
}
.profile-sections {
margin: 16px;
border-radius: 24px;
.status-text {
font-size: 0.875rem;
color: $color-dark-on-surface;
}
}
.profile-sections {
margin: 16px;
display: flex;
flex-direction: column;
gap: 4px;
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;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
width: calc(100% - (16px * 2));
box-sizing: border-box;
flex-direction: row;
gap: 16px;
align-items: center;
transition: outline 0.1s ease;
outline: 0px solid transparent;
outline-offset: -1px;
.section {
background: $color-dark-surface-container-high;
border-radius: 10px;
padding: 8px 16px;
.content-container {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
flex-direction: column;
gap: 4px;
width: 100%;
.content-container {
display: flex;
flex-direction: column;
gap: 4px;
.label {
font-size: small;
color: $color-dark-on-surface-variant;
user-select: none;
}
.value {
color: $color-dark-on-surface;
font-size: medium;
width: 100%;
line-height: 1.4;
font-family: inherit;
cursor: text;
outline: none;
background: transparent;
border: none;
caret-color: $color-dark-primary;
.label {
font-size: small;
&::placeholder {
color: $color-dark-on-surface-variant;
}
}
}
.value {
color: $color-dark-on-surface;
font-size: medium;
width: 100%;
line-height: 1.4;
font-family: inherit;
cursor: text;
outline: none;
background: transparent;
border: none;
caret-color: $color-dark-primary;
// First and last section
&:first-child {
border-top-left-radius: $edge-radius;
border-top-right-radius: $edge-radius;
}
&::placeholder {
color: $color-dark-on-surface-variant;
}
}
&: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;
}
}
}
}
}
.styled-dialog {
.profile-dialog-fab {
position: absolute;
bottom: 24px;
@@ -195,4 +181,28 @@
transform: translateY(0);
}
}
// Admin Actions Section
.admin-actions-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
.admin-actions-header {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px 0;
color: #f44336;
}
.admin-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-buttons mdui-button {
width: 100%;
}
}
@@ -0,0 +1,72 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Suspension Dialog Content Styles
.suspension-dialog-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
padding: 24px;
.suspension-icon-section {
margin-bottom: 24px;
.suspension-icon {
font-size: 80px;
color: #f44336;
display: block;
}
}
.suspension-text {
max-width: 400px;
.suspension-headline {
font-size: 28px;
font-weight: 600;
margin: 0 0 16px 0;
color: #f44336;
line-height: 1.2;
}
.suspension-body {
font-size: 16px;
margin: 0 0 20px 0;
color: $color-dark-on-surface;
line-height: 1.5;
}
.suspension-reason {
text-align: left;
strong {
color: $color-dark-on-surface;
font-weight: 600;
}
.suspension-reason-text {
background: $color-dark-surface-container-high;
border-radius: 12px;
padding: 16px;
margin-top: 5px;
font-size: 14px;
color: $color-dark-on-surface;
text-align: left;
}
}
.suspension-secondary {
font-size: 14px;
margin: 20px 0 0 0;
color: $color-dark-on-surface-variant;
line-height: 1.4;
a {
margin-left: 4px;
}
}
}
}
+2 -1
View File
@@ -10,4 +10,5 @@
@use "animations";
@use "callWindow";
@use "profile-dialog";
@use "typing-indicators";
@use "typing-indicators";
@use "suspension-dialog";
+1 -1
View File
@@ -69,7 +69,6 @@ export function useDM() {
try {
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
console.log(lastPlaintext);
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
@@ -174,6 +173,7 @@ export function useDM() {
decryptedMessages.push({
id: env.id,
user_id: env.senderId,
content: text,
username: username,
timestamp: env.timestamp,
+42 -9
View File
@@ -19,11 +19,16 @@ export type CallStatus = "calling" | "connecting" | "active" | "ended";
export interface ProfileDialogData {
userId?: number;
username?: string;
display_name?: string;
profilePicture?: string;
bio?: string;
memberSince?: string;
online?: boolean;
isOwnProfile: boolean;
verified?: boolean;
suspended?: boolean;
suspension_reason?: string | null;
deleted?: boolean;
}
interface ActiveDM {
@@ -71,6 +76,8 @@ interface ChatState {
export interface UserState {
currentUser: User | null;
authToken: string | null;
isSuspended: boolean;
suspensionReason: string | null;
}
interface AppState {
@@ -110,6 +117,7 @@ interface AppState {
setUser: (token: string, user: User) => void;
logout: () => void;
restoreUserFromStorage: () => Promise<void>;
setSuspended: (reason: string) => void;
// Profile dialog state
setProfileDialog: (data: ProfileDialogData | null) => void;
@@ -224,13 +232,17 @@ export const useAppState = create<AppState>((set, get) => ({
// User state
user: {
currentUser: null,
authToken: null
authToken: null,
isSuspended: false,
suspensionReason: null
},
setUser: (token: string, user: User) => {
set(() => ({
user: {
currentUser: user,
authToken: token
authToken: token,
isSuspended: user.suspended || false,
suspensionReason: user.suspension_reason || null
}
}));
@@ -254,8 +266,6 @@ export const useAppState = create<AppState>((set, get) => ({
credentials: token
},
data: {}
}).then(() => {
console.log("Ping succeeded")
})
} catch {}
},
@@ -277,7 +287,9 @@ export const useAppState = create<AppState>((set, get) => ({
set(() => ({
user: {
currentUser: null,
authToken: null
authToken: null,
isSuspended: false,
suspensionReason: null
}
}));
},
@@ -294,10 +306,25 @@ export const useAppState = create<AppState>((set, get) => ({
const user: User = await response.json();
restoreKeys();
// Check if user is suspended
if (user.suspended) {
set(() => ({
user: {
currentUser: user,
authToken: token,
isSuspended: true,
suspensionReason: user.suspension_reason || null
}
}));
return; // Don't initialize managers or notifications for suspended users
}
set(() => ({
user: {
currentUser: user,
authToken: token
authToken: token,
isSuspended: false,
suspensionReason: null
}
}));
@@ -313,8 +340,6 @@ export const useAppState = create<AppState>((set, get) => ({
credentials: token
},
data: {}
}).then(() => {
console.log("Ping succeeded")
})
} catch {}
@@ -677,5 +702,13 @@ export const useAppState = create<AppState>((set, get) => ({
dmTypingUsers: newDmTypingUsers
}
};
})
}),
setSuspended: (reason: string) => set((state) => ({
user: {
...state.user,
isSuspended: true,
suspensionReason: reason
}
}))
}));
+60
View File
@@ -3,9 +3,69 @@ import { RightPanel } from "./right/RightPanel";
import "@/pages/chat/css/chat.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { CallWindow } from "./right/calls/CallWindow";
import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAppState } from "@/pages/chat/state";
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
const location = useLocation();
const navigate = useNavigate();
const { user, setProfileDialog } = useAppState();
const processedProfile = useRef<string | null>(null);
// Handle profile links ONLY from navigation state (from SmartCatchAll)
useEffect(() => {
async function handleProfileLink() {
if (!user.authToken) return;
// Only process profile links that come from navigation state (SmartCatchAll)
// This prevents re-processing on page refresh
if (!location.state?.profileInfo) return;
const profileInfo = location.state.profileInfo;
// Create a unique key for this profile
const profileKey = profileInfo.userId
? `user_${profileInfo.userId}`
: `username_${profileInfo.username}`;
// Skip if we've already processed this exact profile
if (processedProfile.current === profileKey) return;
processedProfile.current = profileKey; // Mark this specific profile as processed
try {
let userProfile;
if (profileInfo.userId) {
// Fetch by user ID
userProfile = await fetchUserProfileById(user.authToken, profileInfo.userId);
} else if (profileInfo.username) {
// Fetch by username
userProfile = await fetchUserProfile(user.authToken, profileInfo.username);
}
if (userProfile) {
setProfileDialog({
...userProfile,
userId: userProfile.id,
memberSince: userProfile.created_at,
isOwnProfile: userProfile.id === user.currentUser?.id
});
}
// Clear the navigation state to prevent re-processing on refresh
navigate(location.pathname, { replace: true, state: null });
} catch (error) {
console.error("Failed to fetch user profile from URL:", error);
}
}
handleProfileLink();
}, [location.state, user.authToken, user.currentUser?.id, setProfileDialog, navigate, location.pathname]);
if (navigateDownloadApp) return navigateDownloadApp;
return (
+385 -148
View File
@@ -1,23 +1,80 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
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 { prompt } from "mdui/functions/prompt";
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
import { RichTextArea } from "@/core/components/RichTextArea";
import { StatusBadge } from "@/core/components/StatusBadge";
import { VerifyButton } from "@/core/components/VerifyButton";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineStatus } from "./right/OnlineStatus";
import { Input } from "@/core/components/Input";
import { StyledDialog } from "@/core/components/StyledDialog";
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);
// Handle dialog open/close based on state
useEffect(() => {
@@ -25,38 +82,24 @@ export function ProfileDialog() {
// Fetch fresh data when opening dialog
fetchFreshProfileData(chat.profileDialog);
} else if (!chat.profileDialog && isOpen) {
// Start close animation
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.remove('open');
dialogRef.current.classList.remove('open');
// Wait for animation to complete before closing
setTimeout(() => {
setIsOpen(false);
}, 300); // Match CSS transition duration
} else {
setIsOpen(false);
}
setIsOpen(false);
}
}, [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,
userId: userProfile.id, // Preserve the userId field
memberSince: userProfile.created_at,
online: userProfile.online,
isOwnProfile: profileData.isOwnProfile
};
}
@@ -72,35 +115,9 @@ export function ProfileDialog() {
setCurrentData(profileData);
setIsOpen(true);
}
};
}
// Trigger transition after component mounts
useEffect(() => {
if (isOpen) {
// Small delay to ensure DOM is ready for transition
const timer = setTimeout(() => {
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.add('open');
dialogRef.current.classList.add('open');
}
}, 10);
return () => clearTimeout(timer);
}
}, [isOpen]);
// Handle ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
handleClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}
}, [isOpen]);
// Subscribe to user's online status when dialog opens
useEffect(() => {
@@ -117,6 +134,14 @@ 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 +152,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({
@@ -142,52 +168,45 @@ export function ProfileDialog() {
confirmText: "Закрыть",
cancelText: "Отмена"
});
triggerCloseAnimation();
closeProfileDialog();
} catch {
// User cancelled, do nothing
}
} else {
triggerCloseAnimation();
}
};
const triggerCloseAnimation = () => {
if (backdropRef.current && dialogRef.current) {
backdropRef.current.classList.remove('open');
dialogRef.current.classList.remove('open');
// Wait for animation to complete before closing
setTimeout(() => {
closeProfileDialog();
}, 300); // Match CSS transition duration
} else {
closeProfileDialog();
}
};
const 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 +221,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,35 +297,150 @@ export function ProfileDialog() {
// Update the original data to match current data
setOriginalData(currentData);
// Close dialog with animation after successful save
triggerCloseAnimation();
// 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 after successful save
closeProfileDialog();
} 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"
});
};
}
if (!isOpen || !currentData) return null;
async function handleSuspend() {
if (!currentData?.userId || !user.authToken) return;
return createPortal(
<div
ref={backdropRef}
className="profile-dialog-backdrop"
onClick={handleBackdropClick}
const isSuspending = !currentData.suspended;
try {
if (isSuspending) {
const reason = await prompt({
headline: "Suspend Account",
description: "Enter the reason for suspending this account:",
confirmText: "Suspend",
cancelText: "Cancel"
});
if (reason) {
const response = await fetch(`/api/user/${currentData.userId}/suspend`, {
method: "POST",
headers: {
"Authorization": `Bearer ${user.authToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ reason })
});
if (response.ok) {
closeProfileDialog();
} else {
const error = await response.json();
console.error("Failed to suspend user:", error);
}
}
} else {
// Unsuspend user
const response = await fetch(`/api/user/${currentData.userId}/unsuspend`, {
method: "POST",
headers: {
"Authorization": `Bearer ${user.authToken}`,
"Content-Type": "application/json"
}
});
if (response.ok) {
closeProfileDialog();
} else {
const error = await response.json();
console.error("Failed to unsuspend user:", error);
}
}
} catch (error) {
console.error(`Failed to ${isSuspending ? 'suspend' : 'unsuspend'} user:`, error);
}
}
async function handleDelete() {
if (!currentData?.userId || !user.authToken) return;
try {
await confirm({
headline: "Delete Account",
description: "This will permanently delete user data but preserve messages and conversations. If the user is online, they will be immediately logged out. This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel"
});
const response = await fetch(`/api/user/${currentData.userId}/delete`, {
method: "POST",
headers: {
"Authorization": `Bearer ${user.authToken}`,
"Content-Type": "application/json"
}
});
if (response.ok) {
closeProfileDialog();
} else {
const error = await response.json();
console.error("Failed to delete user:", error);
}
} catch (error) {
// User cancelled or error occurred
console.error("Failed to delete user:", error);
}
}
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 (!currentData) return null;
return (
<StyledDialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
onBackdropClick={handleClose}
>
<div ref={dialogRef} className="profile-dialog">
<div className="profile-dialog-content">
{/* Profile Picture */}
<div className="profile-picture-section">
<div className="profile-picture-section">
<img
className="profile-picture"
src={currentData.profilePicture || defaultAvatar}
@@ -281,72 +460,132 @@ export function ProfileDialog() {
)}
</div>
{/* Username */}
{currentData.username && (
<div className="username-section">
<input
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
<div className="username-with-badge">
<Input
autoresizing={true}
className="username-input"
type="text"
value={currentData.username}
onChange={handleUsernameChange}
value={currentData.display_name}
onChange={handleDisplayNameChange}
readOnly={!currentData.isOwnProfile}
placeholder="Имя пользователя"
placeholder="Имя" />
<StatusBadge
verified={currentData.verified || false}
userId={currentData.userId}
size="large" />
</div>
{errors.display_name && (
<div className="error-message">{errors.display_name}</div>
)}
</div>
{(currentData?.userId || currentData?.isOwnProfile) && !currentData.deleted && (
<div className="online-status-section">
<OnlineStatus userId={currentData.userId || user.currentUser!.id} />
</div>
)}
{/* Admin Actions Section - Hide for deleted users */}
{!currentData.isOwnProfile && user.currentUser?.id === 1 && !currentData.deleted && (
<div className="admin-actions-section">
<h3 className="admin-actions-header">Admin Actions</h3>
<div className="admin-buttons">
<mdui-button
variant="filled"
color="error"
icon={currentData.suspended ? "check_circle--filled" : "block--filled"}
onClick={handleSuspend}
>
{currentData.suspended ? "Unsuspend Account" : "Suspend Account"}
</mdui-button>
<mdui-button
variant="filled"
color="error"
icon="delete_forever--filled"
onClick={handleDelete}
>
Delete Account
</mdui-button>
<VerifyButton
userId={currentData.userId!}
verified={currentData.verified || false}
onVerificationChange={(verified) => {
setCurrentData({ ...currentData, verified });
}}
/>
</div>
</div>
)}
{/* Verify button for non-admin owner */}
{!currentData.isOwnProfile && currentData.userId && user.currentUser?.id !== 1 && (
<div className="verify-section">
<VerifyButton
userId={currentData.userId}
verified={currentData.verified || false}
onVerificationChange={(verified) => {
setCurrentData({ ...currentData, verified });
}}
/>
</div>
)}
{/* Online Status */}
{currentData?.userId && (
<div className="online-status-section">
<OnlineStatus userId={currentData.userId} />
{/* Hide profile sections for deleted users */}
{!currentData.deleted && (
<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" />
{currentData.bio !== undefined && (
<Section
type="bio"
icon="info--filled"
label="О себе:"
value={currentData.bio}
onChange={handleBioChange}
readOnly={!currentData.isOwnProfile}
placeholder="Нет информации о себе"
textArea />
)}
{currentData.memberSince && (
<Section
type="member-since"
icon="calendar_month--filled"
label="Участник с:"
value={formatDate(currentData.memberSince)}
readOnly={true} />
)}
{currentData.verified && (
<Section
type="verified"
icon="verified--filled"
label="Верификация:"
value="Этот аккаунт - официальное лицо FromChat."
readOnly={true}
/>
)}
</div>
)}
<div className="profile-sections">
{/* 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>
)}
{/* 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>
)}
</div>
</div>
{/* Save FAB */}
{currentData.isOwnProfile && (
<mdui-fab
icon="check"
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
onClick={handleSave}
disabled={isSaving}
/>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
@@ -354,8 +593,6 @@ export function ProfileDialog() {
style={{ display: "none" }}
onChange={handleFileSelect}
/>
</div>
</div>,
document.getElementById("root")!
</StyledDialog>
);
}
@@ -0,0 +1,42 @@
import { StyledDialog } from "@/core/components/StyledDialog";
interface SuspensionDialogProps {
reason: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SuspensionDialog({ reason, open, onOpenChange }: SuspensionDialogProps) {
return (
<StyledDialog
open={open}
onOpenChange={onOpenChange}
>
<div className="suspension-dialog-content">
<div className="suspension-icon-section">
<mdui-icon name="block--filled" className="suspension-icon" />
</div>
<div className="suspension-text">
<h2 className="suspension-headline">Аккаунт заблокирован</h2>
<p className="suspension-body">
Ваш аккаунт был заблокирован за нарушение правил сообщества.
Вы не можете отправлять сообщения или взаимодействовать с другими пользователями.
</p>
{reason && reason !== "No reason provided" && (
<div className="suspension-reason">
<strong>Причина блокировки:</strong>
<div className="suspension-reason-text">
{reason}
</div>
</div>
)}
<p className="suspension-secondary">
Если вы считаете, что блокировка была применена по ошибке,
<a href="https://t.me/denis0001_dev" target="_blank" rel="noopener noreferrer">обратитесь к администратору</a> для рассмотрения вашего случая.
</p>
</div>
</div>
</StyledDialog>
);
}
@@ -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>
@@ -4,10 +4,11 @@ import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "@/core/api/authApi";
import { fetchUserPublicKey } from "@/core/api/dmApi";
import { StatusBadge } from "@/core/components/StatusBadge";
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 {
@@ -19,13 +20,16 @@ interface PublicChat {
interface DMConversation {
id: number;
userId: number;
username: string;
display_name: string;
profile_picture?: string;
online?: boolean;
type: "dm";
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
verified?: boolean;
}
type ChatItem = PublicChat | DMConversation;
@@ -83,7 +87,9 @@ export function UnifiedChatsList() {
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
id: user.id,
userId: user.id, // Add userId field
username: user.username,
display_name: user.display_name,
profile_picture: user.profile_picture,
online: user.online,
type: "dm" as const,
@@ -172,13 +178,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 +193,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 +220,7 @@ export function UnifiedChatsList() {
profilePicture: dmConversation.profile_picture,
online: dmConversation.online || false
});
};
}
if (isLoadingUsers) {
return (
@@ -256,17 +261,25 @@ export function UnifiedChatsList() {
return (
<mdui-list-item
key={`dm-${chat.id}`}
headline={chat.username}
headline={chat.display_name}
onClick={() => handleDMClick(chat)}
style={{ cursor: "pointer" }}
>
<div slot="headline" className="dm-list-headline">
{chat.display_name}
<StatusBadge
verified={chat.verified || false}
userId={chat.userId}
size="small"
/>
</div>
<span slot="description" className="list-description">
{chat.lastMessage || "Нет сообщений"}
</span>
<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",
@@ -1,14 +1,16 @@
import { useState, useEffect } from "react";
import { useAppState } from "@/pages/chat/state";
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
import { StatusBadge } from "@/core/components/StatusBadge";
import type { User } from "@/core/types";
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 SearchBar from "@/core/components/SearchBar";
interface SearchUser extends User {
publicKey?: string | null;
verified?: boolean;
}
export function UsernameSearch() {
@@ -68,10 +70,12 @@ export function UsernameSearch() {
};
}, [searchResults]);
async function handleUserClick(searchUser: SearchUser) {
if (!user.authToken) return;
try {
let publicKey = searchUser.publicKey;
if (!publicKey) {
const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken);
@@ -150,6 +154,14 @@ export function UsernameSearch() {
onClick={() => handleUserClick(searchUser)}
style={{ cursor: "pointer" }}
>
<div slot="headline" className="search-result-headline">
{searchUser.username}
<StatusBadge
verified={searchUser.verified || false}
userId={searchUser.id}
size="small"
/>
</div>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
src={searchUser.profile_picture || defaultAvatar}
@@ -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}
@@ -143,7 +143,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
{children}
</div>
<MaterialDialog
headline="Удалить сообщение?"
open={deleteDialogOpen}
@@ -158,7 +157,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}
+77 -23
View File
@@ -10,10 +10,12 @@ 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, fetchUserProfile } from "@/core/api/profileApi";
import { StatusBadge } from "@/core/components/StatusBadge";
import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
import { parseProfileLink } from "@/core/profileLinks";
interface MessageReactionsProps {
reactions?: Reaction[];
@@ -147,7 +149,6 @@ interface Rect {
}
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(new Set());
@@ -164,26 +165,37 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope;
useEffect(() => {
(async () => {
setFormattedMessage({
__html: DOMPurify.sanitize(
await parse(message.content)
).trim()
});
})();
}, [message]);
const formattedMessage = useMemo(() => {
// First, temporarily replace existing fromchat.ru links to avoid conflicts
const linkPlaceholders: string[] = [];
let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => {
const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`;
linkPlaceholders.push(match);
return placeholder;
});
// Now process @mentions that aren't in existing links
content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => {
return `<a href="https://fromchat.ru/@${username}" class="mention-link">${match}</a>`;
});
// Restore the original links
linkPlaceholders.forEach((link, index) => {
content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link);
});
return {
__html: DOMPurify.sanitize(parse(content, { async: false })).trim()
};
}, [message.content]);
// Auto-decrypt images in DMs
useEffect(() => {
if (isDm && message.files) {
message.files.forEach(async (file) => {
console.log(file);
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
console.log("Decrypting...");
const decryptedUrl = await decryptFile(file);
console.log(decryptedUrl);
if (decryptedUrl) {
updateDecryptedFiles(draft => {
draft.set(file.path, decryptedUrl);
@@ -195,11 +207,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}, [message.files, isDm, decryptedFiles]);
async function decryptFile(file: Attachment): Promise<string | null> {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
debugger;
console.warn("Conditions not met")
return null;
}
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
// Check if already decrypted
if (decryptedFiles.has(file.path)) {
@@ -389,10 +397,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,
@@ -406,6 +414,44 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
}
}
async function handleLinkClick(e: React.MouseEvent<HTMLDivElement>) {
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
const profileLink = parseProfileLink((target as HTMLAnchorElement).href);
if (profileLink) {
e.preventDefault();
e.stopPropagation();
if (!user.authToken) return;
try {
let userProfile;
if (profileLink.userId) {
userProfile = await fetchUserProfileById(user.authToken, profileLink.userId);
} else if (profileLink.username) {
userProfile = await fetchUserProfile(user.authToken, profileLink.username);
}
if (userProfile) {
setProfileDialog({
...userProfile,
userId: userProfile.id,
memberSince: userProfile.created_at,
isOwnProfile: userProfile.id === user.currentUser?.id
});
} else {
throw new Error("Invalid link: " + (target as HTMLAnchorElement).href);
}
} catch (error) {
console.error("Failed to fetch user profile from link:", error);
}
}
}
}
function handleContextMenu(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
@@ -434,7 +480,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
{!isAuthor && !isDm && (
<div className="message-profile-pic" onClick={handleProfileClick}>
<img
src={message.profile_picture || defaultAvatar}
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
alt={message.username}
onError={(e) => {
const target = e.target as HTMLImageElement;
@@ -450,6 +496,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
className="message-username"
onClick={handleProfileClick}>
{message.username}
<StatusBadge
verified={message.verified || false}
userId={message.user_id}
size="small"
/>
</div>
)}
@@ -460,7 +511,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
</Quote>
)}
<div className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`} dangerouslySetInnerHTML={formattedMessage} />
<div
className={`message-content ${isEmojiMessage ? "emoji-content" : ""} ${isSingleEmojiMessage ? "single-emoji-content" : ""}`}
dangerouslySetInnerHTML={formattedMessage}
onClick={handleLinkClick} />
{message.files && message.files.length > 0 && (
<mdui-list className="message-attachments">
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "@/core/types";
import { EmojiMenu } from "./EmojiMenu";
import { useAppState } from "@/pages/chat/state";
interface MessageContextMenuProps {
message: Message;
@@ -33,6 +34,7 @@ export function MessageContextMenu({
isOpen,
onOpenChange
}: MessageContextMenuProps) {
const { user } = useAppState();
// Internal state for closing animation
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
@@ -209,7 +211,7 @@ export function MessageContextMenu({
onDelete(message);
handleClose();
},
show: isAuthor
show: isAuthor || user.currentUser?.id === 1
},
];
@@ -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
};
+2
View File
@@ -65,8 +65,10 @@
"dependencies": {
"dompurify": "^3.2.7",
"electron-squirrel-startup": "^1.0.1",
"escape-string-regexp": "^5.0.0",
"marked": "^16.3.0",
"mdui": "^2.1.4",
"motion": "^12.23.24",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.3",