mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-24 12:05:05 +03:00
Merge branch 'feature/usernames'
This commit is contained in:
+29
-6
@@ -4,14 +4,21 @@ from contextlib import asynccontextmanager
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
from constants import DATABASE_URL
|
||||||
from routes import account, messaging, profile, push, webrtc
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup - run migration in separate process to avoid logging interference
|
# Startup - run migration in separate process to avoid logging interference
|
||||||
try:
|
try:
|
||||||
print("Starting database migration check...")
|
logger.info("Starting database migration check...")
|
||||||
# Run migration in a separate process
|
# Run migration in a separate process
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
[
|
||||||
@@ -20,17 +27,33 @@ async def lifespan(app: FastAPI):
|
|||||||
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||||
],
|
],
|
||||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
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:
|
except Exception as e:
|
||||||
print(f"Failed to run database migrations: {e}")
|
logger.error(f"Failed to run database migrations: {e}")
|
||||||
raise
|
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
|
yield
|
||||||
|
|
||||||
# Shutdown (if needed in the future)
|
# Shutdown (if needed in the future)
|
||||||
# logger.info("Application shutdown")
|
|
||||||
|
|
||||||
# Инициализация FastAPI
|
# Инициализация FastAPI
|
||||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||||
|
|||||||
@@ -35,4 +35,20 @@ def get_current_user(
|
|||||||
detail="User not found",
|
detail="User not found",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
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
|
return user
|
||||||
+94
-6
@@ -102,6 +102,21 @@ def run_migrations():
|
|||||||
logger.info(f"No new migrations needed or error creating migration: {e}")
|
logger.info(f"No new migrations needed or error creating migration: {e}")
|
||||||
pass
|
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
|
# Run the upgrade command
|
||||||
logger.info("Running database migrations...")
|
logger.info("Running database migrations...")
|
||||||
try:
|
try:
|
||||||
@@ -116,6 +131,40 @@ def run_migrations():
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
connection.execute(text("DELETE FROM alembic_version"))
|
connection.execute(text("DELETE FROM alembic_version"))
|
||||||
connection.commit()
|
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
|
# Try upgrade again
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
logger.info("Database migrations completed successfully after reset.")
|
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.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
||||||
connection.commit()
|
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")
|
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||||
for file in os.listdir(versions_dir):
|
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||||
if file.endswith('.py') and not file.startswith('__'):
|
|
||||||
os.remove(os.path.join(versions_dir, file))
|
|
||||||
|
|
||||||
# Create a completely fresh migration with full schema
|
if migration_files:
|
||||||
logger.info("Creating fresh migration with complete schema...")
|
# 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)
|
_create_complete_migration(alembic_cfg)
|
||||||
|
|
||||||
# Run the migration
|
# Run the migration
|
||||||
@@ -458,7 +525,28 @@ def _create_database_directly():
|
|||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
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')"))
|
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()
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -13,12 +13,17 @@ class User(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
display_name = Column(String(64), nullable=False)
|
||||||
password_hash = Column(String(200), nullable=False)
|
password_hash = Column(String(200), nullable=False)
|
||||||
profile_picture = Column(String(255), nullable=True)
|
profile_picture = Column(String(255), nullable=True)
|
||||||
bio = Column(Text, nullable=True)
|
bio = Column(Text, nullable=True)
|
||||||
online = Column(Boolean, default=False)
|
online = Column(Boolean, default=False)
|
||||||
last_seen = Column(DateTime, default=datetime.now)
|
last_seen = Column(DateTime, default=datetime.now)
|
||||||
created_at = 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")
|
messages = relationship("Message", back_populates="author", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
@@ -149,6 +154,7 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
|
display_name: str
|
||||||
password: str
|
password: str
|
||||||
confirm_password: str
|
confirm_password: str
|
||||||
|
|
||||||
@@ -178,11 +184,16 @@ class PushSubscriptionRequest(BaseModel):
|
|||||||
class UserProfileResponse(BaseModel):
|
class UserProfileResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
|
display_name: str
|
||||||
profile_picture: str | None
|
profile_picture: str | None
|
||||||
bio: str | None
|
bio: str | None
|
||||||
online: bool
|
online: bool
|
||||||
last_seen: datetime
|
last_seen: datetime | None
|
||||||
created_at: datetime
|
created_at: datetime | None
|
||||||
|
verified: bool
|
||||||
|
suspended: bool
|
||||||
|
suspension_reason: str | None
|
||||||
|
deleted: bool
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from constants import OWNER_USERNAME
|
|||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup
|
||||||
from utils import create_token, get_password_hash, verify_password
|
from utils import create_token, get_password_hash, verify_password
|
||||||
from validation import is_valid_password, is_valid_username
|
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -17,9 +17,14 @@ def convert_user(user: User) -> dict:
|
|||||||
"last_seen": user.last_seen.isoformat(),
|
"last_seen": user.last_seen.isoformat(),
|
||||||
"online": user.online,
|
"online": user.online,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
|
"display_name": user.display_name,
|
||||||
"profile_picture": user.profile_picture,
|
"profile_picture": user.profile_picture,
|
||||||
"bio": user.bio,
|
"bio": user.bio,
|
||||||
"admin": user.username == OWNER_USERNAME
|
"admin": user.username == OWNER_USERNAME,
|
||||||
|
"verified": user.verified,
|
||||||
|
"suspended": user.suspended or False,
|
||||||
|
"suspension_reason": user.suspension_reason,
|
||||||
|
"deleted": user.deleted or False
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/check_auth")
|
@router.get("/check_auth")
|
||||||
@@ -58,6 +63,7 @@ def login(request: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||||
username = request.username.strip()
|
username = request.username.strip()
|
||||||
|
display_name = request.display_name.strip()
|
||||||
password = request.password.strip()
|
password = request.password.strip()
|
||||||
confirm_password = request.confirm_password.strip()
|
confirm_password = request.confirm_password.strip()
|
||||||
|
|
||||||
@@ -75,7 +81,13 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
if not is_valid_username(username):
|
if not is_valid_username(username):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Имя пользователя должно быть от 3 до 20 символов и не содержать пробелов"
|
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_valid_display_name(display_name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_valid_password(password):
|
if not is_valid_password(password):
|
||||||
@@ -105,11 +117,17 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
hashed_password = get_password_hash(password)
|
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(
|
new_user = User(
|
||||||
username=username,
|
username=username,
|
||||||
|
display_name=display_name,
|
||||||
password_hash=hashed_password,
|
password_hash=hashed_password,
|
||||||
online=True,
|
online=True,
|
||||||
last_seen=datetime.now()
|
last_seen=datetime.now(),
|
||||||
|
verified=is_owner
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
|
|||||||
@@ -48,17 +48,29 @@ def convert_message(msg: Message) -> dict:
|
|||||||
reactions_dict[emoji]["count"] += 1
|
reactions_dict[emoji]["count"] += 1
|
||||||
reactions_dict[emoji]["users"].append({
|
reactions_dict[emoji]["users"].append({
|
||||||
"id": reaction.user_id,
|
"id": reaction.user_id,
|
||||||
"username": reaction.user.username
|
"username": reaction.user.display_name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 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 {
|
return {
|
||||||
"id": msg.id,
|
"id": msg.id,
|
||||||
|
"user_id": msg.author.id,
|
||||||
"content": msg.content,
|
"content": msg.content,
|
||||||
"timestamp": msg.timestamp.isoformat(),
|
"timestamp": msg.timestamp.isoformat(),
|
||||||
"is_read": msg.is_read,
|
"is_read": msg.is_read,
|
||||||
"is_edited": msg.is_edited,
|
"is_edited": msg.is_edited,
|
||||||
"username": msg.author.username,
|
"username": username,
|
||||||
"profile_picture": msg.author.profile_picture,
|
"profile_picture": profile_picture,
|
||||||
|
"verified": verified,
|
||||||
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
"reply_to": convert_message(msg.reply_to) if msg.reply_to else None,
|
||||||
"reactions": list(reactions_dict.values()),
|
"reactions": list(reactions_dict.values()),
|
||||||
"files": [
|
"files": [
|
||||||
@@ -88,9 +100,21 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
|||||||
reactions_dict[emoji]["count"] += 1
|
reactions_dict[emoji]["count"] += 1
|
||||||
reactions_dict[emoji]["users"].append({
|
reactions_dict[emoji]["users"].append({
|
||||||
"id": reaction.user_id,
|
"id": reaction.user_id,
|
||||||
"username": reaction.user.username
|
"username": reaction.user.display_name
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 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 {
|
return {
|
||||||
"id": envelope.id,
|
"id": envelope.id,
|
||||||
"senderId": envelope.sender_id,
|
"senderId": envelope.sender_id,
|
||||||
@@ -101,6 +125,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
|||||||
"iv2": envelope.iv2_b64,
|
"iv2": envelope.iv2_b64,
|
||||||
"wrappedMk": envelope.wrapped_mk_b64,
|
"wrappedMk": envelope.wrapped_mk_b64,
|
||||||
"timestamp": envelope.timestamp.isoformat(),
|
"timestamp": envelope.timestamp.isoformat(),
|
||||||
|
"verified": sender_verified,
|
||||||
"reactions": list(reactions_dict.values()),
|
"reactions": list(reactions_dict.values()),
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
@@ -1228,6 +1253,24 @@ class MessaggingSocketManager:
|
|||||||
if self.user_by_ws.get(websocket) == user_id:
|
if self.user_by_ws.get(websocket) == user_id:
|
||||||
await websocket.send_json(message)
|
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):
|
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"""
|
"""Broadcast status change to all connections that are subscribed to this user"""
|
||||||
message = {
|
message = {
|
||||||
|
|||||||
+291
-10
@@ -3,6 +3,7 @@ import re
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
@@ -11,12 +12,16 @@ import io
|
|||||||
from dependencies import get_db, get_current_user
|
from dependencies import get_db, get_current_user
|
||||||
from models import User, UpdateBioRequest, UserProfileResponse
|
from models import User, UpdateBioRequest, UserProfileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from validation import is_valid_username, is_valid_display_name
|
||||||
|
from similarity import is_user_similar_to_verified
|
||||||
|
from messaging import messagingManager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Request models
|
# Request models
|
||||||
class UpdateProfileRequest(BaseModel):
|
class UpdateProfileRequest(BaseModel):
|
||||||
nickname: str | None = None
|
username: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
# Create uploads directory if it doesn't exist
|
# Create uploads directory if it doesn't exist
|
||||||
@@ -102,6 +107,7 @@ async def get_user_profile(
|
|||||||
return {
|
return {
|
||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name,
|
||||||
"profile_picture": current_user.profile_picture,
|
"profile_picture": current_user.profile_picture,
|
||||||
"bio": current_user.bio,
|
"bio": current_user.bio,
|
||||||
"online": current_user.online,
|
"online": current_user.online,
|
||||||
@@ -121,19 +127,32 @@ async def update_user_profile(
|
|||||||
updated = False
|
updated = False
|
||||||
|
|
||||||
# Update username if provided
|
# Update username if provided
|
||||||
if request.nickname is not None:
|
if request.username is not None:
|
||||||
nickname = request.nickname.strip()
|
username = request.username.strip()
|
||||||
if len(nickname) < 3:
|
if not is_valid_username(username):
|
||||||
raise HTTPException(status_code=400, detail="Username must be at least 3 characters long")
|
raise HTTPException(
|
||||||
if len(nickname) > 50:
|
status_code=400,
|
||||||
raise HTTPException(status_code=400, detail="Username must be 50 characters or less")
|
detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания"
|
||||||
|
)
|
||||||
|
|
||||||
# Check if username is already taken by another user
|
# Check if username is already taken by another user
|
||||||
existing_user = db.query(User).filter(User.username == nickname, User.id != current_user.id).first()
|
existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(status_code=400, detail="Username already taken")
|
raise HTTPException(status_code=400, detail="Это имя пользователя уже занято")
|
||||||
|
|
||||||
current_user.username = nickname
|
current_user.username = username
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# Update display name if provided
|
||||||
|
if request.display_name is not None:
|
||||||
|
display_name = request.display_name.strip()
|
||||||
|
if not is_valid_display_name(display_name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым"
|
||||||
|
)
|
||||||
|
|
||||||
|
current_user.display_name = display_name
|
||||||
updated = True
|
updated = True
|
||||||
|
|
||||||
# Update bio if provided
|
# Update bio if provided
|
||||||
@@ -150,12 +169,14 @@ async def update_user_profile(
|
|||||||
return {
|
return {
|
||||||
"message": "Profile updated successfully",
|
"message": "Profile updated successfully",
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name,
|
||||||
"bio": current_user.bio
|
"bio": current_user.bio
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"message": "No changes made",
|
"message": "No changes made",
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
|
"display_name": current_user.display_name,
|
||||||
"bio": current_user.bio
|
"bio": current_user.bio
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,9 +218,269 @@ async def get_user_by_username(
|
|||||||
return UserProfileResponse(
|
return UserProfileResponse(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
|
display_name=user.display_name,
|
||||||
profile_picture=user.profile_picture,
|
profile_picture=user.profile_picture,
|
||||||
bio=user.bio,
|
bio=user.bio,
|
||||||
online=user.online,
|
online=user.online,
|
||||||
last_seen=user.last_seen,
|
last_seen=user.last_seen,
|
||||||
created_at=user.created_at
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -3,7 +3,17 @@ import re
|
|||||||
def is_valid_username(username: str) -> bool:
|
def is_valid_username(username: str) -> bool:
|
||||||
if len(username) < 3 or len(username) > 20:
|
if len(username) < 3 or len(username) > 20:
|
||||||
return False
|
return False
|
||||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', username):
|
# Only allow English letters, numbers, dashes and underscores
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_display_name(display_name: str) -> bool:
|
||||||
|
if len(display_name) < 1 or len(display_name) > 64:
|
||||||
|
return False
|
||||||
|
# Check if not blank (only whitespace)
|
||||||
|
if not display_name.strip():
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
+68
-17
@@ -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 { ElectronTitleBar } from "./Electron";
|
||||||
import { useAppState } from "./pages/chat/state";
|
import { useAppState } from "./pages/chat/state";
|
||||||
import { useEffect, useState, lazy } from "react";
|
import { lazy, useEffect, useState } from "react";
|
||||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
import { parseProfileLink } from "./core/profileLinks";
|
||||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||||
|
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||||
|
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
||||||
|
|
||||||
// Lazy load route components
|
// Lazy load route components
|
||||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||||
@@ -12,11 +14,62 @@ const LoginPage = lazy(() => import("./pages/auth/LoginPage"));
|
|||||||
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
|
const RegisterPage = lazy(() => import("./pages/auth/RegisterPage"));
|
||||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
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() {
|
export default function App() {
|
||||||
const { restoreUserFromStorage } = useAppState();
|
const { restoreUserFromStorage, user } = useAppState();
|
||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
// Restore user from localStorage on app initialization
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restoreUserFromStorage().finally(() => {
|
restoreUserFromStorage().finally(() => {
|
||||||
setAuthReady(true);
|
setAuthReady(true);
|
||||||
@@ -28,20 +81,18 @@ export default function App() {
|
|||||||
<ElectronTitleBar />
|
<ElectronTitleBar />
|
||||||
<div id="main-wrapper">
|
<div id="main-wrapper">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
{routeConfig.map((route, index) => (
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route key={index} path={route.path} element={route.element} />
|
||||||
<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 />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
{user.isSuspended && (
|
||||||
|
<SuspensionDialog
|
||||||
|
reason={user.suspensionReason || "No reason provided"}
|
||||||
|
open={true}
|
||||||
|
onOpenChange={() => {}} // Suspended users can't close the dialog
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@ import type { UserProfile } from "@/core/types";
|
|||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
nickname?: string;
|
username?: string;
|
||||||
|
display_name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
|
|||||||
// Map backend fields to frontend fields
|
// Map backend fields to frontend fields
|
||||||
return {
|
return {
|
||||||
profile_picture: data.profile_picture,
|
profile_picture: data.profile_picture,
|
||||||
nickname: data.username,
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
description: data.bio
|
description: data.bio
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,8 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
|
|||||||
try {
|
try {
|
||||||
// Map frontend fields to backend fields
|
// Map frontend fields to backend fields
|
||||||
const backendData = {
|
const backendData = {
|
||||||
nickname: data.nickname,
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
description: data.description
|
description: data.description
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,3 +129,64 @@ export async function fetchUserProfile(token: string, username: string): Promise
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches user profile data by user ID
|
||||||
|
*/
|
||||||
|
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||||
|
headers: getAuthHeaders(token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile by ID:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+10
@@ -62,12 +62,14 @@ export interface Reaction {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: number;
|
id: number;
|
||||||
|
user_id: number;
|
||||||
username: string;
|
username: string;
|
||||||
content: string;
|
content: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
is_edited: boolean;
|
is_edited: boolean;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
|
verified?: boolean;
|
||||||
reply_to?: Message;
|
reply_to?: Message;
|
||||||
files?: Attachment[];
|
files?: Attachment[];
|
||||||
reactions?: Reaction[];
|
reactions?: Reaction[];
|
||||||
@@ -111,9 +113,14 @@ export interface User {
|
|||||||
last_seen: string;
|
last_seen: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
profile_picture: string;
|
profile_picture: string;
|
||||||
|
verified?: boolean;
|
||||||
|
suspended?: boolean;
|
||||||
|
suspension_reason?: string | null;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,11 +137,13 @@ export interface User {
|
|||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------
|
// ----------
|
||||||
@@ -163,6 +172,7 @@ export interface LoginRequest {
|
|||||||
*/
|
*/
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
password: string;
|
password: string;
|
||||||
confirm_password: string;
|
confirm_password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { delay } from "@/utils/utils";
|
|||||||
import { CallSignalingHandler } from "./calls/signaling";
|
import { CallSignalingHandler } from "./calls/signaling";
|
||||||
import { onlineStatusManager } from "./onlineStatusManager";
|
import { onlineStatusManager } from "./onlineStatusManager";
|
||||||
import { typingManager } from "./typingManager";
|
import { typingManager } from "./typingManager";
|
||||||
|
import { useAppState } from "@/pages/chat/state";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new WebSocket connection to the chat server
|
* Creates a new WebSocket connection to the chat server
|
||||||
@@ -129,6 +130,19 @@ websocket.addEventListener("message", (e) => {
|
|||||||
typingManager.handleDmTyping(response as any);
|
typingManager.handleDmTyping(response as any);
|
||||||
} else if (response.type === "stopDmTyping") {
|
} else if (response.type === "stopDmTyping") {
|
||||||
typingManager.handleStopDmTyping(response as any);
|
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
|
// Route message to global handler if set
|
||||||
|
|||||||
@@ -82,3 +82,101 @@ button, input {
|
|||||||
padding: 0.5rem;
|
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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
@use "components";
|
@use "components";
|
||||||
@use "colors" as *;
|
@use "colors" as *;
|
||||||
@use "material" as *;
|
@use "material" as *;
|
||||||
|
@use "../core/components/css/styled-dialog";
|
||||||
|
|
||||||
@use "fonts/montserrat";
|
@use "fonts/montserrat";
|
||||||
@use "fonts/material-symbols";
|
@use "fonts/material-symbols";
|
||||||
@@ -42,3 +43,7 @@ mdui-dialog {
|
|||||||
margin-block-end: 0;
|
margin-block-end: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mdui-icon {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { API_BASE_URL } from "@/core/config";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import type { TextField } from "mdui/components/text-field";
|
import type { TextField } from "mdui/components/text-field";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
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 { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
|
||||||
import { isElectron } from "@/core/electron/electron";
|
import { isElectron } from "@/core/electron/electron";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -98,6 +98,15 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const data: ErrorResponse = await response.json();
|
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 || "Неверное имя пользователя или пароль");
|
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -105,7 +114,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<MaterialTextField
|
<MaterialTextField
|
||||||
label="Имя пользователя"
|
label="@Имя пользователя"
|
||||||
id="login-username"
|
id="login-username"
|
||||||
name="username"
|
name="username"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { TextField } from "mdui/components/text-field";
|
|||||||
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
|
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
|
||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
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 { ensureKeysOnLogin } from "@/core/api/authApi";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import "./auth.scss";
|
import "./auth.scss";
|
||||||
@@ -23,6 +23,7 @@ export default function RegisterPage() {
|
|||||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayNameElement = useRef<TextField>(null);
|
||||||
const usernameElement = useRef<TextField>(null);
|
const usernameElement = useRef<TextField>(null);
|
||||||
const passwordElement = useRef<TextField>(null);
|
const passwordElement = useRef<TextField>(null);
|
||||||
const confirmPasswordElement = useRef<TextField>(null);
|
const confirmPasswordElement = useRef<TextField>(null);
|
||||||
@@ -36,11 +37,12 @@ export default function RegisterPage() {
|
|||||||
<form onSubmit={async (e) => {
|
<form onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const displayName = displayNameElement.current!.value.trim();
|
||||||
const username = usernameElement.current!.value.trim();
|
const username = usernameElement.current!.value.trim();
|
||||||
const password = passwordElement.current!.value.trim();
|
const password = passwordElement.current!.value.trim();
|
||||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||||
|
|
||||||
if (!username || !password || !confirmPassword) {
|
if (!displayName || !username || !password || !confirmPassword) {
|
||||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,11 +52,22 @@ export default function RegisterPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (displayName.length < 1 || displayName.length > 64) {
|
||||||
|
showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (username.length < 3 || username.length > 20) {
|
if (username.length < 3 || username.length > 20) {
|
||||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate username format (only English letters, numbers, dashes, underscores)
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||||
|
showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 5 || password.length > 50) {
|
if (password.length < 5 || password.length > 50) {
|
||||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||||
return;
|
return;
|
||||||
@@ -62,6 +75,7 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const request: RegisterRequest = {
|
const request: RegisterRequest = {
|
||||||
|
display_name: displayName,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
confirm_password: confirmPassword
|
confirm_password: confirmPassword
|
||||||
@@ -97,7 +111,18 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<MaterialTextField
|
<MaterialTextField
|
||||||
label="Имя пользователя"
|
label="Отображаемое имя"
|
||||||
|
id="register-display-name"
|
||||||
|
name="display_name"
|
||||||
|
variant="outlined"
|
||||||
|
icon="badge--filled"
|
||||||
|
autocomplete="name"
|
||||||
|
maxlength={64}
|
||||||
|
counter
|
||||||
|
required
|
||||||
|
ref={displayNameElement} />
|
||||||
|
<MaterialTextField
|
||||||
|
label="@Имя пользователя"
|
||||||
id="register-username"
|
id="register-username"
|
||||||
name="username"
|
name="username"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
margin: 10px;
|
margin: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
@@ -375,3 +378,25 @@
|
|||||||
justify-content: center;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,58 +2,16 @@
|
|||||||
@use "../../../css/material" as *;
|
@use "../../../css/material" as *;
|
||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
// Profile Dialog Styles
|
// Profile Dialog Specific Styles
|
||||||
.profile-dialog-backdrop {
|
// Base dialog styles are now in _styled-dialog.scss
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
.styled-dialog-content {
|
||||||
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;
|
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 {
|
.error-message {
|
||||||
opacity: 1;
|
color: $color-dark-error;
|
||||||
visibility: visible;
|
font-size: small;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.profile-picture-section {
|
.profile-picture-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -94,6 +52,9 @@
|
|||||||
.username-section {
|
.username-section {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
.username-with-badge {
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
.username-input {
|
.username-input {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -108,6 +69,7 @@
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.online-status-section {
|
.online-status-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -134,15 +96,15 @@
|
|||||||
|
|
||||||
.profile-sections {
|
.profile-sections {
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
border-radius: 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
overflow: hidden;
|
|
||||||
width: calc(100% - (16px * 2));
|
width: calc(100% - (16px * 2));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
$edge-radius: 24px;
|
||||||
|
|
||||||
background: $color-dark-surface-container-high;
|
background: $color-dark-surface-container-high;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
@@ -150,6 +112,9 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
transition: outline 0.1s ease;
|
||||||
|
outline: 0px solid transparent;
|
||||||
|
outline-offset: -1px;
|
||||||
|
|
||||||
.content-container {
|
.content-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -160,6 +125,7 @@
|
|||||||
.label {
|
.label {
|
||||||
font-size: small;
|
font-size: small;
|
||||||
color: $color-dark-on-surface-variant;
|
color: $color-dark-on-surface-variant;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
@@ -179,10 +145,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First and last section
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: $edge-radius;
|
||||||
|
border-top-right-radius: $edge-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-left-radius: $edge-radius;
|
||||||
|
border-bottom-right-radius: $edge-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
outline: 1px solid $color-dark-error;
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.styled-dialog {
|
||||||
.profile-dialog-fab {
|
.profile-dialog-fab {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
@@ -195,4 +181,28 @@
|
|||||||
transform: translateY(0);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,3 +11,4 @@
|
|||||||
@use "callWindow";
|
@use "callWindow";
|
||||||
@use "profile-dialog";
|
@use "profile-dialog";
|
||||||
@use "typing-indicators";
|
@use "typing-indicators";
|
||||||
|
@use "suspension-dialog";
|
||||||
@@ -69,7 +69,6 @@ export function useDM() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
||||||
console.log(lastPlaintext);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to decrypt last message:", error);
|
console.error("Failed to decrypt last message:", error);
|
||||||
}
|
}
|
||||||
@@ -174,6 +173,7 @@ export function useDM() {
|
|||||||
|
|
||||||
decryptedMessages.push({
|
decryptedMessages.push({
|
||||||
id: env.id,
|
id: env.id,
|
||||||
|
user_id: env.senderId,
|
||||||
content: text,
|
content: text,
|
||||||
username: username,
|
username: username,
|
||||||
timestamp: env.timestamp,
|
timestamp: env.timestamp,
|
||||||
|
|||||||
@@ -19,11 +19,16 @@ export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
|||||||
export interface ProfileDialogData {
|
export interface ProfileDialogData {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
display_name?: string;
|
||||||
profilePicture?: string;
|
profilePicture?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
memberSince?: string;
|
memberSince?: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
isOwnProfile: boolean;
|
isOwnProfile: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
suspended?: boolean;
|
||||||
|
suspension_reason?: string | null;
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveDM {
|
interface ActiveDM {
|
||||||
@@ -71,6 +76,8 @@ interface ChatState {
|
|||||||
export interface UserState {
|
export interface UserState {
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
|
isSuspended: boolean;
|
||||||
|
suspensionReason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
@@ -110,6 +117,7 @@ interface AppState {
|
|||||||
setUser: (token: string, user: User) => void;
|
setUser: (token: string, user: User) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
restoreUserFromStorage: () => Promise<void>;
|
restoreUserFromStorage: () => Promise<void>;
|
||||||
|
setSuspended: (reason: string) => void;
|
||||||
|
|
||||||
// Profile dialog state
|
// Profile dialog state
|
||||||
setProfileDialog: (data: ProfileDialogData | null) => void;
|
setProfileDialog: (data: ProfileDialogData | null) => void;
|
||||||
@@ -224,13 +232,17 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
// User state
|
// User state
|
||||||
user: {
|
user: {
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
authToken: null
|
authToken: null,
|
||||||
|
isSuspended: false,
|
||||||
|
suspensionReason: null
|
||||||
},
|
},
|
||||||
setUser: (token: string, user: User) => {
|
setUser: (token: string, user: User) => {
|
||||||
set(() => ({
|
set(() => ({
|
||||||
user: {
|
user: {
|
||||||
currentUser: 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
|
credentials: token
|
||||||
},
|
},
|
||||||
data: {}
|
data: {}
|
||||||
}).then(() => {
|
|
||||||
console.log("Ping succeeded")
|
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
@@ -277,7 +287,9 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
set(() => ({
|
set(() => ({
|
||||||
user: {
|
user: {
|
||||||
currentUser: null,
|
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();
|
const user: User = await response.json();
|
||||||
restoreKeys();
|
restoreKeys();
|
||||||
|
|
||||||
|
// Check if user is suspended
|
||||||
|
if (user.suspended) {
|
||||||
set(() => ({
|
set(() => ({
|
||||||
user: {
|
user: {
|
||||||
currentUser: 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
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -313,8 +340,6 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
credentials: token
|
credentials: token
|
||||||
},
|
},
|
||||||
data: {}
|
data: {}
|
||||||
}).then(() => {
|
|
||||||
console.log("Ping succeeded")
|
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -677,5 +702,13 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
dmTypingUsers: newDmTypingUsers
|
dmTypingUsers: newDmTypingUsers
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
setSuspended: (reason: string) => set((state) => ({
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
isSuspended: true,
|
||||||
|
suspensionReason: reason
|
||||||
|
}
|
||||||
|
}))
|
||||||
}));
|
}));
|
||||||
@@ -3,9 +3,69 @@ import { RightPanel } from "./right/RightPanel";
|
|||||||
import "@/pages/chat/css/chat.scss";
|
import "@/pages/chat/css/chat.scss";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
import { CallWindow } from "./right/calls/CallWindow";
|
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() {
|
export default function ChatPage() {
|
||||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
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;
|
if (navigateDownloadApp) return navigateDownloadApp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,23 +1,80 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import type { ProfileDialogData } from "@/pages/chat/state";
|
import type { ProfileDialogData } from "@/pages/chat/state";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import { confirm } from "mdui/functions/confirm";
|
import { confirm } from "mdui/functions/confirm";
|
||||||
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
|
import { prompt } from "mdui/functions/prompt";
|
||||||
|
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
|
||||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||||
|
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||||
|
import { VerifyButton } from "@/core/components/VerifyButton";
|
||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { OnlineStatus } from "./right/OnlineStatus";
|
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() {
|
export function ProfileDialog() {
|
||||||
const { chat, user, closeProfileDialog } = useAppState();
|
const { chat, user, closeProfileDialog, setUser } = useAppState();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
|
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
|
||||||
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
|
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{[key: string]: string}>({});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Handle dialog open/close based on state
|
// Handle dialog open/close based on state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,38 +82,24 @@ export function ProfileDialog() {
|
|||||||
// Fetch fresh data when opening dialog
|
// Fetch fresh data when opening dialog
|
||||||
fetchFreshProfileData(chat.profileDialog);
|
fetchFreshProfileData(chat.profileDialog);
|
||||||
} else if (!chat.profileDialog && isOpen) {
|
} 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);
|
setIsOpen(false);
|
||||||
}, 300); // Match CSS transition duration
|
|
||||||
} else {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [chat.profileDialog, isOpen]);
|
}, [chat.profileDialog, isOpen]);
|
||||||
|
|
||||||
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
|
async function fetchFreshProfileData(profileData: ProfileDialogData) {
|
||||||
if (!user.authToken) return;
|
if (!user.authToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let freshData = profileData;
|
let freshData = profileData;
|
||||||
|
|
||||||
// If it's not the public chat and has a username, fetch fresh data
|
// If it's not the public chat and has a user ID, fetch fresh data
|
||||||
if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) {
|
if (profileData.userId && profileData.username !== "Общий чат") {
|
||||||
const userProfile = await fetchUserProfile(user.authToken, profileData.username);
|
const userProfile = await fetchUserProfileById(user.authToken, profileData.userId);
|
||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
freshData = {
|
freshData = {
|
||||||
userId: userProfile.id,
|
...userProfile,
|
||||||
username: userProfile.username,
|
userId: userProfile.id, // Preserve the userId field
|
||||||
profilePicture: userProfile.profile_picture,
|
|
||||||
bio: userProfile.bio,
|
|
||||||
memberSince: userProfile.created_at,
|
memberSince: userProfile.created_at,
|
||||||
online: userProfile.online,
|
|
||||||
isOwnProfile: profileData.isOwnProfile
|
isOwnProfile: profileData.isOwnProfile
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,35 +115,9 @@ export function ProfileDialog() {
|
|||||||
setCurrentData(profileData);
|
setCurrentData(profileData);
|
||||||
setIsOpen(true);
|
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
|
// Subscribe to user's online status when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,6 +134,14 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
||||||
|
|
||||||
|
|
||||||
|
// Validate fields when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentData && isOpen) {
|
||||||
|
validateFields();
|
||||||
|
}
|
||||||
|
}, [currentData, isOpen]);
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!originalData || !currentData) return false;
|
if (!originalData || !currentData) return false;
|
||||||
|
|
||||||
@@ -127,13 +152,14 @@ export function ProfileDialog() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
normalizeValue(originalData.display_name) !== normalizeValue(currentData.display_name) ||
|
||||||
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
||||||
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
||||||
originalData.profilePicture !== currentData.profilePicture
|
originalData.profilePicture !== currentData.profilePicture
|
||||||
);
|
);
|
||||||
}, [originalData, currentData]);
|
}, [originalData, currentData]);
|
||||||
|
|
||||||
const handleClose = async () => {
|
async function handleClose() {
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
try {
|
try {
|
||||||
await confirm({
|
await confirm({
|
||||||
@@ -142,52 +168,45 @@ export function ProfileDialog() {
|
|||||||
confirmText: "Закрыть",
|
confirmText: "Закрыть",
|
||||||
cancelText: "Отмена"
|
cancelText: "Отмена"
|
||||||
});
|
});
|
||||||
triggerCloseAnimation();
|
closeProfileDialog();
|
||||||
} catch {
|
} catch {
|
||||||
// User cancelled, do nothing
|
// 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 {
|
} else {
|
||||||
closeProfileDialog();
|
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;
|
if (!currentData) return;
|
||||||
setCurrentData({ ...currentData, username: e.target.value });
|
const newValue = e.target.value;
|
||||||
|
setCurrentData({ ...currentData, display_name: newValue });
|
||||||
|
|
||||||
|
// Validate display name in real-time
|
||||||
|
validateDisplayName(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBioChange = (newBio: string) => {
|
function handleUsernameChange(value: string) {
|
||||||
|
if (!currentData) return;
|
||||||
|
setCurrentData({ ...currentData, username: value });
|
||||||
|
|
||||||
|
// Validate username in real-time
|
||||||
|
validateUsername(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleBioChange(newBio: string) {
|
||||||
if (!currentData) return;
|
if (!currentData) return;
|
||||||
setCurrentData({ ...currentData, bio: newBio });
|
setCurrentData({ ...currentData, bio: newBio });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProfilePictureClick = () => {
|
function handleProfilePictureClick() {
|
||||||
if (currentData?.isOwnProfile) {
|
if (currentData?.isOwnProfile) {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file && file.type.startsWith("image/")) {
|
if (file && file.type.startsWith("image/")) {
|
||||||
// Open cropper dialog here - for now just update the image
|
// Open cropper dialog here - for now just update the image
|
||||||
@@ -202,15 +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;
|
if (!currentData || !user.authToken || !originalData) return;
|
||||||
|
|
||||||
|
// Validate fields first
|
||||||
|
if (!validateFields()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
// Update profile data
|
// Update profile data
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
|
if (originalData.display_name !== currentData.display_name) {
|
||||||
|
updateData.display_name = currentData.display_name;
|
||||||
|
}
|
||||||
if (originalData.username !== currentData.username) {
|
if (originalData.username !== currentData.username) {
|
||||||
updateData.nickname = currentData.username;
|
updateData.username = currentData.username;
|
||||||
}
|
}
|
||||||
if (originalData.bio !== currentData.bio) {
|
if (originalData.bio !== currentData.bio) {
|
||||||
updateData.description = currentData.bio;
|
updateData.description = currentData.bio;
|
||||||
@@ -233,34 +297,149 @@ export function ProfileDialog() {
|
|||||||
// Update the original data to match current data
|
// Update the original data to match current data
|
||||||
setOriginalData(currentData);
|
setOriginalData(currentData);
|
||||||
|
|
||||||
// Close dialog with animation after successful save
|
// If this is the current user's profile and username was changed, update the current user data
|
||||||
triggerCloseAnimation();
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to save profile:", error);
|
console.error("Failed to save profile:", error);
|
||||||
|
// Handle API errors
|
||||||
|
if (error instanceof Error && error.message.includes("уже занято")) {
|
||||||
|
setErrors({ username: "Это имя пользователя уже занято" });
|
||||||
|
} else {
|
||||||
|
setErrors({ general: "Ошибка при сохранении профиля" });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
function formatDate(dateString: string) {
|
||||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric"
|
day: "numeric"
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
if (!isOpen || !currentData) return null;
|
async function handleSuspend() {
|
||||||
|
if (!currentData?.userId || !user.authToken) return;
|
||||||
|
|
||||||
return createPortal(
|
const isSuspending = !currentData.suspended;
|
||||||
<div
|
|
||||||
ref={backdropRef}
|
try {
|
||||||
className="profile-dialog-backdrop"
|
if (isSuspending) {
|
||||||
onClick={handleBackdropClick}
|
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
|
<img
|
||||||
className="profile-picture"
|
className="profile-picture"
|
||||||
@@ -281,72 +460,132 @@ export function ProfileDialog() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Username */}
|
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
|
||||||
{currentData.username && (
|
<div className="username-with-badge">
|
||||||
<div className="username-section">
|
<Input
|
||||||
<input
|
autoresizing={true}
|
||||||
className="username-input"
|
className="username-input"
|
||||||
type="text"
|
type="text"
|
||||||
|
value={currentData.display_name}
|
||||||
|
onChange={handleDisplayNameChange}
|
||||||
|
readOnly={!currentData.isOwnProfile}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
value={currentData.username}
|
||||||
onChange={handleUsernameChange}
|
onChange={handleUsernameChange}
|
||||||
readOnly={!currentData.isOwnProfile}
|
readOnly={!currentData.isOwnProfile}
|
||||||
placeholder="Имя пользователя"
|
placeholder="username" />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Online Status */}
|
|
||||||
{currentData?.userId && (
|
|
||||||
<div className="online-status-section">
|
|
||||||
<OnlineStatus userId={currentData.userId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="profile-sections">
|
|
||||||
{/* Bio */}
|
|
||||||
{currentData.bio !== undefined && (
|
{currentData.bio !== undefined && (
|
||||||
<div className="section bio">
|
<Section
|
||||||
<mdui-icon name="info--filled" />
|
type="bio"
|
||||||
<div className="content-container">
|
icon="info--filled"
|
||||||
<label className="label">О себе:</label>
|
label="О себе:"
|
||||||
<RichTextArea
|
value={currentData.bio}
|
||||||
text={currentData.bio || ""}
|
onChange={handleBioChange}
|
||||||
onTextChange={handleBioChange}
|
|
||||||
placeholder="Нет информации о себе"
|
|
||||||
className="value"
|
|
||||||
rows={1}
|
|
||||||
readOnly={!currentData.isOwnProfile}
|
readOnly={!currentData.isOwnProfile}
|
||||||
/>
|
placeholder="Нет информации о себе"
|
||||||
</div>
|
textArea />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Member Since */}
|
|
||||||
{currentData.memberSince && (
|
{currentData.memberSince && (
|
||||||
<div className="section member-since">
|
<Section
|
||||||
<mdui-icon name="calendar_month--filled" />
|
type="member-since"
|
||||||
<div className="content-container">
|
icon="calendar_month--filled"
|
||||||
<span className="label">Участник с:</span>
|
label="Участник с:"
|
||||||
<span className="value">
|
value={formatDate(currentData.memberSince)}
|
||||||
{formatDate(currentData.memberSince)}
|
readOnly={true} />
|
||||||
</span>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
{currentData.verified && (
|
||||||
|
<Section
|
||||||
|
type="verified"
|
||||||
|
icon="verified--filled"
|
||||||
|
label="Верификация:"
|
||||||
|
value="Этот аккаунт - официальное лицо FromChat."
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Save FAB */}
|
|
||||||
{currentData.isOwnProfile && (
|
{currentData.isOwnProfile && (
|
||||||
<mdui-fab
|
<mdui-fab
|
||||||
icon="check"
|
icon="check"
|
||||||
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
|
className={`profile-dialog-fab ${fabVisible ? "visible" : ""}`}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hidden file input */}
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -354,8 +593,6 @@ export function ProfileDialog() {
|
|||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StyledDialog>
|
||||||
</div>,
|
|
||||||
document.getElementById("root")!
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = () => {
|
const handleProfileClick = () => {
|
||||||
setProfileDialog({
|
setProfileDialog({
|
||||||
userId: user.currentUser?.id,
|
userId: user.currentUser?.id,
|
||||||
username: profileData?.nickname || "Пользователь",
|
username: profileData?.username || "Пользователь",
|
||||||
|
display_name: profileData?.display_name || "Пользователь",
|
||||||
profilePicture: profileData?.profile_picture,
|
profilePicture: profileData?.profile_picture,
|
||||||
bio: profileData?.description,
|
bio: profileData?.description,
|
||||||
memberSince: user.currentUser?.created_at,
|
memberSince: user.currentUser?.created_at,
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { useAppState, type ChatTabs } from "@/pages/chat/state";
|
import { useAppState, type ChatTabs } from "@/pages/chat/state";
|
||||||
import { UnifiedChatsList } from "./UnifiedChatsList";
|
import { UnifiedChatsList } from "./UnifiedChatsList";
|
||||||
import type { FormEvent } from "react";
|
|
||||||
import type { Tabs } from "mdui/components/tabs";
|
import type { Tabs } from "mdui/components/tabs";
|
||||||
|
|
||||||
export function ChatTabs() {
|
export function ChatTabs() {
|
||||||
const { chat, setActiveTab } = useAppState();
|
const { chat, setActiveTab } = useAppState();
|
||||||
|
|
||||||
function handleChange(e: FormEvent<Tabs> & CustomEvent<{ value: string }>) {
|
|
||||||
setActiveTab(e.detail.value as ChatTabs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-tabs">
|
<div className="chat-tabs">
|
||||||
<mdui-tabs
|
<mdui-tabs
|
||||||
value={chat.activeTab}
|
value={chat.activeTab}
|
||||||
full-width
|
full-width
|
||||||
onChange={handleChange}>
|
onChange={(e) => setActiveTab((e.target as Tabs).value as ChatTabs)}>
|
||||||
<mdui-tab value="chats">
|
<mdui-tab value="chats">
|
||||||
Чаты
|
Чаты
|
||||||
</mdui-tab>
|
</mdui-tab>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "@/core/api/authApi";
|
import { getAuthHeaders } from "@/core/api/authApi";
|
||||||
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||||
|
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||||
import type { Message } from "@/core/types";
|
import type { Message } from "@/core/types";
|
||||||
import { websocket } from "@/core/websocket";
|
import { websocket } from "@/core/websocket";
|
||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { OnlineIndicator } from "../right/OnlineIndicator";
|
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
|
|
||||||
interface PublicChat {
|
interface PublicChat {
|
||||||
@@ -19,13 +20,16 @@ interface PublicChat {
|
|||||||
|
|
||||||
interface DMConversation {
|
interface DMConversation {
|
||||||
id: number;
|
id: number;
|
||||||
|
userId: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
display_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
type: "dm";
|
type: "dm";
|
||||||
lastMessage?: string;
|
lastMessage?: string;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
publicKey?: string | null;
|
publicKey?: string | null;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatItem = PublicChat | DMConversation;
|
type ChatItem = PublicChat | DMConversation;
|
||||||
@@ -83,7 +87,9 @@ export function UnifiedChatsList() {
|
|||||||
|
|
||||||
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
|
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
userId: user.id, // Add userId field
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
profile_picture: user.profile_picture,
|
profile_picture: user.profile_picture,
|
||||||
online: user.online,
|
online: user.online,
|
||||||
type: "dm" as const,
|
type: "dm" as const,
|
||||||
@@ -172,13 +178,13 @@ export function UnifiedChatsList() {
|
|||||||
};
|
};
|
||||||
}, [allChats]);
|
}, [allChats]);
|
||||||
|
|
||||||
const formatPublicChatMessage = (chatId: string): string => {
|
function formatPublicChatMessage(chatId: string): string {
|
||||||
const lastMessage = lastMessages[chatId];
|
const lastMessage = lastMessages[chatId];
|
||||||
if (!lastMessage) {
|
if (!lastMessage) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentUser = lastMessage.username === user.currentUser?.username;
|
const isCurrentUser = lastMessage.user_id === user.currentUser?.id;
|
||||||
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
|
const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `;
|
||||||
|
|
||||||
const maxContentLength = 50 - prefix.length;
|
const maxContentLength = 50 - prefix.length;
|
||||||
@@ -187,14 +193,13 @@ export function UnifiedChatsList() {
|
|||||||
: lastMessage.content;
|
: lastMessage.content;
|
||||||
|
|
||||||
return prefix + content;
|
return prefix + content;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
async function handlePublicChatClick(chatName: string) {
|
||||||
const handlePublicChatClick = async (chatName: string) => {
|
|
||||||
await switchToPublicChat(chatName);
|
await switchToPublicChat(chatName);
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDMClick = async (dmConversation: DMConversation) => {
|
async function handleDMClick(dmConversation: DMConversation) {
|
||||||
if (!dmConversation.publicKey) {
|
if (!dmConversation.publicKey) {
|
||||||
const authToken = useAppState.getState().user.authToken;
|
const authToken = useAppState.getState().user.authToken;
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
@@ -215,7 +220,7 @@ export function UnifiedChatsList() {
|
|||||||
profilePicture: dmConversation.profile_picture,
|
profilePicture: dmConversation.profile_picture,
|
||||||
online: dmConversation.online || false
|
online: dmConversation.online || false
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
if (isLoadingUsers) {
|
if (isLoadingUsers) {
|
||||||
return (
|
return (
|
||||||
@@ -256,17 +261,25 @@ export function UnifiedChatsList() {
|
|||||||
return (
|
return (
|
||||||
<mdui-list-item
|
<mdui-list-item
|
||||||
key={`dm-${chat.id}`}
|
key={`dm-${chat.id}`}
|
||||||
headline={chat.username}
|
headline={chat.display_name}
|
||||||
onClick={() => handleDMClick(chat)}
|
onClick={() => handleDMClick(chat)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
|
<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">
|
<span slot="description" className="list-description">
|
||||||
{chat.lastMessage || "Нет сообщений"}
|
{chat.lastMessage || "Нет сообщений"}
|
||||||
</span>
|
</span>
|
||||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||||
<img
|
<img
|
||||||
src={chat.profile_picture || defaultAvatar}
|
src={chat.profile_picture || defaultAvatar}
|
||||||
alt={chat.username}
|
alt={chat.display_name}
|
||||||
style={{
|
style={{
|
||||||
width: "40px",
|
width: "40px",
|
||||||
height: "40px",
|
height: "40px",
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
|
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
|
||||||
|
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||||
import type { User } from "@/core/types";
|
import type { User } from "@/core/types";
|
||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { OnlineIndicator } from "../right/OnlineIndicator";
|
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import SearchBar from "@/core/components/SearchBar";
|
import SearchBar from "@/core/components/SearchBar";
|
||||||
|
|
||||||
interface SearchUser extends User {
|
interface SearchUser extends User {
|
||||||
publicKey?: string | null;
|
publicKey?: string | null;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsernameSearch() {
|
export function UsernameSearch() {
|
||||||
@@ -68,10 +70,12 @@ export function UsernameSearch() {
|
|||||||
};
|
};
|
||||||
}, [searchResults]);
|
}, [searchResults]);
|
||||||
|
|
||||||
|
|
||||||
async function handleUserClick(searchUser: SearchUser) {
|
async function handleUserClick(searchUser: SearchUser) {
|
||||||
if (!user.authToken) return;
|
if (!user.authToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
let publicKey = searchUser.publicKey;
|
let publicKey = searchUser.publicKey;
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken);
|
const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken);
|
||||||
@@ -150,6 +154,14 @@ export function UsernameSearch() {
|
|||||||
onClick={() => handleUserClick(searchUser)}
|
onClick={() => handleUserClick(searchUser)}
|
||||||
style={{ cursor: "pointer" }}
|
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" }}>
|
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||||
<img
|
<img
|
||||||
src={searchUser.profile_picture || defaultAvatar}
|
src={searchUser.profile_picture || defaultAvatar}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
message={message}
|
message={message}
|
||||||
isAuthor={isDm ?
|
isAuthor={isDm ?
|
||||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||||
(message.username === user.currentUser?.username)
|
(message.user_id === user.currentUser?.id)
|
||||||
}
|
}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onReactionClick={handleReactionClick}
|
onReactionClick={handleReactionClick}
|
||||||
@@ -143,7 +143,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<MaterialDialog
|
<MaterialDialog
|
||||||
headline="Удалить сообщение?"
|
headline="Удалить сообщение?"
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
@@ -158,7 +157,7 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
message={contextMenu.message}
|
message={contextMenu.message}
|
||||||
isAuthor={isDm ?
|
isAuthor={isDm ?
|
||||||
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
(contextMenu.message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||||
(contextMenu.message.username === user.currentUser?.username)
|
(contextMenu.message.user_id === user.currentUser?.id)
|
||||||
}
|
}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
|||||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||||
import { getAuthHeaders } from "@/core/api/authApi";
|
import { getAuthHeaders } from "@/core/api/authApi";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
|
||||||
|
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||||
import { ub64 } from "@/utils/utils";
|
import { ub64 } from "@/utils/utils";
|
||||||
import { useImmer } from "use-immer";
|
import { useImmer } from "use-immer";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { parseProfileLink } from "@/core/profileLinks";
|
||||||
|
|
||||||
interface MessageReactionsProps {
|
interface MessageReactionsProps {
|
||||||
reactions?: Reaction[];
|
reactions?: Reaction[];
|
||||||
@@ -147,7 +149,6 @@ interface Rect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
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 [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||||
const [downloadingPaths, updateDownloadingPaths] = 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 imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||||
|
|
||||||
useEffect(() => {
|
const formattedMessage = useMemo(() => {
|
||||||
(async () => {
|
// First, temporarily replace existing fromchat.ru links to avoid conflicts
|
||||||
setFormattedMessage({
|
const linkPlaceholders: string[] = [];
|
||||||
__html: DOMPurify.sanitize(
|
let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => {
|
||||||
await parse(message.content)
|
const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`;
|
||||||
).trim()
|
linkPlaceholders.push(match);
|
||||||
|
return placeholder;
|
||||||
});
|
});
|
||||||
})();
|
|
||||||
}, [message]);
|
// 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
|
// Auto-decrypt images in DMs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDm && message.files) {
|
if (isDm && message.files) {
|
||||||
message.files.forEach(async (file) => {
|
message.files.forEach(async (file) => {
|
||||||
console.log(file);
|
|
||||||
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
|
||||||
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
|
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
|
||||||
console.log("Decrypting...");
|
|
||||||
const decryptedUrl = await decryptFile(file);
|
const decryptedUrl = await decryptFile(file);
|
||||||
console.log(decryptedUrl);
|
|
||||||
if (decryptedUrl) {
|
if (decryptedUrl) {
|
||||||
updateDecryptedFiles(draft => {
|
updateDecryptedFiles(draft => {
|
||||||
draft.set(file.path, decryptedUrl);
|
draft.set(file.path, decryptedUrl);
|
||||||
@@ -195,11 +207,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
}, [message.files, isDm, decryptedFiles]);
|
}, [message.files, isDm, decryptedFiles]);
|
||||||
|
|
||||||
async function decryptFile(file: Attachment): Promise<string | null> {
|
async function decryptFile(file: Attachment): Promise<string | null> {
|
||||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
|
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
|
||||||
debugger;
|
|
||||||
console.warn("Conditions not met")
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already decrypted
|
// Check if already decrypted
|
||||||
if (decryptedFiles.has(file.path)) {
|
if (decryptedFiles.has(file.path)) {
|
||||||
@@ -389,10 +397,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function handleProfileClick() {
|
async function handleProfileClick() {
|
||||||
if (!user.authToken || !message.username) return;
|
if (!user.authToken || !message.user_id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userProfile = await fetchUserProfile(user.authToken, message.username);
|
const userProfile = await fetchUserProfileById(user.authToken, message.user_id);
|
||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
setProfileDialog({
|
setProfileDialog({
|
||||||
...userProfile,
|
...userProfile,
|
||||||
@@ -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) {
|
function handleContextMenu(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -434,7 +480,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
{!isAuthor && !isDm && (
|
{!isAuthor && !isDm && (
|
||||||
<div className="message-profile-pic" onClick={handleProfileClick}>
|
<div className="message-profile-pic" onClick={handleProfileClick}>
|
||||||
<img
|
<img
|
||||||
src={message.profile_picture || defaultAvatar}
|
src={message.username?.startsWith("Deleted User #") ? defaultAvatar : (message.profile_picture || defaultAvatar)}
|
||||||
alt={message.username}
|
alt={message.username}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
@@ -450,6 +496,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
className="message-username"
|
className="message-username"
|
||||||
onClick={handleProfileClick}>
|
onClick={handleProfileClick}>
|
||||||
{message.username}
|
{message.username}
|
||||||
|
<StatusBadge
|
||||||
|
verified={message.verified || false}
|
||||||
|
userId={message.user_id}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -460,7 +511,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
</Quote>
|
</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 && (
|
{message.files && message.files.length > 0 && (
|
||||||
<mdui-list className="message-attachments">
|
<mdui-list className="message-attachments">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import type { Message, Size2D } from "@/core/types";
|
import type { Message, Size2D } from "@/core/types";
|
||||||
import { EmojiMenu } from "./EmojiMenu";
|
import { EmojiMenu } from "./EmojiMenu";
|
||||||
|
import { useAppState } from "@/pages/chat/state";
|
||||||
|
|
||||||
interface MessageContextMenuProps {
|
interface MessageContextMenuProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -33,6 +34,7 @@ export function MessageContextMenu({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: MessageContextMenuProps) {
|
}: MessageContextMenuProps) {
|
||||||
|
const { user } = useAppState();
|
||||||
// Internal state for closing animation
|
// Internal state for closing animation
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||||
@@ -209,7 +211,7 @@ export function MessageContextMenu({
|
|||||||
onDelete(message);
|
onDelete(message);
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
show: isAuthor
|
show: isAuthor || user.currentUser?.id === 1
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
editDmEnvelope,
|
editDmEnvelope,
|
||||||
deleteDmEnvelope
|
deleteDmEnvelope
|
||||||
} from "@/core/api/dmApi";
|
} from "@/core/api/dmApi";
|
||||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
import { fetchUserProfileById } from "@/core/api/profileApi";
|
||||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||||
@@ -84,6 +84,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
|
|
||||||
const dmMsg: Message = {
|
const dmMsg: Message = {
|
||||||
id: env.id,
|
id: env.id,
|
||||||
|
user_id: env.senderId,
|
||||||
content: content,
|
content: content,
|
||||||
username: username,
|
username: username,
|
||||||
timestamp: env.timestamp,
|
timestamp: env.timestamp,
|
||||||
@@ -353,12 +354,13 @@ export class DMPanel extends MessagePanel {
|
|||||||
if (!this.dmData || !this.currentUser.authToken) return null;
|
if (!this.dmData || !this.currentUser.authToken) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
|
const userProfile = await fetchUserProfileById(this.currentUser.authToken, this.dmData.userId);
|
||||||
if (!userProfile) return null;
|
if (!userProfile) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: userProfile.id,
|
userId: userProfile.id,
|
||||||
username: userProfile.username,
|
username: userProfile.username,
|
||||||
|
display_name: userProfile.display_name,
|
||||||
profilePicture: userProfile.profile_picture,
|
profilePicture: userProfile.profile_picture,
|
||||||
bio: userProfile.bio,
|
bio: userProfile.bio,
|
||||||
memberSince: userProfile.created_at,
|
memberSince: userProfile.created_at,
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export abstract class MessagePanel {
|
|||||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const tempMessage: Message = {
|
const tempMessage: Message = {
|
||||||
id: -1, // Temporary negative ID
|
id: -1, // Temporary negative ID
|
||||||
|
user_id: this.currentUser.currentUser?.id ?? -1,
|
||||||
username: this.currentUser.currentUser?.username ?? "You",
|
username: this.currentUser.currentUser?.username ?? "You",
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
is_read: false,
|
is_read: false,
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class PublicChatPanel extends MessagePanel {
|
|||||||
const newMsg = response.data;
|
const newMsg = response.data;
|
||||||
|
|
||||||
// Check if this is a confirmation of a message we sent
|
// Check if this is a confirmation of a message we sent
|
||||||
const isOurMessage = newMsg.username === this.currentUser.currentUser?.username;
|
const isOurMessage = newMsg.user_id === this.currentUser.currentUser?.id;
|
||||||
if (isOurMessage) {
|
if (isOurMessage) {
|
||||||
// This is our message being confirmed, find the temp message and replace it
|
// This is our message being confirmed, find the temp message and replace it
|
||||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||||
@@ -199,7 +199,8 @@ export class PublicChatPanel extends MessagePanel {
|
|||||||
|
|
||||||
async getProfile(): Promise<ProfileDialogData | null> {
|
async getProfile(): Promise<ProfileDialogData | null> {
|
||||||
return {
|
return {
|
||||||
username: "Общий чат",
|
username: "general",
|
||||||
|
display_name: "Общий чат",
|
||||||
bio: "Общаемся со всеми пользователями FromChat!",
|
bio: "Общаемся со всеми пользователями FromChat!",
|
||||||
isOwnProfile: false
|
isOwnProfile: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,8 +65,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.2.7",
|
"dompurify": "^3.2.7",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
|
"escape-string-regexp": "^5.0.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"mdui": "^2.1.4",
|
"mdui": "^2.1.4",
|
||||||
|
"motion": "^12.23.24",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.3",
|
"react-router-dom": "^7.9.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user