Implement verification checkmark

This commit is contained in:
2025-10-21 22:49:26 +03:00
Unverified
parent 3293d91368
commit 9e19342998
23 changed files with 736 additions and 52 deletions
+27 -3
View File
@@ -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)
+2
View File
@@ -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
+8 -2
View File
@@ -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)
+9
View File
@@ -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": [
{ {
+63 -1
View File
@@ -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
}
+147
View File
@@ -0,0 +1,147 @@
"""
Similarity detection utilities for username and display name comparison.
Implements both edit distance and visual similarity detection.
"""
def levenshtein_distance(s1: str, s2: str) -> int:
"""Calculate Levenshtein distance between two strings."""
if len(s1) < len(s2):
return levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = list(range(len(s2) + 1))
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
def check_visual_similarity(s1: str, s2: str) -> bool:
"""
Check if two strings are visually similar using common homoglyphs.
Returns True if strings are visually similar.
"""
if len(s1) != len(s2):
return False
# Common homoglyph mappings
homoglyphs = {
'0': ['O', 'o', 'Q'],
'O': ['0', 'o', 'Q'],
'o': ['0', 'O', 'Q'],
'1': ['l', 'I', '|'],
'l': ['1', 'I', '|'],
'I': ['1', 'l', '|'],
'5': ['S', 's'],
'S': ['5', 's'],
's': ['5', 'S'],
'6': ['G', 'g'],
'G': ['6', 'g'],
'g': ['6', 'G'],
'8': ['B', 'b'],
'B': ['8', 'b'],
'b': ['8', 'B'],
'9': ['g', 'q'],
'g': ['9', 'q'],
'q': ['9', 'g'],
'2': ['Z', 'z'],
'Z': ['2', 'z'],
'z': ['2', 'Z'],
'3': ['E'],
'E': ['3'],
'4': ['A'],
'A': ['4'],
'7': ['T', 't'],
'T': ['7', 't'],
't': ['7', 'T'],
}
for i in range(len(s1)):
c1, c2 = s1[i], s2[i]
if c1 == c2:
continue
# Check if characters are homoglyphs
if (c1 in homoglyphs and c2 in homoglyphs[c1]) or \
(c2 in homoglyphs and c1 in homoglyphs[c2]):
continue
return False
return True
def check_username_similarity(username1: str, username2: str) -> bool:
"""
Check if two usernames are similar using both edit distance and visual similarity.
Returns True if usernames are considered similar.
"""
if username1 == username2:
return False
# Check edit distance (Levenshtein distance <= 2)
edit_distance = levenshtein_distance(username1.lower(), username2.lower())
if edit_distance <= 2:
return True
# Check visual similarity
if check_visual_similarity(username1, username2):
return True
return False
def check_display_name_similarity(display_name1: str, display_name2: str) -> bool:
"""
Check if two display names are similar using both edit distance and visual similarity.
Returns True if display names are considered similar.
"""
if display_name1 == display_name2:
return False
# Check edit distance (Levenshtein distance <= 2)
edit_distance = levenshtein_distance(display_name1.lower(), display_name2.lower())
if edit_distance <= 2:
return True
# Check visual similarity
if check_visual_similarity(display_name1, display_name2):
return True
return False
def is_user_similar_to_verified(user_username: str, user_display_name: str,
verified_users: list[dict]) -> tuple[bool, str]:
"""
Check if a user is similar to any verified user.
Args:
user_username: Username to check
user_display_name: Display name to check
verified_users: List of verified user dictionaries with 'username' and 'display_name' keys
Returns:
Tuple of (is_similar, similar_to_username)
"""
for verified_user in verified_users:
verified_username = verified_user.get('username', '')
verified_display_name = verified_user.get('display_name', '')
# Check username similarity
if check_username_similarity(user_username, verified_username):
return True, verified_username
# Check display name similarity
if check_display_name_similarity(user_display_name, verified_display_name):
return True, verified_username
return False, ""
+41
View File
@@ -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;
}
}
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import useCombinedRefs from '@/core/hooks/useCombinedRefs';
import { id } from '@/utils/utils';
interface AutoResizeInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
autoresizing?: true;
placeholderMinWidth?: boolean;
onAutosize?: (width: number) => void;
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
autoresizing?: false;
placeholderMinWidth?: false;
onAutosize?: undefined;
}
export function Input({
autoresizing = false,
placeholderMinWidth = false,
onAutosize,
style: inputStyle,
...inputProps
}: AutoResizeInputProps | InputProps) {
const [inputWidth, setInputWidth] = useState(0);
const sizerRef = useRef<HTMLDivElement>(null);
const placeholderSizerRef = useRef<HTMLDivElement>(null);
const [inputRef, inputElement] = useCombinedRefs<HTMLInputElement>();
const sizerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
visibility: 'hidden',
height: 0,
overflow: 'scroll',
whiteSpace: 'pre',
};
const copyStyles = useCallback((styles: CSSStyleDeclaration, node: HTMLElement) => {
node.style.fontSize = styles.fontSize;
node.style.fontFamily = styles.fontFamily;
node.style.fontWeight = styles.fontWeight;
node.style.fontStyle = styles.fontStyle;
node.style.letterSpacing = styles.letterSpacing;
node.style.textTransform = styles.textTransform;
}, []);
const updateInputWidth = useCallback(() => {
if (!sizerRef.current || typeof sizerRef.current.scrollWidth === 'undefined') {
return;
}
let newInputWidth: number;
if (inputProps.placeholder && (!inputProps.value || (inputProps.value && placeholderMinWidth))) {
const sizerWidth = sizerRef.current.scrollWidth;
const placeholderWidth = placeholderSizerRef.current?.scrollWidth || 0;
newInputWidth = Math.max(sizerWidth, placeholderWidth) + 2;
} else {
newInputWidth = sizerRef.current.scrollWidth + 2;
}
if (newInputWidth !== inputWidth) {
setInputWidth(newInputWidth);
onAutosize?.(newInputWidth);
}
}, [inputProps.placeholder, inputProps.value, inputProps.type, placeholderMinWidth, inputWidth, onAutosize]);
const copyInputStyles = useCallback(() => {
if (!inputElement.current || !window.getComputedStyle) {
return;
}
const inputStyles = window.getComputedStyle(inputElement.current);
if (!inputStyles) {
return;
}
copyStyles(inputStyles, sizerRef.current!);
if (placeholderSizerRef.current) {
copyStyles(inputStyles, placeholderSizerRef.current);
}
}, [inputElement]);
useEffect(() => {
if (autoresizing) {
copyInputStyles();
updateInputWidth();
}
}, [autoresizing, copyInputStyles, updateInputWidth]);
useEffect(() => {
if (autoresizing) {
updateInputWidth();
}
}, [inputProps.value, inputProps.placeholder, autoresizing, updateInputWidth]);
return (
<>
<input
{...inputProps}
ref={inputRef}
style={{
boxSizing: 'content-box',
width: autoresizing ? `${inputWidth}px` : undefined,
...inputStyle,
}}
/>
{autoresizing && createPortal(
<>
<div ref={sizerRef} style={sizerStyle}>
{inputProps.defaultValue || inputProps.value || ''}
</div>
{inputProps.placeholder && (
<div ref={placeholderSizerRef} style={sizerStyle}>
{inputProps.placeholder}
</div>
)}
</>,
id("root")
)}
</>
);
}
@@ -0,0 +1,9 @@
import type { TextField } from "mdui/components/text-field";
interface TextFieldProps extends React.ComponentPropsWithoutRef<"mdui-text-field"> {
ref?: React.Ref<TextField>
}
export function MaterialTextField({ ref, ...props }: TextFieldProps) {
return <mdui-text-field autocomplete="off" ref={ref as React.Ref<HTMLElement>} {...props} />
}
@@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state";
interface StatusBadgeProps {
verified: boolean;
userId?: number;
size?: "small" | "medium" | "large";
}
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) {
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false);
const { user } = useAppState();
const className = `status-badge ${size}`;
// Check similarity for unverified users
useEffect(() => {
if (!verified && userId && user.authToken) {
checkUserSimilarity(userId, user.authToken)
.then(result => {
setIsSimilarToVerified(result?.isSimilar || false);
})
.catch(error => {
console.error('Error checking similarity:', error);
setIsSimilarToVerified(false);
});
} else {
setIsSimilarToVerified(false);
}
}, [verified, userId, user.authToken]);
if (verified) {
return (
<span className={`${className} verified`} title="Подтверждённый аккаунт">
<mdui-icon name="verified--filled" />
</span>
);
}
if (isSimilarToVerified) {
return (
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
<mdui-icon name="warning" />
</span>
);
}
// Don't show anything if not verified and not similar
return null;
}
@@ -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>
);
}
+3
View File
@@ -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;
} }
// ---------- // ----------
+98
View File
@@ -81,4 +81,102 @@ button, input {
border-left: 3px solid $color-dark-primary; border-left: 3px solid $color-dark-primary;
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;
} }
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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,18 +99,22 @@
.username-section { .username-section {
text-align: center; text-align: center;
.username-input { .username-with-badge {
background: none; gap: 0;
border: none;
font-size: 1.5rem; .username-input {
font-weight: 500; background: none;
color: $color-dark-on-surface; border: none;
text-align: center; font-size: 1.5rem;
outline: none; font-weight: 500;
padding: 8px; color: $color-dark-on-surface;
border-radius: 4px; text-align: center;
transition: background-color 0.2s ease; outline: none;
cursor: text; padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
cursor: text;
}
} }
} }
@@ -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 {
+1
View File
@@ -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 {
+51 -22
View File
@@ -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,23 +36,20 @@ 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 = (
<input <input
className="value" className="value"
type="text" type="text"
value={value} value={value}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
readOnly={readOnly} /> readOnly={readOnly} />
); );
} }
} 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">
className="username-input" <Input
type="text" autoresizing={true}
value={currentData.display_name} className="username-input"
onChange={handleDisplayNameChange} type="text"
readOnly={!currentData.isOwnProfile} value={currentData.display_name}
placeholder="Имя" onChange={handleDisplayNameChange}
/> readOnly={!currentData.isOwnProfile}
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>
)} )}