diff --git a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs index cc8587e..89a5db8 100644 --- a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs +++ b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs @@ -15,5 +15,6 @@ public class UserProfileDto public string? StatusText { get; set; } public string? StatusEmoji { get; set; } public DateTime? StatusExpiresAt { get; set; } + public bool IsInvisible { get; set; } public DateTime CreatedAt { get; set; } } diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs index 83fb099..56f2132 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs @@ -21,6 +21,7 @@ using Knot.Modules.Conversations.Application.DTOs; using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Conversations.Application.Abstractions; +using Knot.Contracts.Profiles.Domain; namespace Knot.Modules.Conversations.Infrastructure.SignalR; @@ -51,6 +52,7 @@ public sealed class ChatHub : Hub private readonly ILogger _logger; private readonly IMemoryCache _cache; private readonly IUserDisplayNameProvider _userProvider; + private readonly IProfileRepository _profileRepository; public ChatHub( ISender sender, @@ -60,7 +62,8 @@ public sealed class ChatHub : Hub IMessageRepository messageRepository, ILogger logger, IMemoryCache cache, - IUserDisplayNameProvider userProvider) + IUserDisplayNameProvider userProvider, + IProfileRepository profileRepository) { _sender = sender; _userContext = userContext; @@ -70,6 +73,7 @@ public sealed class ChatHub : Hub _logger = logger; _cache = cache; _userProvider = userProvider; + _profileRepository = profileRepository; } public override async Task OnConnectedAsync() @@ -95,7 +99,11 @@ public sealed class ChatHub : Hub 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(); } @@ -111,7 +119,14 @@ public sealed class ChatHub : Hub if (set.Count == 0) { _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); @@ -120,6 +135,52 @@ public sealed class ChatHub : Hub await base.OnDisconnectedAsync(exception); } + private async Task 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 // ──────────────────────────────────────────────────────────────── diff --git a/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs b/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs index 0643a52..faba9eb 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs @@ -8,5 +8,6 @@ public record UpdateProfileRequest( DateTime? Birthday, string? StatusText, string? StatusEmoji, - DateTime? StatusExpiresAt); + DateTime? StatusExpiresAt, + bool? IsInvisible); public record UpdateSettingsRequest(bool? HideStoryViews); diff --git a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs index e2c0ad1..b43cc1b 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs @@ -14,7 +14,8 @@ public sealed record UpdateProfileCommand( DateTime? Birthday, string? StatusText, string? StatusEmoji, - DateTime? StatusExpiresAt) : ICommand; + DateTime? StatusExpiresAt, + bool? IsInvisible) : ICommand; internal sealed class UpdateProfileCommandHandler : ICommandHandler { @@ -43,6 +44,7 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler HideStoryViews = hideStoryViews; + public void UpdateInvisible(bool isInvisible) + => IsInvisible = isInvisible; + public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt) { StatusText = statusText; diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs index e36fa2e..126bd3f 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs @@ -81,6 +81,7 @@ internal class ProfileRepository : IProfileRepository document.UpdateAvatar(dto.Avatar); document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt); + document.UpdateInvisible(dto.IsInvisible); await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct); return Result.Success(document.ToDto()); diff --git a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs index ae50d6c..b967197 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs @@ -24,6 +24,7 @@ public static class ProfileMappings StatusText = hasActiveStatus ? document.StatusText : null, StatusEmoji = hasActiveStatus ? document.StatusEmoji : null, StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null, + IsInvisible = document.IsInvisible, CreatedAt = document.CreatedAt }; } diff --git a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs index 680d428..6e228b5 100644 --- a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs +++ b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs @@ -83,7 +83,8 @@ public static class ProfilesEndpoints request.Birthday, request.StatusText, request.StatusEmoji, - request.StatusExpiresAt), + request.StatusExpiresAt, + request.IsInvisible), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); } diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index d07e698..023c16d 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -21,6 +21,7 @@ export interface User extends UserPresence { statusText?: string | null; statusEmoji?: string | null; statusExpiresAt?: string | null; + isInvisible?: boolean; createdAt: string; hideStoryViews?: boolean; } diff --git a/client-web/src/core/utils/normalize.ts b/client-web/src/core/utils/normalize.ts index 30059bc..cd2d762 100644 --- a/client-web/src/core/utils/normalize.ts +++ b/client-web/src/core/utils/normalize.ts @@ -17,7 +17,7 @@ export function normalizeUser(user: any): any { result.username = result.userName; } - // 2. Приведение avatar (Auth -> avatar, Profiles -> avatarUrl) + if (!result.avatarUrl && result.avatar) { result.avatarUrl = result.avatar; } @@ -25,7 +25,7 @@ export function normalizeUser(user: any): any { result.avatar = result.avatarUrl; } - // 3. Приведение bio (Settings -> bio, Profiles -> about) + if (!result.bio && result.about) { result.bio = result.about; } @@ -33,7 +33,7 @@ export function normalizeUser(user: any): any { result.about = result.bio; } - // 4. Приведение status (snake_case -> camelCase) + if (!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; } + // 5. Privacy flag + if (typeof result.isInvisible === 'undefined' && typeof result.is_invisible !== 'undefined') { + result.isInvisible = result.is_invisible; + } + return result; } diff --git a/client-web/src/modules/chats/application/chatStore.ts b/client-web/src/modules/chats/application/chatStore.ts index f9323bd..1350b20 100644 --- a/client-web/src/modules/chats/application/chatStore.ts +++ b/client-web/src/modules/chats/application/chatStore.ts @@ -35,6 +35,7 @@ interface ChatState { addTypingUser: (chatId: string, userId: string) => void; removeTypingUser: (chatId: string, userId: string) => void; updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void; + applyInvisiblePrivacyMode: (enabled: boolean) => void; setReplyTo: (message: Message | null) => void; setEditingMessage: (message: Message | null) => void; addChat: (chat: Chat) => void; @@ -457,6 +458,9 @@ export const useChatStore = create((set, get) => ({ }, updateUserOnlineStatus: (userId, isOnline, lastSeen) => { + if (useAuthStore.getState().user?.isInvisible) { + return; + } set((state) => ({ chats: state.chats.map((chat) => ({ ...chat, @@ -469,6 +473,22 @@ export const useChatStore = create((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 }), setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }), diff --git a/client-web/src/modules/chats/presentation/ChatPage.tsx b/client-web/src/modules/chats/presentation/ChatPage.tsx index 0709008..bea7378 100644 --- a/client-web/src/modules/chats/presentation/ChatPage.tsx +++ b/client-web/src/modules/chats/presentation/ChatPage.tsx @@ -33,6 +33,7 @@ export default function ChatPage() { addTypingUser, removeTypingUser, updateUserOnlineStatus, + applyInvisiblePrivacyMode, setPinnedMessage, removePinnedMessage, clearStore, @@ -76,6 +77,12 @@ export default function ChatPage() { loadChats(); }, [loadChats]); + useEffect(() => { + if (user?.isInvisible) { + applyInvisiblePrivacyMode(true); + } + }, [user?.isInvisible, applyInvisiblePrivacyMode]); + // Обработка закрытия вкладки — отправить disconnect useEffect(() => { const handleBeforeUnload = () => { @@ -195,10 +202,12 @@ export default function ChatPage() { }); socket.on('user_online', (data: { userId: string }) => { + if (useAuthStore.getState().user?.isInvisible) return; updateUserOnlineStatus(data.userId, true); }); socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => { + if (useAuthStore.getState().user?.isInvisible) return; updateUserOnlineStatus(data.userId, false, data.lastSeen); }); diff --git a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx index 3d858ea..32367cc 100644 --- a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx @@ -49,7 +49,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { ? otherMember?.user.avatarUrl || otherMember?.user.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 const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id); diff --git a/client-web/src/modules/chats/presentation/components/ChatView.tsx b/client-web/src/modules/chats/presentation/components/ChatView.tsx index dcb1991..ac8c8d2 100644 --- a/client-web/src/modules/chats/presentation/components/ChatView.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatView.tsx @@ -181,6 +181,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal ? otherMember?.user.avatarUrl || otherMember?.user.avatar || null : chat?.avatarUrl || chat?.avatar || null; const isOnline = chat?.type === 'personal' && otherMember?.user.isOnline; + const privacyExchangeMode = !!user?.isInvisible; const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id); @@ -859,7 +860,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal ? {t('typing')} : isOnline ? {t('online')} - : chat.type === 'personal' && otherMember?.user.lastSeen + : chat.type === 'personal' && privacyExchangeMode + ? t('wasRecently') + : chat.type === 'personal' && otherMember?.user.lastSeen ? `${formatLastSeen(otherMember.user.lastSeen, lang)}` : chat.type === 'group' ? `${chat.members.length} ${t('members')}` diff --git a/client-web/src/modules/users/infrastructure/userApi.ts b/client-web/src/modules/users/infrastructure/userApi.ts index 344b512..3109a5e 100644 --- a/client-web/src/modules/users/infrastructure/userApi.ts +++ b/client-web/src/modules/users/infrastructure/userApi.ts @@ -19,11 +19,12 @@ export class UserApi { statusText?: string | null; statusEmoji?: string | null; statusExpiresAt?: string | null; + isInvisible?: boolean; }) { - // Основной путь по ТЗ: PATCH /api/user/profile + const payload = { ...data, - about: data.bio // Дублируем для совместимости + about: data.bio }; return httpClient.request('/user/profile', { method: 'PATCH', @@ -32,7 +33,7 @@ export class UserApi { } static async updateSettings(settings: any) { - // Конечная точка в новом бэкенде: PUT /api/profiles/settings + return httpClient.request('/profiles/settings', { method: 'PUT', body: JSON.stringify(settings), @@ -40,13 +41,13 @@ export class UserApi { } static async uploadAvatar(file: File) { - // Конечная точка в новом бэкенде: POST /api/profiles/avatar + const formData = new FormData(); formData.append('avatar', file); return httpClient.request('/profiles/avatar', { method: 'POST', body: formData, - timeout: 120_000, // Аватар может быть большим, даем время на обработку + timeout: 120_000, }); } diff --git a/client-web/src/modules/users/presentation/components/SettingsPage.tsx b/client-web/src/modules/users/presentation/components/SettingsPage.tsx index a7ba8b2..39d4298 100644 --- a/client-web/src/modules/users/presentation/components/SettingsPage.tsx +++ b/client-web/src/modules/users/presentation/components/SettingsPage.tsx @@ -7,6 +7,7 @@ import { Bell, Shield, Eye, Smile } from 'lucide-react'; import { useAuthStore } from '../../../auth/application/authStore'; +import { useChatStore } from '../../../chats/application/chatStore'; import { useLang } from '../../../../core/infrastructure/i18n'; import { UserApi } from '../../infrastructure/userApi'; import { getMediaUrl, getInitials } from '../../../../core/utils/utils'; @@ -28,6 +29,7 @@ const STATUS_PRESETS = [ export default function SettingsPage() { const { user, updateUser, logout } = useAuthStore(); + const { applyInvisiblePrivacyMode } = useChatStore(); const { t, lang, setLang } = useLang(); const [editing, setEditing] = useState(false); @@ -37,11 +39,13 @@ export default function SettingsPage() { birthday: user?.birthday || '', statusText: user?.statusText || '', statusEmoji: user?.statusEmoji || '💬', - statusDuration: 'none' as 'none' | '1h' | '1d' + statusDuration: 'none' as 'none' | '1h' | '1d', + isInvisible: !!user?.isInvisible }); const [saving, setSaving] = useState(false); const [showStatusPicker, setShowStatusPicker] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); + const [updatingInvisible, setUpdatingInvisible] = useState(false); // Avatar cropping state const [cropModal, setCropModal] = useState<{ @@ -60,7 +64,8 @@ export default function SettingsPage() { birthday: user.birthday || '', statusText: user.statusText || '', statusEmoji: user.statusEmoji || '💬', - statusDuration: 'none' + statusDuration: 'none', + isInvisible: !!user.isInvisible }); } }, [user]); @@ -83,6 +88,9 @@ export default function SettingsPage() { }; const updatedUser = await UserApi.updateProfile(payload); updateUser(updatedUser); + if (payload.isInvisible) { + applyInvisiblePrivacyMode(true); + } setEditing(false); } catch (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 || '??'); return ( @@ -412,6 +446,34 @@ export default function SettingsPage() { {/* Account Section */}
+