скрытие статуса online через сокеты и поле isInvisible в профиле
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +860,8 @@ 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' && privacyExchangeMode
|
||||||
|
? t('wasRecently')
|
||||||
: chat.type === 'personal' && otherMember?.user.lastSeen
|
: chat.type === 'personal' && otherMember?.user.lastSeen
|
||||||
? `${formatLastSeen(otherMember.user.lastSeen, lang)}`
|
? `${formatLastSeen(otherMember.user.lastSeen, lang)}`
|
||||||
: chat.type === 'group'
|
: chat.type === 'group'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user