Merge branch 'feature/username-verification' into feature/usernames

This commit is contained in:
2025-10-22 22:09:09 +03:00
Unverified
36 changed files with 1687 additions and 328 deletions
+29 -5
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,13 +27,30 @@ 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)
+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
+94 -6
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,14 +183,32 @@ 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...")
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
@@ -458,7 +525,28 @@ def _create_database_directly():
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)
"""))
# 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()
+10 -2
View File
@@ -20,6 +20,10 @@ class User(Base):
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")
@@ -184,8 +188,12 @@ class UserProfileResponse(BaseModel):
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
+11 -2
View File
@@ -20,7 +20,11 @@ def convert_user(user: User) -> dict:
"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")
@@ -113,12 +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)
+44 -2
View File
@@ -51,6 +51,16 @@ def convert_message(msg: Message) -> dict:
"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,
@@ -58,8 +68,9 @@ def convert_message(msg: Message) -> dict:
"timestamp": msg.timestamp.isoformat(),
"is_read": msg.is_read,
"is_edited": msg.is_edited,
"username": msg.author.display_name,
"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": [
@@ -92,6 +103,18 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
"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,
@@ -102,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": [
{
@@ -1229,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 = {
+239 -1
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
@@ -12,6 +13,8 @@ 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()
@@ -236,6 +239,23 @@ async def get_user_by_id(
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,
@@ -244,5 +264,223 @@ async def get_user_by_id(
bio=user.bio,
online=user.online,
last_seen=user.last_seen,
created_at=user.created_at
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, ""
+9 -1
View File
@@ -6,6 +6,7 @@ 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"));
@@ -66,7 +67,7 @@ function SmartCatchAll() {
}
export default function App() {
const { restoreUserFromStorage } = useAppState();
const { restoreUserFromStorage, user } = useAppState();
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
@@ -85,6 +86,13 @@ export default function App() {
))}
</Routes>
</div>
{user.isSuspended && (
<SuspensionDialog
reason={user.suspensionReason || "No reason provided"}
open={true}
onOpenChange={() => {}} // Suspended users can't close the dialog
/>
)}
</BrowserRouter>
)
}
+41
View File
@@ -149,3 +149,44 @@ export async function fetchUserProfileById(token: string, userId: number): Promi
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;
}
}
}
+6
View File
@@ -69,6 +69,7 @@ export interface Message {
is_edited: boolean;
timestamp: string;
profile_picture?: string;
verified?: boolean;
reply_to?: Message;
files?: Attachment[];
reactions?: Reaction[];
@@ -116,6 +117,10 @@ export interface User {
admin?: boolean;
bio?: string;
profile_picture: string;
verified?: boolean;
suspended?: boolean;
suspension_reason?: string | null;
deleted?: boolean;
}
/**
@@ -138,6 +143,7 @@ export interface UserProfile {
online: boolean;
last_seen: string;
created_at: string;
verified?: boolean;
}
// ----------
+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
@@ -82,3 +82,101 @@ button, input {
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";
@@ -42,3 +43,7 @@ mdui-dialog {
margin-block-end: 0;
}
}
mdui-icon {
user-select: none;
}
+10 -1
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) {
+1 -1
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";
@@ -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);
@@ -2,57 +2,10 @@
@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;
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;
// Profile Dialog Specific Styles
// Base dialog styles are now in _styled-dialog.scss
&.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;
}
.profile-dialog-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
.styled-dialog-content {
align-items: center;
.error-message {
@@ -99,6 +52,9 @@
.username-section {
text-align: center;
.username-with-badge {
gap: 0;
.username-input {
background: none;
border: none;
@@ -113,6 +69,7 @@
cursor: text;
}
}
}
.online-status-section {
display: flex;
@@ -168,6 +125,7 @@
.label {
font-size: small;
color: $color-dark-on-surface-variant;
user-select: none;
}
.value {
@@ -210,6 +168,7 @@
}
}
.styled-dialog {
.profile-dialog-fab {
position: absolute;
bottom: 24px;
@@ -222,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;
}
}
}
}
+1
View File
@@ -11,3 +11,4 @@
@use "callWindow";
@use "profile-dialog";
@use "typing-indicators";
@use "suspension-dialog";
-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);
}
+41 -9
View File
@@ -25,6 +25,10 @@ export interface ProfileDialogData {
memberSince?: string;
online?: boolean;
isOwnProfile: boolean;
verified?: boolean;
suspended?: boolean;
suspension_reason?: string | null;
deleted?: boolean;
}
interface ActiveDM {
@@ -72,6 +76,8 @@ interface ChatState {
export interface UserState {
currentUser: User | null;
authToken: string | null;
isSuspended: boolean;
suspensionReason: string | null;
}
interface AppState {
@@ -111,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;
@@ -225,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
}
}));
@@ -255,8 +266,6 @@ export const useAppState = create<AppState>((set, get) => ({
credentials: token
},
data: {}
}).then(() => {
console.log("Ping succeeded")
})
} catch {}
},
@@ -278,7 +287,9 @@ export const useAppState = create<AppState>((set, get) => ({
set(() => ({
user: {
currentUser: null,
authToken: null
authToken: null,
isSuspended: false,
suspensionReason: null
}
}));
},
@@ -295,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
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,
isSuspended: false,
suspensionReason: null
}
}));
@@ -314,8 +340,6 @@ export const useAppState = create<AppState>((set, get) => ({
credentials: token
},
data: {}
}).then(() => {
console.log("Ping succeeded")
})
} catch {}
@@ -678,5 +702,13 @@ export const useAppState = create<AppState>((set, get) => ({
dmTypingUsers: newDmTypingUsers
}
};
})
}),
setSuspended: (reason: string) => set((state) => ({
user: {
...state.user,
isSuspended: true,
suspensionReason: reason
}
}))
}));
+179 -73
View File
@@ -1,13 +1,17 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useAppState } from "@/pages/chat/state";
import type { ProfileDialogData } from "@/pages/chat/state";
import defaultAvatar from "@/images/default-avatar.png";
import { confirm } from "mdui/functions/confirm";
import { 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;
@@ -17,7 +21,7 @@ interface SectionProps {
value?: string;
onChange?: (value: string) => void;
readOnly: boolean;
placeholder: string;
placeholder?: string;
textArea?: boolean;
}
@@ -33,8 +37,7 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
placeholder={placeholder}
className="value"
rows={1}
readOnly={readOnly}
/>
readOnly={readOnly} />
);
} else {
valueComponent = (
@@ -47,9 +50,7 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
);
}
} else {
valueComponent = (
<span className="value">{value}</span>
);
valueComponent = <span className="value">{value}</span>
}
return (
@@ -74,7 +75,6 @@ export function ProfileDialog() {
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{[key: string]: string}>({});
const fileInputRef = useRef<HTMLInputElement>(null);
const [openClass, setOpenClass] = useState(false);
// Handle dialog open/close based on state
useEffect(() => {
@@ -82,13 +82,7 @@ export function ProfileDialog() {
// Fetch fresh data when opening dialog
fetchFreshProfileData(chat.profileDialog);
} else if (!chat.profileDialog && isOpen) {
// Start close animation
setOpenClass(false);
// Wait for animation to complete before closing
setTimeout(() => {
setIsOpen(false);
}, 300);
}
}, [chat.profileDialog, isOpen]);
@@ -104,6 +98,7 @@ export function ProfileDialog() {
if (userProfile) {
freshData = {
...userProfile,
userId: userProfile.id, // Preserve the userId field
memberSince: userProfile.created_at,
isOwnProfile: profileData.isOwnProfile
};
@@ -122,30 +117,7 @@ export function ProfileDialog() {
}
}
// Trigger transition after component mounts
useEffect(() => {
if (isOpen) {
// Small delay to ensure DOM is ready for transition
const timer = requestAnimationFrame(() => {
setOpenClass(true);
});
return () => cancelAnimationFrame(timer);
}
}, [isOpen]);
// Handle ESC key
useEffect(() => {
if (isOpen) {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") {
handleClose();
}
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}
}, [isOpen]);
// Subscribe to user's online status when dialog opens
useEffect(() => {
@@ -162,6 +134,7 @@ export function ProfileDialog() {
}
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
// Validate fields when data changes
useEffect(() => {
if (currentData && isOpen) {
@@ -195,30 +168,16 @@ export function ProfileDialog() {
confirmText: "Закрыть",
cancelText: "Отмена"
});
triggerCloseAnimation();
closeProfileDialog();
} catch {
// User cancelled, do nothing
}
} else {
triggerCloseAnimation();
}
};
function triggerCloseAnimation() {
setOpenClass(false);
// Wait for animation to complete before closing
setTimeout(() => {
closeProfileDialog();
}, 300); // Match CSS transition duration
};
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
};
function handleDisplayNameChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!currentData) return;
const newValue = e.target.value;
@@ -350,8 +309,8 @@ export function ProfileDialog() {
setUser(user.authToken, updatedUser);
}
// Close dialog with animation after successful save
triggerCloseAnimation();
// Close dialog after successful save
closeProfileDialog();
} catch (error) {
console.error("Failed to save profile:", error);
// Handle API errors
@@ -373,6 +332,91 @@ export function ProfileDialog() {
});
}
async function handleSuspend() {
if (!currentData?.userId || !user.authToken) return;
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 => {
@@ -384,14 +428,18 @@ export function ProfileDialog() {
return hasChanges && currentData?.isOwnProfile && !isSaving && !hasErrors;
}, [hasChanges, currentData?.isOwnProfile, isSaving, errors]);
if (!isOpen || !currentData) return null;
if (!currentData) return null;
return createPortal(
<div
className={`profile-dialog-backdrop ${openClass ? "open" : ""}`}
onClick={handleBackdropClick}>
<div className={`profile-dialog ${openClass ? "open" : ""}`}>
<div className="profile-dialog-content">
return (
<StyledDialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
onBackdropClick={handleClose}
>
<div className="profile-picture-section">
<img
className="profile-picture"
@@ -413,25 +461,78 @@ export function ProfileDialog() {
</div>
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
<input
<div className="username-with-badge">
<Input
autoresizing={true}
className="username-input"
type="text"
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?.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>
)}
{/* Hide profile sections for deleted users */}
{!currentData.deleted && (
<div className="profile-sections">
<Section
type="username"
@@ -452,8 +553,7 @@ export function ProfileDialog() {
onChange={handleBioChange}
readOnly={!currentData.isOwnProfile}
placeholder="Нет информации о себе"
textArea
/>
textArea />
)}
{currentData.memberSince && (
@@ -462,12 +562,20 @@ export function ProfileDialog() {
icon="calendar_month--filled"
label="Участник с:"
value={formatDate(currentData.memberSince)}
readOnly={true} />
)}
{currentData.verified && (
<Section
type="verified"
icon="verified--filled"
label="Верификация:"
value="Этот аккаунт - официальное лицо FromChat."
readOnly={true}
placeholder="Участник с:"
/>
)}
</div>
</div>
)}
{currentData.isOwnProfile && (
<mdui-fab
@@ -485,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>
);
}
@@ -4,6 +4,7 @@ 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";
@@ -19,6 +20,7 @@ interface PublicChat {
interface DMConversation {
id: number;
userId: number;
username: string;
display_name: string;
profile_picture?: string;
@@ -27,6 +29,7 @@ interface DMConversation {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
verified?: boolean;
}
type ChatItem = PublicChat | DMConversation;
@@ -84,6 +87,7 @@ 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,
@@ -261,6 +265,14 @@ export function UnifiedChatsList() {
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>
@@ -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}
+7 -4
View File
@@ -11,6 +11,7 @@ import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "@/pages/chat/state";
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";
@@ -192,12 +193,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
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);
@@ -486,7 +484,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;
@@ -502,6 +500,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>
)}
@@ -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
},
];
+1
View File
@@ -68,6 +68,7 @@
"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",