скрытие статуса online через сокеты и поле isInvisible в профиле

This commit is contained in:
max
2026-04-17 20:47:28 +03:00
parent b346372555
commit 5245e8b7ae
16 changed files with 192 additions and 18 deletions
@@ -15,5 +15,6 @@ public class UserProfileDto
public string? StatusText { get; set; } public string? StatusText { get; set; }
public string? StatusEmoji { get; set; } public string? StatusEmoji { get; set; }
public DateTime? StatusExpiresAt { get; set; } public DateTime? StatusExpiresAt { get; set; }
public bool IsInvisible { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
} }
@@ -21,6 +21,7 @@ using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Profiles.Domain;
namespace Knot.Modules.Conversations.Infrastructure.SignalR; namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -51,6 +52,7 @@ public sealed class ChatHub : Hub
private readonly ILogger<ChatHub> _logger; private readonly ILogger<ChatHub> _logger;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly IUserDisplayNameProvider _userProvider; private readonly IUserDisplayNameProvider _userProvider;
private readonly IProfileRepository _profileRepository;
public ChatHub( public ChatHub(
ISender sender, ISender sender,
@@ -60,7 +62,8 @@ public sealed class ChatHub : Hub
IMessageRepository messageRepository, IMessageRepository messageRepository,
ILogger<ChatHub> logger, ILogger<ChatHub> logger,
IMemoryCache cache, IMemoryCache cache,
IUserDisplayNameProvider userProvider) IUserDisplayNameProvider userProvider,
IProfileRepository profileRepository)
{ {
_sender = sender; _sender = sender;
_userContext = userContext; _userContext = userContext;
@@ -70,6 +73,7 @@ public sealed class ChatHub : Hub
_logger = logger; _logger = logger;
_cache = cache; _cache = cache;
_userProvider = userProvider; _userProvider = userProvider;
_profileRepository = profileRepository;
} }
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
@@ -95,7 +99,11 @@ public sealed class ChatHub : Hub
userId, Context.ConnectionId, userChats.Count); userId, Context.ConnectionId, userChats.Count);
await Clients.Others.SendAsync("user_online", new { userId }); var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, Context.ConnectionAborted);
if (!isInvisible)
{
await BroadcastPresenceToVisibleUsersAsync("user_online", new { userId }, _userContext.UserId);
}
} }
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
@@ -111,7 +119,14 @@ public sealed class ChatHub : Hub
if (set.Count == 0) if (set.Count == 0)
{ {
_userConnections.TryRemove(userId, out _); _userConnections.TryRemove(userId, out _);
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow }); var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, Context.ConnectionAborted);
if (!isInvisible)
{
await BroadcastPresenceToVisibleUsersAsync(
"user_offline",
new { userId, lastSeen = DateTime.UtcNow },
_userContext.UserId);
}
} }
} }
_cache.Set("Global_OnlineUsersCount", _userConnections.Count); _cache.Set("Global_OnlineUsersCount", _userConnections.Count);
@@ -120,6 +135,52 @@ public sealed class ChatHub : Hub
await base.OnDisconnectedAsync(exception); await base.OnDisconnectedAsync(exception);
} }
private async Task<bool> IsUserInvisibleAsync(Guid userId, CancellationToken ct)
{
var profile = await _profileRepository.GetAsync(userId, ct);
return profile?.IsInvisible ?? false;
}
private async Task BroadcastPresenceToVisibleUsersAsync(string eventName, object payload, Guid sourceUserId)
{
var recipients = _userConnections.Keys
.Where(id => id != sourceUserId.ToString())
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToList();
if (recipients.Count == 0)
return;
var recipientProfiles = await _profileRepository.GetAsync(recipients, Context.ConnectionAborted);
var invisibleRecipientIds = recipientProfiles
.Where(p => p.IsInvisible)
.Select(p => p.UserId)
.ToHashSet();
foreach (var kvp in _userConnections)
{
if (!Guid.TryParse(kvp.Key, out var recipientId))
continue;
if (recipientId == sourceUserId)
continue;
if (invisibleRecipientIds.Contains(recipientId))
continue;
string[] connectionIds;
lock (kvp.Value)
{
connectionIds = kvp.Value.ToArray();
}
foreach (var connectionId in connectionIds)
{
await Clients.Client(connectionId).SendAsync(eventName, payload);
}
}
}
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
// Chat methods // Chat methods
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
@@ -8,5 +8,6 @@ public record UpdateProfileRequest(
DateTime? Birthday, DateTime? Birthday,
string? StatusText, string? StatusText,
string? StatusEmoji, string? StatusEmoji,
DateTime? StatusExpiresAt); DateTime? StatusExpiresAt,
bool? IsInvisible);
public record UpdateSettingsRequest(bool? HideStoryViews); public record UpdateSettingsRequest(bool? HideStoryViews);
@@ -14,7 +14,8 @@ public sealed record UpdateProfileCommand(
DateTime? Birthday, DateTime? Birthday,
string? StatusText, string? StatusText,
string? StatusEmoji, string? StatusEmoji,
DateTime? StatusExpiresAt) : ICommand<UserProfileDto>; DateTime? StatusExpiresAt,
bool? IsInvisible) : ICommand<UserProfileDto>;
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto> internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
{ {
@@ -43,6 +44,7 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
profile.StatusText = request.StatusText; profile.StatusText = request.StatusText;
profile.StatusEmoji = request.StatusEmoji; profile.StatusEmoji = request.StatusEmoji;
profile.StatusExpiresAt = request.StatusExpiresAt; profile.StatusExpiresAt = request.StatusExpiresAt;
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
var result = await _repository.UpdateAsync(profile, cancellationToken); var result = await _repository.UpdateAsync(profile, cancellationToken);
if (result.IsFailure) if (result.IsFailure)
@@ -31,6 +31,8 @@ public sealed class ProfileDocument
public bool HideStoryViews { get; private set; } public bool HideStoryViews { get; private set; }
public bool IsInvisible { get; private set; }
public bool IsBanned { get; private set; } public bool IsBanned { get; private set; }
public bool IsDeleted { get; private set; } public bool IsDeleted { get; private set; }
@@ -71,6 +73,9 @@ public sealed class ProfileDocument
public void UpdateSettings(bool hideStoryViews) public void UpdateSettings(bool hideStoryViews)
=> HideStoryViews = hideStoryViews; => HideStoryViews = hideStoryViews;
public void UpdateInvisible(bool isInvisible)
=> IsInvisible = isInvisible;
public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt) public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt)
{ {
StatusText = statusText; StatusText = statusText;
@@ -81,6 +81,7 @@ internal class ProfileRepository : IProfileRepository
document.UpdateAvatar(dto.Avatar); document.UpdateAvatar(dto.Avatar);
document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt); document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt);
document.UpdateInvisible(dto.IsInvisible);
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct); await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
return Result.Success(document.ToDto()); return Result.Success(document.ToDto());
@@ -24,6 +24,7 @@ public static class ProfileMappings
StatusText = hasActiveStatus ? document.StatusText : null, StatusText = hasActiveStatus ? document.StatusText : null,
StatusEmoji = hasActiveStatus ? document.StatusEmoji : null, StatusEmoji = hasActiveStatus ? document.StatusEmoji : null,
StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null, StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null,
IsInvisible = document.IsInvisible,
CreatedAt = document.CreatedAt CreatedAt = document.CreatedAt
}; };
} }
@@ -83,7 +83,8 @@ public static class ProfilesEndpoints
request.Birthday, request.Birthday,
request.StatusText, request.StatusText,
request.StatusEmoji, request.StatusEmoji,
request.StatusExpiresAt), request.StatusExpiresAt,
request.IsInvisible),
ct); ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
} }
+1
View File
@@ -21,6 +21,7 @@ export interface User extends UserPresence {
statusText?: string | null; statusText?: string | null;
statusEmoji?: string | null; statusEmoji?: string | null;
statusExpiresAt?: string | null; statusExpiresAt?: string | null;
isInvisible?: boolean;
createdAt: string; createdAt: string;
hideStoryViews?: boolean; hideStoryViews?: boolean;
} }
+8 -3
View File
@@ -17,7 +17,7 @@ export function normalizeUser(user: any): any {
result.username = result.userName; result.username = result.userName;
} }
// 2. Приведение avatar (Auth -> avatar, Profiles -> avatarUrl)
if (!result.avatarUrl && result.avatar) { if (!result.avatarUrl && result.avatar) {
result.avatarUrl = result.avatar; result.avatarUrl = result.avatar;
} }
@@ -25,7 +25,7 @@ export function normalizeUser(user: any): any {
result.avatar = result.avatarUrl; result.avatar = result.avatarUrl;
} }
// 3. Приведение bio (Settings -> bio, Profiles -> about)
if (!result.bio && result.about) { if (!result.bio && result.about) {
result.bio = result.about; result.bio = result.about;
} }
@@ -33,7 +33,7 @@ export function normalizeUser(user: any): any {
result.about = result.bio; result.about = result.bio;
} }
// 4. Приведение status (snake_case -> camelCase)
if (!result.statusText && result.status_text) { if (!result.statusText && result.status_text) {
result.statusText = result.status_text; result.statusText = result.status_text;
} }
@@ -44,6 +44,11 @@ export function normalizeUser(user: any): any {
result.statusExpiresAt = result.status_expires_at; result.statusExpiresAt = result.status_expires_at;
} }
// 5. Privacy flag
if (typeof result.isInvisible === 'undefined' && typeof result.is_invisible !== 'undefined') {
result.isInvisible = result.is_invisible;
}
return result; return result;
} }
@@ -35,6 +35,7 @@ interface ChatState {
addTypingUser: (chatId: string, userId: string) => void; addTypingUser: (chatId: string, userId: string) => void;
removeTypingUser: (chatId: string, userId: string) => void; removeTypingUser: (chatId: string, userId: string) => void;
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void; updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
applyInvisiblePrivacyMode: (enabled: boolean) => void;
setReplyTo: (message: Message | null) => void; setReplyTo: (message: Message | null) => void;
setEditingMessage: (message: Message | null) => void; setEditingMessage: (message: Message | null) => void;
addChat: (chat: Chat) => void; addChat: (chat: Chat) => void;
@@ -457,6 +458,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
}, },
updateUserOnlineStatus: (userId, isOnline, lastSeen) => { updateUserOnlineStatus: (userId, isOnline, lastSeen) => {
if (useAuthStore.getState().user?.isInvisible) {
return;
}
set((state) => ({ set((state) => ({
chats: state.chats.map((chat) => ({ chats: state.chats.map((chat) => ({
...chat, ...chat,
@@ -469,6 +473,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
})); }));
}, },
applyInvisiblePrivacyMode: (enabled) => {
if (!enabled) return;
set((state) => ({
chats: state.chats.map((chat) => ({
...chat,
members: chat.members.map((member) => ({
...member,
user: {
...member.user,
isOnline: false,
},
})),
})),
}));
},
setReplyTo: (message) => set({ replyTo: message, editingMessage: null }), setReplyTo: (message) => set({ replyTo: message, editingMessage: null }),
setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }), setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }),
@@ -33,6 +33,7 @@ export default function ChatPage() {
addTypingUser, addTypingUser,
removeTypingUser, removeTypingUser,
updateUserOnlineStatus, updateUserOnlineStatus,
applyInvisiblePrivacyMode,
setPinnedMessage, setPinnedMessage,
removePinnedMessage, removePinnedMessage,
clearStore, clearStore,
@@ -76,6 +77,12 @@ export default function ChatPage() {
loadChats(); loadChats();
}, [loadChats]); }, [loadChats]);
useEffect(() => {
if (user?.isInvisible) {
applyInvisiblePrivacyMode(true);
}
}, [user?.isInvisible, applyInvisiblePrivacyMode]);
// Обработка закрытия вкладки — отправить disconnect // Обработка закрытия вкладки — отправить disconnect
useEffect(() => { useEffect(() => {
const handleBeforeUnload = () => { const handleBeforeUnload = () => {
@@ -195,10 +202,12 @@ export default function ChatPage() {
}); });
socket.on('user_online', (data: { userId: string }) => { socket.on('user_online', (data: { userId: string }) => {
if (useAuthStore.getState().user?.isInvisible) return;
updateUserOnlineStatus(data.userId, true); updateUserOnlineStatus(data.userId, true);
}); });
socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => { socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => {
if (useAuthStore.getState().user?.isInvisible) return;
updateUserOnlineStatus(data.userId, false, data.lastSeen); updateUserOnlineStatus(data.userId, false, data.lastSeen);
}); });
@@ -49,7 +49,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
? otherMember?.user.avatarUrl || otherMember?.user.avatar || null ? otherMember?.user.avatarUrl || otherMember?.user.avatar || null
: chat.avatarUrl || chat.avatar || null; : chat.avatarUrl || chat.avatar || null;
const isOnline = chat.type === 'personal' && !!otherMember?.user.isOnline; const isOnline = chat.type === 'personal' && !user?.isInvisible && !!otherMember?.user.isOnline;
// Check if someone is typing in this chat // Check if someone is typing in this chat
const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id); const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id);
@@ -181,6 +181,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
? otherMember?.user.avatarUrl || otherMember?.user.avatar || null ? otherMember?.user.avatarUrl || otherMember?.user.avatar || null
: chat?.avatarUrl || chat?.avatar || null; : chat?.avatarUrl || chat?.avatar || null;
const isOnline = chat?.type === 'personal' && otherMember?.user.isOnline; const isOnline = chat?.type === 'personal' && otherMember?.user.isOnline;
const privacyExchangeMode = !!user?.isInvisible;
const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id); const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id);
@@ -859,7 +860,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
? <span className="text-primary font-black animate-pulse">{t('typing')}</span> ? <span className="text-primary font-black animate-pulse">{t('typing')}</span>
: isOnline : isOnline
? <span className="text-success">{t('online')}</span> ? <span className="text-success">{t('online')}</span>
: chat.type === 'personal' && otherMember?.user.lastSeen : chat.type === 'personal' && privacyExchangeMode
? t('wasRecently')
: chat.type === 'personal' && otherMember?.user.lastSeen
? `${formatLastSeen(otherMember.user.lastSeen, lang)}` ? `${formatLastSeen(otherMember.user.lastSeen, lang)}`
: chat.type === 'group' : chat.type === 'group'
? `${chat.members.length} ${t('members')}` ? `${chat.members.length} ${t('members')}`
@@ -19,11 +19,12 @@ export class UserApi {
statusText?: string | null; statusText?: string | null;
statusEmoji?: string | null; statusEmoji?: string | null;
statusExpiresAt?: string | null; statusExpiresAt?: string | null;
isInvisible?: boolean;
}) { }) {
// Основной путь по ТЗ: PATCH /api/user/profile
const payload = { const payload = {
...data, ...data,
about: data.bio // Дублируем для совместимости about: data.bio
}; };
return httpClient.request<User>('/user/profile', { return httpClient.request<User>('/user/profile', {
method: 'PATCH', method: 'PATCH',
@@ -32,7 +33,7 @@ export class UserApi {
} }
static async updateSettings(settings: any) { static async updateSettings(settings: any) {
// Конечная точка в новом бэкенде: PUT /api/profiles/settings
return httpClient.request('/profiles/settings', { return httpClient.request('/profiles/settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(settings), body: JSON.stringify(settings),
@@ -40,13 +41,13 @@ export class UserApi {
} }
static async uploadAvatar(file: File) { static async uploadAvatar(file: File) {
// Конечная точка в новом бэкенде: POST /api/profiles/avatar
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', file); formData.append('avatar', file);
return httpClient.request<User>('/profiles/avatar', { return httpClient.request<User>('/profiles/avatar', {
method: 'POST', method: 'POST',
body: formData, body: formData,
timeout: 120_000, // Аватар может быть большим, даем время на обработку timeout: 120_000,
}); });
} }
@@ -7,6 +7,7 @@ import {
Bell, Shield, Eye, Smile Bell, Shield, Eye, Smile
} from 'lucide-react'; } from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore'; import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../../chats/application/chatStore';
import { useLang } from '../../../../core/infrastructure/i18n'; import { useLang } from '../../../../core/infrastructure/i18n';
import { UserApi } from '../../infrastructure/userApi'; import { UserApi } from '../../infrastructure/userApi';
import { getMediaUrl, getInitials } from '../../../../core/utils/utils'; import { getMediaUrl, getInitials } from '../../../../core/utils/utils';
@@ -28,6 +29,7 @@ const STATUS_PRESETS = [
export default function SettingsPage() { export default function SettingsPage() {
const { user, updateUser, logout } = useAuthStore(); const { user, updateUser, logout } = useAuthStore();
const { applyInvisiblePrivacyMode } = useChatStore();
const { t, lang, setLang } = useLang(); const { t, lang, setLang } = useLang();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -37,11 +39,13 @@ export default function SettingsPage() {
birthday: user?.birthday || '', birthday: user?.birthday || '',
statusText: user?.statusText || '', statusText: user?.statusText || '',
statusEmoji: user?.statusEmoji || '💬', statusEmoji: user?.statusEmoji || '💬',
statusDuration: 'none' as 'none' | '1h' | '1d' statusDuration: 'none' as 'none' | '1h' | '1d',
isInvisible: !!user?.isInvisible
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showStatusPicker, setShowStatusPicker] = useState(false); const [showStatusPicker, setShowStatusPicker] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [updatingInvisible, setUpdatingInvisible] = useState(false);
// Avatar cropping state // Avatar cropping state
const [cropModal, setCropModal] = useState<{ const [cropModal, setCropModal] = useState<{
@@ -60,7 +64,8 @@ export default function SettingsPage() {
birthday: user.birthday || '', birthday: user.birthday || '',
statusText: user.statusText || '', statusText: user.statusText || '',
statusEmoji: user.statusEmoji || '💬', statusEmoji: user.statusEmoji || '💬',
statusDuration: 'none' statusDuration: 'none',
isInvisible: !!user.isInvisible
}); });
} }
}, [user]); }, [user]);
@@ -83,6 +88,9 @@ export default function SettingsPage() {
}; };
const updatedUser = await UserApi.updateProfile(payload); const updatedUser = await UserApi.updateProfile(payload);
updateUser(updatedUser); updateUser(updatedUser);
if (payload.isInvisible) {
applyInvisiblePrivacyMode(true);
}
setEditing(false); setEditing(false);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -134,6 +142,32 @@ export default function SettingsPage() {
} }
} }
const handleToggleInvisible = async () => {
if (!user || updatingInvisible) return;
const nextInvisible = !formData.isInvisible;
setUpdatingInvisible(true);
try {
const updatedUser = await UserApi.updateProfile({
displayName: user.displayName || '',
bio: user.bio || '',
birthday: user.birthday || null,
statusText: user.statusText || null,
statusEmoji: user.statusEmoji || null,
statusExpiresAt: user.statusExpiresAt || null,
isInvisible: nextInvisible,
});
updateUser(updatedUser);
setFormData((p) => ({ ...p, isInvisible: nextInvisible }));
if (nextInvisible) {
applyInvisiblePrivacyMode(true);
}
} catch (err) {
console.error('Failed to update privacy status', err);
} finally {
setUpdatingInvisible(false);
}
};
const initials = getInitials(user?.displayName || user?.username || '??'); const initials = getInitials(user?.displayName || user?.username || '??');
return ( return (
@@ -412,6 +446,34 @@ export default function SettingsPage() {
{/* Account Section */} {/* Account Section */}
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between"> <div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
<button
onClick={handleToggleInvisible}
disabled={updatingInvisible}
className={`w-full mb-3 flex items-center justify-between p-4 rounded-2xl border transition-all group ${
formData.isInvisible
? 'bg-primary/10 border-primary/30'
: 'bg-white/5 hover:bg-white/10 border-white/10'
} ${updatingInvisible ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center transition-transform ${
formData.isInvisible ? 'bg-primary/20 text-primary' : 'bg-white/10 text-white/70'
}`}>
<Eye size={16} />
</div>
<div className="text-left">
<span className="block text-[11px] font-black uppercase tracking-widest text-white/80">
{lang === 'ru' ? 'Скрывать мой статус онлайн' : 'Hide my online status'}
</span>
<span className="block text-[10px] text-white/40 mt-0.5">
{lang === 'ru' ? 'Если включено, вы тоже не видите точный статус других' : 'If enabled, you also cannot see exact status of others'}
</span>
</div>
</div>
<div className={`w-11 h-6 rounded-full p-1 transition-colors ${formData.isInvisible ? 'bg-primary' : 'bg-white/15'}`}>
<div className={`w-4 h-4 rounded-full bg-white transition-transform ${formData.isInvisible ? 'translate-x-5' : 'translate-x-0'}`} />
</div>
</button>
<button <button
onClick={() => setShowChangePasswordModal(true)} onClick={() => setShowChangePasswordModal(true)}
className="w-full mb-3 flex items-center justify-between p-4 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all group" className="w-full mb-3 flex items-center justify-between p-4 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all group"