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
+41
View File
@@ -149,3 +149,44 @@ export async function fetchUserProfileById(token: string, userId: number): Promi
return null;
}
}
/**
* Toggles verification status for a user (owner only)
*/
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
method: 'POST',
headers: getAuthHeaders(token)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error verifying user:', error);
return null;
}
}
/**
* Checks if a user is similar to any verified user
*/
export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> {
try {
const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, {
headers: getAuthHeaders(token)
});
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.error('Error checking user similarity:', error);
return null;
}
}
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import useCombinedRefs from '@/core/hooks/useCombinedRefs';
import { id } from '@/utils/utils';
interface AutoResizeInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
autoresizing?: true;
placeholderMinWidth?: boolean;
onAutosize?: (width: number) => void;
}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
autoresizing?: false;
placeholderMinWidth?: false;
onAutosize?: undefined;
}
export function Input({
autoresizing = false,
placeholderMinWidth = false,
onAutosize,
style: inputStyle,
...inputProps
}: AutoResizeInputProps | InputProps) {
const [inputWidth, setInputWidth] = useState(0);
const sizerRef = useRef<HTMLDivElement>(null);
const placeholderSizerRef = useRef<HTMLDivElement>(null);
const [inputRef, inputElement] = useCombinedRefs<HTMLInputElement>();
const sizerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
visibility: 'hidden',
height: 0,
overflow: 'scroll',
whiteSpace: 'pre',
};
const copyStyles = useCallback((styles: CSSStyleDeclaration, node: HTMLElement) => {
node.style.fontSize = styles.fontSize;
node.style.fontFamily = styles.fontFamily;
node.style.fontWeight = styles.fontWeight;
node.style.fontStyle = styles.fontStyle;
node.style.letterSpacing = styles.letterSpacing;
node.style.textTransform = styles.textTransform;
}, []);
const updateInputWidth = useCallback(() => {
if (!sizerRef.current || typeof sizerRef.current.scrollWidth === 'undefined') {
return;
}
let newInputWidth: number;
if (inputProps.placeholder && (!inputProps.value || (inputProps.value && placeholderMinWidth))) {
const sizerWidth = sizerRef.current.scrollWidth;
const placeholderWidth = placeholderSizerRef.current?.scrollWidth || 0;
newInputWidth = Math.max(sizerWidth, placeholderWidth) + 2;
} else {
newInputWidth = sizerRef.current.scrollWidth + 2;
}
if (newInputWidth !== inputWidth) {
setInputWidth(newInputWidth);
onAutosize?.(newInputWidth);
}
}, [inputProps.placeholder, inputProps.value, inputProps.type, placeholderMinWidth, inputWidth, onAutosize]);
const copyInputStyles = useCallback(() => {
if (!inputElement.current || !window.getComputedStyle) {
return;
}
const inputStyles = window.getComputedStyle(inputElement.current);
if (!inputStyles) {
return;
}
copyStyles(inputStyles, sizerRef.current!);
if (placeholderSizerRef.current) {
copyStyles(inputStyles, placeholderSizerRef.current);
}
}, [inputElement]);
useEffect(() => {
if (autoresizing) {
copyInputStyles();
updateInputWidth();
}
}, [autoresizing, copyInputStyles, updateInputWidth]);
useEffect(() => {
if (autoresizing) {
updateInputWidth();
}
}, [inputProps.value, inputProps.placeholder, autoresizing, updateInputWidth]);
return (
<>
<input
{...inputProps}
ref={inputRef}
style={{
boxSizing: 'content-box',
width: autoresizing ? `${inputWidth}px` : undefined,
...inputStyle,
}}
/>
{autoresizing && createPortal(
<>
<div ref={sizerRef} style={sizerStyle}>
{inputProps.defaultValue || inputProps.value || ''}
</div>
{inputProps.placeholder && (
<div ref={placeholderSizerRef} style={sizerStyle}>
{inputProps.placeholder}
</div>
)}
</>,
id("root")
)}
</>
);
}
@@ -0,0 +1,9 @@
import type { TextField } from "mdui/components/text-field";
interface TextFieldProps extends React.ComponentPropsWithoutRef<"mdui-text-field"> {
ref?: React.Ref<TextField>
}
export function MaterialTextField({ ref, ...props }: TextFieldProps) {
return <mdui-text-field autocomplete="off" ref={ref as React.Ref<HTMLElement>} {...props} />
}
@@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import { checkUserSimilarity } from "@/core/api/profileApi";
import { useAppState } from "@/pages/chat/state";
interface StatusBadgeProps {
verified: boolean;
userId?: number;
size?: "small" | "medium" | "large";
}
export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) {
const [isSimilarToVerified, setIsSimilarToVerified] = useState(false);
const { user } = useAppState();
const className = `status-badge ${size}`;
// Check similarity for unverified users
useEffect(() => {
if (!verified && userId && user.authToken) {
checkUserSimilarity(userId, user.authToken)
.then(result => {
setIsSimilarToVerified(result?.isSimilar || false);
})
.catch(error => {
console.error('Error checking similarity:', error);
setIsSimilarToVerified(false);
});
} else {
setIsSimilarToVerified(false);
}
}, [verified, userId, user.authToken]);
if (verified) {
return (
<span className={`${className} verified`} title="Подтверждённый аккаунт">
<mdui-icon name="verified--filled" />
</span>
);
}
if (isSimilarToVerified) {
return (
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
<mdui-icon name="warning" />
</span>
);
}
// Don't show anything if not verified and not similar
return null;
}
@@ -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;
timestamp: string;
profile_picture?: string;
verified?: boolean;
reply_to?: Message;
files?: Attachment[];
reactions?: Reaction[];
@@ -116,6 +117,7 @@ export interface User {
admin?: boolean;
bio?: string;
profile_picture: string;
verified?: boolean;
}
/**
@@ -138,6 +140,7 @@ export interface UserProfile {
online: boolean;
last_seen: string;
created_at: string;
verified?: boolean;
}
// ----------
+98
View File
@@ -81,4 +81,102 @@ button, input {
border-left: 3px solid $color-dark-primary;
padding: 0.5rem;
}
}
// Verified badge styles
.verified-badge {
display: inline-flex;
align-items: center;
color: $color-dark-primary;
vertical-align: middle;
user-select: none;
&.small {
font-size: 14px;
width: 14px;
height: 14px;
}
&.medium {
font-size: 18px;
width: 18px;
height: 18px;
}
&.large {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Status badge styles (unified for verified and warning)
.status-badge {
display: inline-flex;
align-items: center;
user-select: none;
&.verified {
color: $color-dark-primary;
}
&.warning {
color: #ff9800; // Orange color for warnings
}
&.small mdui-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&.medium mdui-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&.large mdui-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Profile dialog specific styles
.username-with-badge {
display: flex;
align-items: center;
gap: 8px;
}
.similarity-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background-color: $color-dark-error-container;
color: $color-dark-on-error-container;
border-radius: 8px;
margin: 12px 0;
font-size: 0.9rem;
line-height: 1.4;
}
.verify-section {
margin: 16px 0;
display: flex;
justify-content: center;
}
.search-result-headline {
display: flex;
align-items: center;
gap: 6px;
}
.dm-list-headline {
display: flex;
align-items: center;
gap: 6px;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { API_BASE_URL } from "@/core/config";
import { useRef } from "react";
import type { TextField } from "mdui/components/text-field";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/TextField";
import { MaterialTextField } from "@/core/components/MaterialTextField";
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { useNavigate } from "react-router-dom";
+1 -1
View File
@@ -6,7 +6,7 @@ import { TextField } from "mdui/components/text-field";
import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types";
import { API_BASE_URL } from "@/core/config";
import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/TextField";
import { MaterialTextField } from "@/core/components/MaterialTextField";
import { ensureKeysOnLogin } from "@/core/api/authApi";
import { useNavigate } from "react-router-dom";
import "./auth.scss";
@@ -69,6 +69,9 @@
margin: 10px;
cursor: pointer;
transition: transform 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
&:hover {
transform: scale(1.05);
@@ -99,18 +99,22 @@
.username-section {
text-align: center;
.username-input {
background: none;
border: none;
font-size: 1.5rem;
font-weight: 500;
color: $color-dark-on-surface;
text-align: center;
outline: none;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
cursor: text;
.username-with-badge {
gap: 0;
.username-input {
background: none;
border: none;
font-size: 1.5rem;
font-weight: 500;
color: $color-dark-on-surface;
text-align: center;
outline: none;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
cursor: text;
}
}
}
@@ -168,6 +172,7 @@
.label {
font-size: small;
color: $color-dark-on-surface-variant;
user-select: none;
}
.value {
+1
View File
@@ -25,6 +25,7 @@ export interface ProfileDialogData {
memberSince?: string;
online?: boolean;
isOwnProfile: boolean;
verified?: boolean;
}
interface ActiveDM {
+51 -22
View File
@@ -6,8 +6,11 @@ import defaultAvatar from "@/images/default-avatar.png";
import { confirm } from "mdui/functions/confirm";
import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi";
import { RichTextArea } from "@/core/components/RichTextArea";
import { StatusBadge } from "@/core/components/StatusBadge";
import { VerifyButton } from "@/core/components/VerifyButton";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineStatus } from "./right/OnlineStatus";
import { Input } from "@/core/components/Input";
interface SectionProps {
type: string;
@@ -17,7 +20,7 @@ interface SectionProps {
value?: string;
onChange?: (value: string) => void;
readOnly: boolean;
placeholder: string;
placeholder?: string;
textArea?: boolean;
}
@@ -33,23 +36,20 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol
placeholder={placeholder}
className="value"
rows={1}
readOnly={readOnly}
/>
readOnly={readOnly} />
);
} else {
valueComponent = (
<input
className="value"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
readOnly={readOnly} />
className="value"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
readOnly={readOnly} />
);
}
} else {
valueComponent = (
<span className="value">{value}</span>
);
valueComponent = <span className="value">{value}</span>
}
return (
@@ -104,6 +104,7 @@ export function ProfileDialog() {
if (userProfile) {
freshData = {
...userProfile,
userId: userProfile.id, // Preserve the userId field
memberSince: userProfile.created_at,
isOwnProfile: profileData.isOwnProfile
};
@@ -162,6 +163,7 @@ export function ProfileDialog() {
}
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
// Validate fields when data changes
useEffect(() => {
if (currentData && isOpen) {
@@ -373,6 +375,7 @@ export function ProfileDialog() {
});
}
const fabVisible = useMemo(() => {
let hasErrors = false;
Object.values(errors).forEach(error => {
@@ -413,14 +416,20 @@ export function ProfileDialog() {
</div>
<div className={`username-section ${errors.display_name ? 'error' : ''}`}>
<input
className="username-input"
type="text"
value={currentData.display_name}
onChange={handleDisplayNameChange}
readOnly={!currentData.isOwnProfile}
placeholder="Имя"
/>
<div className="username-with-badge">
<Input
autoresizing={true}
className="username-input"
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>
)}
@@ -432,6 +441,19 @@ export function ProfileDialog() {
</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">
<Section
type="username"
@@ -452,8 +474,7 @@ export function ProfileDialog() {
onChange={handleBioChange}
readOnly={!currentData.isOwnProfile}
placeholder="Нет информации о себе"
textArea
/>
textArea />
)}
{currentData.memberSince && (
@@ -462,8 +483,16 @@ export function ProfileDialog() {
icon="calendar_month--filled"
label="Участник с:"
value={formatDate(currentData.memberSince)}
readOnly={true} />
)}
{currentData.verified && (
<Section
type="verified"
icon="verified--filled"
label="Верификация:"
value="Этот аккаунт - официальное лицо FromChat."
readOnly={true}
placeholder="Участник с:"
/>
)}
</div>
@@ -4,6 +4,7 @@ import { useDM, type DMUser } from "@/pages/chat/hooks/useDM";
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "@/core/api/authApi";
import { fetchUserPublicKey } from "@/core/api/dmApi";
import { StatusBadge } from "@/core/components/StatusBadge";
import type { Message } from "@/core/types";
import { websocket } from "@/core/websocket";
import { onlineStatusManager } from "@/core/onlineStatusManager";
@@ -19,6 +20,7 @@ interface PublicChat {
interface DMConversation {
id: number;
userId: number;
username: string;
display_name: string;
profile_picture?: string;
@@ -27,6 +29,7 @@ interface DMConversation {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
verified?: boolean;
}
type ChatItem = PublicChat | DMConversation;
@@ -84,6 +87,7 @@ export function UnifiedChatsList() {
const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({
id: user.id,
userId: user.id, // Add userId field
username: user.username,
display_name: user.display_name,
profile_picture: user.profile_picture,
@@ -261,6 +265,14 @@ export function UnifiedChatsList() {
onClick={() => handleDMClick(chat)}
style={{ cursor: "pointer" }}
>
<div slot="headline" className="dm-list-headline">
{chat.display_name}
<StatusBadge
verified={chat.verified || false}
userId={chat.userId}
size="small"
/>
</div>
<span slot="description" className="list-description">
{chat.lastMessage || "Нет сообщений"}
</span>
@@ -1,14 +1,16 @@
import { useState, useEffect } from "react";
import { useAppState } from "@/pages/chat/state";
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
import { StatusBadge } from "@/core/components/StatusBadge";
import type { User } from "@/core/types";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { OnlineIndicator } from "../right/OnlineIndicator";
import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator";
import defaultAvatar from "@/images/default-avatar.png";
import SearchBar from "@/core/components/SearchBar";
interface SearchUser extends User {
publicKey?: string | null;
verified?: boolean;
}
export function UsernameSearch() {
@@ -68,10 +70,12 @@ export function UsernameSearch() {
};
}, [searchResults]);
async function handleUserClick(searchUser: SearchUser) {
if (!user.authToken) return;
try {
let publicKey = searchUser.publicKey;
if (!publicKey) {
const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken);
@@ -150,6 +154,14 @@ export function UsernameSearch() {
onClick={() => handleUserClick(searchUser)}
style={{ cursor: "pointer" }}
>
<div slot="headline" className="search-result-headline">
{searchUser.username}
<StatusBadge
verified={searchUser.verified || false}
userId={searchUser.id}
size="small"
/>
</div>
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
<img
src={searchUser.profile_picture || defaultAvatar}
@@ -11,6 +11,7 @@ import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { getAuthHeaders } from "@/core/api/authApi";
import { useAppState } from "@/pages/chat/state";
import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi";
import { StatusBadge } from "@/core/components/StatusBadge";
import { ub64 } from "@/utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
@@ -502,6 +503,11 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
className="message-username"
onClick={handleProfileClick}>
{message.username}
<StatusBadge
verified={message.verified || false}
userId={message.user_id}
size="small"
/>
</div>
)}