mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement verification checkmark
This commit is contained in:
+27
-3
@@ -4,8 +4,15 @@ 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):
|
||||||
@@ -20,13 +27,30 @@ 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}")
|
print(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)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class User(Base):
|
|||||||
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)
|
||||||
messages = relationship("Message", back_populates="author", lazy="select")
|
messages = relationship("Message", back_populates="author", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
@@ -186,6 +187,7 @@ class UserProfileResponse(BaseModel):
|
|||||||
online: bool
|
online: bool
|
||||||
last_seen: datetime
|
last_seen: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
verified: bool
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ def convert_user(user: User) -> dict:
|
|||||||
"display_name": user.display_name,
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/check_auth")
|
@router.get("/check_auth")
|
||||||
@@ -113,12 +114,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,
|
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)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def convert_message(msg: Message) -> dict:
|
|||||||
"is_edited": msg.is_edited,
|
"is_edited": msg.is_edited,
|
||||||
"username": msg.author.display_name,
|
"username": msg.author.display_name,
|
||||||
"profile_picture": msg.author.profile_picture,
|
"profile_picture": msg.author.profile_picture,
|
||||||
|
"verified": msg.author.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": [
|
||||||
@@ -92,6 +93,13 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict:
|
|||||||
"username": reaction.user.display_name
|
"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()
|
||||||
|
sender_verified = sender.verified if sender else False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": envelope.id,
|
"id": envelope.id,
|
||||||
"senderId": envelope.sender_id,
|
"senderId": envelope.sender_id,
|
||||||
@@ -102,6 +110,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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 validation import is_valid_username, is_valid_display_name
|
||||||
|
from similarity import is_user_similar_to_verified
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -244,5 +245,66 @@ async def get_user_by_id(
|
|||||||
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,
|
||||||
|
verified=user.verified
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, ""
|
||||||
@@ -149,3 +149,44 @@ export async function fetchUserProfileById(token: string, userId: number): Promi
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+3
@@ -69,6 +69,7 @@ export interface Message {
|
|||||||
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[];
|
||||||
@@ -116,6 +117,7 @@ export interface User {
|
|||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
profile_picture: string;
|
profile_picture: string;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,6 +140,7 @@ export interface UserProfile {
|
|||||||
online: boolean;
|
online: boolean;
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------
|
// ----------
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -99,6 +99,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;
|
||||||
@@ -113,6 +116,7 @@
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.online-status-section {
|
.online-status-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -168,6 +172,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 {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface ProfileDialogData {
|
|||||||
memberSince?: string;
|
memberSince?: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
isOwnProfile: boolean;
|
isOwnProfile: boolean;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveDM {
|
interface ActiveDM {
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import defaultAvatar from "@/images/default-avatar.png";
|
|||||||
import { confirm } from "mdui/functions/confirm";
|
import { confirm } from "mdui/functions/confirm";
|
||||||
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
|
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";
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -17,7 +20,7 @@ interface SectionProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
placeholder: string;
|
placeholder?: string;
|
||||||
textArea?: boolean;
|
textArea?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +36,7 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="value"
|
className="value"
|
||||||
rows={1}
|
rows={1}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly} />
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
valueComponent = (
|
valueComponent = (
|
||||||
@@ -47,9 +49,7 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
valueComponent = (
|
valueComponent = <span className="value">{value}</span>
|
||||||
<span className="value">{value}</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,6 +104,7 @@ export function ProfileDialog() {
|
|||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
freshData = {
|
freshData = {
|
||||||
...userProfile,
|
...userProfile,
|
||||||
|
userId: userProfile.id, // Preserve the userId field
|
||||||
memberSince: userProfile.created_at,
|
memberSince: userProfile.created_at,
|
||||||
isOwnProfile: profileData.isOwnProfile
|
isOwnProfile: profileData.isOwnProfile
|
||||||
};
|
};
|
||||||
@@ -162,6 +163,7 @@ export function ProfileDialog() {
|
|||||||
}
|
}
|
||||||
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
||||||
|
|
||||||
|
|
||||||
// Validate fields when data changes
|
// Validate fields when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentData && isOpen) {
|
if (currentData && isOpen) {
|
||||||
@@ -373,6 +375,7 @@ export function ProfileDialog() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fabVisible = useMemo(() => {
|
const fabVisible = useMemo(() => {
|
||||||
let hasErrors = false;
|
let hasErrors = false;
|
||||||
Object.values(errors).forEach(error => {
|
Object.values(errors).forEach(error => {
|
||||||
@@ -413,14 +416,20 @@ export function ProfileDialog() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
|
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
|
||||||
<input
|
<div className="username-with-badge">
|
||||||
|
<Input
|
||||||
|
autoresizing={true}
|
||||||
className="username-input"
|
className="username-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={currentData.display_name}
|
value={currentData.display_name}
|
||||||
onChange={handleDisplayNameChange}
|
onChange={handleDisplayNameChange}
|
||||||
readOnly={!currentData.isOwnProfile}
|
readOnly={!currentData.isOwnProfile}
|
||||||
placeholder="Имя"
|
placeholder="Имя" />
|
||||||
/>
|
<StatusBadge
|
||||||
|
verified={currentData.verified || false}
|
||||||
|
userId={currentData.userId}
|
||||||
|
size="large" />
|
||||||
|
</div>
|
||||||
{errors.display_name && (
|
{errors.display_name && (
|
||||||
<div className="error-message">{errors.display_name}</div>
|
<div className="error-message">{errors.display_name}</div>
|
||||||
)}
|
)}
|
||||||
@@ -432,6 +441,19 @@ export function ProfileDialog() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Verify button for owner */}
|
||||||
|
{!currentData.isOwnProfile && currentData.userId && (
|
||||||
|
<div className="verify-section">
|
||||||
|
<VerifyButton
|
||||||
|
userId={currentData.userId}
|
||||||
|
verified={currentData.verified || false}
|
||||||
|
onVerificationChange={(verified) => {
|
||||||
|
setCurrentData({ ...currentData, verified });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="profile-sections">
|
<div className="profile-sections">
|
||||||
<Section
|
<Section
|
||||||
type="username"
|
type="username"
|
||||||
@@ -452,8 +474,7 @@ export function ProfileDialog() {
|
|||||||
onChange={handleBioChange}
|
onChange={handleBioChange}
|
||||||
readOnly={!currentData.isOwnProfile}
|
readOnly={!currentData.isOwnProfile}
|
||||||
placeholder="Нет информации о себе"
|
placeholder="Нет информации о себе"
|
||||||
textArea
|
textArea />
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentData.memberSince && (
|
{currentData.memberSince && (
|
||||||
@@ -462,8 +483,16 @@ export function ProfileDialog() {
|
|||||||
icon="calendar_month--filled"
|
icon="calendar_month--filled"
|
||||||
label="Участник с:"
|
label="Участник с:"
|
||||||
value={formatDate(currentData.memberSince)}
|
value={formatDate(currentData.memberSince)}
|
||||||
|
readOnly={true} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentData.verified && (
|
||||||
|
<Section
|
||||||
|
type="verified"
|
||||||
|
icon="verified--filled"
|
||||||
|
label="Верификация:"
|
||||||
|
value="Этот аккаунт - официальное лицо FromChat."
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
placeholder="Участник с:"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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";
|
||||||
@@ -19,6 +20,7 @@ interface PublicChat {
|
|||||||
|
|
||||||
interface DMConversation {
|
interface DMConversation {
|
||||||
id: number;
|
id: number;
|
||||||
|
userId: number;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
@@ -27,6 +29,7 @@ interface DMConversation {
|
|||||||
lastMessage?: string;
|
lastMessage?: string;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
publicKey?: string | null;
|
publicKey?: string | null;
|
||||||
|
verified?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatItem = PublicChat | DMConversation;
|
type ChatItem = PublicChat | DMConversation;
|
||||||
@@ -84,6 +87,7 @@ 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,
|
display_name: user.display_name,
|
||||||
profile_picture: user.profile_picture,
|
profile_picture: user.profile_picture,
|
||||||
@@ -261,6 +265,14 @@ export function UnifiedChatsList() {
|
|||||||
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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { fetchUserProfileById, 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";
|
||||||
@@ -502,6 +503,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user