26 Commits

Author SHA1 Message Date
max
f4f61071ed внедрил пресеты статусов и модульную архитектуру 2026-04-18 00:43:46 +03:00
max
e8c9a55fe9 безопасная обертка IsUserInvisibleAsync 2026-04-17 23:03:15 +03:00
max
bccbf12a11 фикс <был недавно> 2026-04-17 22:24:09 +03:00
Халимов Рустам
c8b0384392 Референс на контракты профиля 2026-04-17 21:07:09 +03:00
max
5245e8b7ae скрытие статуса online через сокеты и поле isInvisible в профиле 2026-04-17 20:47:28 +03:00
max
b346372555 внедрена защищенная смена пароля (бэк + фронт) 2026-04-17 19:52:57 +03:00
max
d2e6eb578a внедрил систему статусов (BIO) 2026-04-17 18:33:46 +03:00
max
ae710c3d43 поправка групп 2026-04-17 17:56:30 +03:00
Халимов Рустам
600e43eec5 Merge remote-tracking branch 'origin/bugfix_web' into bugfix_web 2026-04-17 00:47:47 +03:00
Халимов Рустам
7b251686b2 Исправление подгрузки данных профиля 2026-04-17 00:43:07 +03:00
max
00682b2977 e# speciallyse enter a commit message to explain why this merge is
necessary,
if it merges an updated upstream into a topic branch.
2026-04-17 00:42:48 +03:00
max
e05572fc3f Исправил неработющую функцию добавления пользователя через чат в группу 2026-04-17 00:42:03 +03:00
Халимов Рустам
c264b7df27 исправил ошибки компиляции TypeScript 2026-04-17 00:37:37 +03:00
Халимов Рустам
56f75ae32b Получение данных профиля 2026-04-17 00:33:45 +03:00
Халимов Рустам
ca9cf27716 О себе, редактирование в профиле 2026-04-17 00:26:38 +03:00
Халимов Рустам
7225e3272e Убрал аватары и имена внутри чата личных чатов 2026-04-16 23:54:19 +03:00
Халимов Рустам
86ae06beb6 Отступы в баблах 2026-04-16 23:48:36 +03:00
Халимов Рустам
454f70f716 Вставка и перетаскивание в поле ввода 2026-04-16 23:42:54 +03:00
Халимов Рустам
e700609d30 Дубликат печати, разметка 2026-04-16 23:36:12 +03:00
Халимов Рустам
88f39aa51f Change for env 2026-04-16 22:42:26 +03:00
Халимов Рустам
c3dbbaa7b8 Rename all 2026-04-16 22:30:37 +03:00
Халимов Рустам
2eb4f48ca0 Test create 2026-04-16 22:28:41 +03:00
Халимов Рустам
0c1adaab6c Rename web 2026-04-16 22:26:04 +03:00
Халимов Рустам
a52726d0e6 Only web 2026-04-16 22:24:41 +03:00
Халимов Рустам
d812a7a40c Change services name 2026-04-16 22:21:58 +03:00
Халимов Рустам
c8b4fed25a Change ports 2026-04-16 22:18:04 +03:00
72 changed files with 1739 additions and 963 deletions

View File

@@ -1,13 +1,13 @@
using FluentAssertions; using FluentAssertions;
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile; using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
using Knot.Modules.Profiles.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Modules.Profiles.Application.Abstractions; using Knot.Contracts.Profiles.Domain;
using Knot.Contracts.Profiles.Application.DTOs;
namespace Knot.Modules.Profiles.UnitTests; namespace Knot.Modules.Profiles.UnitTests;
@@ -25,14 +25,11 @@ public class UpdateProfileCommandHandlerTests
[Fact] [Fact]
public async Task Handle_ShouldReturnError_WhenProfileNotFound() public async Task Handle_ShouldReturnError_WhenProfileNotFound()
{ {
// Arrange var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null);
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null); _profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null);
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
// Act
var result = await _handler.Handle(command, CancellationToken.None); var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue(); result.IsFailure.Should().BeTrue();
} }
} }

View File

@@ -4,7 +4,6 @@ public interface IJwtTokenProvider
{ {
string GenerateAccessToken(Guid userId, string username); string GenerateAccessToken(Guid userId, string username);
string GenerateRefreshToken(); string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
string Generate(Guid userId, string username, string displayName, string? avatar); string Generate(Guid userId, string username, string displayName, string? avatar);
string Generate(Domain.UserContract user); string Generate(Domain.UserContract user);
} }

View File

@@ -7,7 +7,8 @@ public static class AuthErrors
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials"); public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled"); public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken"); public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
public static Error UserNotFound => new("Auth.UserNotFound", "User not found"); public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
public static Error PasswordConfirmationMismatch => new("Auth.PasswordConfirmationMismatch", "Passwords do not match");
public static Error PasswordTooShort => new("Auth.PasswordTooShort", "Password must be at least 8 characters");
public static Error OldPasswordInvalid => new("Auth.OldPasswordInvalid", "Current password is incorrect");
} }

View File

@@ -19,12 +19,4 @@ public class UserContract
public bool IsExternal { get; set; } public bool IsExternal { get; set; }
public string? Domain { get; set; } public string? Domain { get; set; }
public DateTime? LastSeen { get; set; } public DateTime? LastSeen { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; }
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
} }

View File

@@ -13,7 +13,6 @@ public interface IMessageRepository
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken); Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken); Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken); Task UpdateAsync(Message message, CancellationToken cancellationToken);

View File

@@ -19,16 +19,13 @@ public abstract class Message : AggregateRoot<Guid>
public bool IsEdited => HasState(MessageState.IsEdited); public bool IsEdited => HasState(MessageState.IsEdited);
public bool IsDeleted => HasState(MessageState.IsDeleted); public bool IsDeleted => HasState(MessageState.IsDeleted);
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id) : base(id)
{ {
ChatId = chatId; ChatId = chatId;
@@ -45,25 +42,15 @@ public abstract class Message : AggregateRoot<Guid>
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId); public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
public virtual void Delete() => AddState(MessageState.IsDeleted); public virtual void Delete() => AddState(MessageState.IsDeleted);
public virtual void Edit(string newContent) public virtual void Edit(string newContent)
{ {
Content = newContent; Content = newContent;
AddState(MessageState.IsEdited); AddState(MessageState.IsEdited);
} }
public void DeleteForUser(Guid userId) public void DeleteForUser(Guid userId)
{ {
if (!_deletedFor.Exists(x => x.UserId == userId)) if (!_deletedFor.Exists(x => x.UserId == userId))
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }

View File

@@ -9,4 +9,8 @@ public static class ProfilesErrors
public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found"); public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found");
public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format"); public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format");
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed"); public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
public static Error BioTooLong => new("Profiles.BioTooLong", "Bio must be 200 characters or less");
public static Error StatusTextTooLong => new("Profiles.StatusTextTooLong", "Status text must be 50 characters or less");
public static Error StatusEmpty => new("Statuses.Empty", "Status emoji or text is required");
public static Error InvalidPreset => new("Statuses.InvalidPreset", "Unknown status preset");
} }

View File

@@ -0,0 +1,9 @@
namespace Knot.Contracts.Profiles.Application.DTOs;
public sealed class StatusPresetDto
{
public string Id { get; set; } = "";
public string Emoji { get; set; } = "";
public string TextRu { get; set; } = "";
public string TextEn { get; set; } = "";
}

View File

@@ -12,5 +12,13 @@ public class UserProfileDto
public DateTime? LastSeen { get; set; } public DateTime? LastSeen { get; set; }
public DateTime? Birthday { get; set; } public DateTime? Birthday { get; set; }
public bool IsPremium { get; set; } public bool IsPremium { get; set; }
public UserStatusDto? Status { get; set; }
public Guid? CurrentStatusId { get; set; }
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; } public DateTime CreatedAt { get; set; }
} }

View File

@@ -0,0 +1,11 @@
namespace Knot.Contracts.Profiles.Application.DTOs;
public sealed class UserStatusDto
{
public Guid Id { get; set; }
public string Type { get; set; } = "Custom";
public string Emoji { get; set; } = "";
public string Text { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,11 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Shared.Kernel;
namespace Knot.Contracts.Profiles.Domain;
public interface IProfileStatusWriter
{
Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default);
Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using Knot.Contracts.Profiles.Application.DTOs;
namespace Knot.Contracts.Profiles.Domain;
public interface IUserStatusRepository
{
Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -250,6 +250,7 @@ app.MapStoriesEndpoints();
app.MapContactsEndpoints(); app.MapContactsEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapProfilesEndpoints(); app.MapProfilesEndpoints();
app.MapStatusesEndpoints();
app.MapFederationEndpoints(); app.MapFederationEndpoints();
app.MapKlipyEndpoints(); app.MapKlipyEndpoints();
app.MapChatsEndpoints(); app.MapChatsEndpoints();

View File

@@ -13,8 +13,7 @@
"Secret": "knot_super_secret_key_1234567890_knot", "Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot", "Issuer": "Knot",
"Audience": "KnotUsers", "Audience": "KnotUsers",
"ExpiryInMinutes": 1440, "ExpiryInMinutes": 1440
"RefreshExpiryInDays": 30
}, },
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot" "KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
} }

View File

@@ -5,8 +5,5 @@ namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider public interface IJwtTokenProvider
{ {
string Generate(User user); string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
} }

View File

@@ -0,0 +1,48 @@
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Auth.Application.Users.ChangePassword;
public sealed record ChangePasswordCommand(
Guid UserId,
string OldPassword,
string NewPassword,
string ConfirmPassword) : ICommand;
internal sealed class ChangePasswordCommandHandler : ICommandHandler<ChangePasswordCommand>
{
private readonly IAuthDbContext _dbContext;
public ChangePasswordCommandHandler(IAuthDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Result> Handle(ChangePasswordCommand request, CancellationToken cancellationToken)
{
if (request.NewPassword != request.ConfirmPassword)
return Result.Failure(AuthErrors.PasswordConfirmationMismatch);
if (request.NewPassword.Length < 8)
return Result.Failure(AuthErrors.PasswordTooShort);
var user = await _dbContext.Set<User>()
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user is null)
return Result.Failure(AuthErrors.UserNotFound);
if (!BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash))
return Result.Failure(AuthErrors.OldPasswordInvalid);
user.ChangePassword(BCrypt.Net.BCrypt.HashPassword(request.NewPassword));
user.SetRefreshToken(null);
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -1,6 +1,4 @@
using System.IdentityModel.Tokens.Jwt; using Knot.Contracts.Auth.Application.Auth.DTOs;
using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
@@ -11,12 +9,10 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto> internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider) public GetMeQueryHandler(IUserRepository userRepository)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_tokenProvider = tokenProvider;
} }
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken) public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -27,18 +23,9 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound); return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
} }
// Check if access token needs to be refreshed (less than 1 hour remaining)
string? newAccessToken = null;
// We can't directly check the current token's expiry here, but we can
// always issue a new token if the user is authenticated
// For now, let's issue a new token on every request (simplified approach)
// A better approach would be to parse the incoming token and check expiry
newAccessToken = _tokenProvider.Generate(user);
var response = new AuthResponseDto var response = new AuthResponseDto
{ {
AccessToken = newAccessToken, AccessToken = string.Empty,
RefreshToken = string.Empty, RefreshToken = string.Empty,
UserId = user.Id, UserId = user.Id,
Username = user.Username, Username = user.Username,

View File

@@ -5,6 +5,9 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login; namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>; public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto> public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -28,21 +31,14 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
} }
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar); string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
// Save refresh token to database
user.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto return Result.Success(new AuthResponseDto
{ {
AccessToken = token, AccessToken = token,
RefreshToken = refreshToken, RefreshToken = string.Empty,
UserId = user.Id, UserId = user.Id,
Username = user.Username, Username = user.Username,
DisplayName = user.DisplayName DisplayName = user.DisplayName
}); });
} }
} }

View File

@@ -1,7 +0,0 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;

View File

@@ -1,65 +0,0 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public RefreshTokenCommandHandler(
IUserRepository userRepository,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
}
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
}
// Check if refresh token has expired
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
{
// Clear expired refresh token
user.SetRefreshToken(null, null);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Failure<AuthResponseDto>(
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
}
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Avatar = user.Avatar
});
}
}

View File

@@ -1,4 +1,5 @@
using BCrypt.Net; using BCrypt.Net;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions; using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs; using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
@@ -8,6 +9,9 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register; namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </summary>
public sealed record RegisterUserCommand( public sealed record RegisterUserCommand(
string Username, string Username,
string Password, string Password,
@@ -15,6 +19,9 @@ public sealed record RegisterUserCommand(
string? Email, string? Email,
string? Bio) : ICommand<AuthResponseDto>; string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto> internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{ {
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
@@ -41,13 +48,16 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled); return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
} }
// 1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken)) if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{ {
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique); return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
} }
// 2. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
var user = User.Create( var user = User.Create(
request.Username, request.Username,
passwordHash, passwordHash,
@@ -55,33 +65,21 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email, request.Email,
request.Bio); request.Bio);
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository; // 4. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> - <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20> Domain User
repoImpl?.Add(user); var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
repoWithDomainUserAdd?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
// Get the saved user as contract string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
if (userContract == null)
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
}
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
string refreshToken = _tokenProvider.GenerateRefreshToken();
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
userContract.SetRefreshToken(refreshToken, refreshExpiry);
await _userRepository.UpdateAsync(userContract, cancellationToken);
return Result.Success(new AuthResponseDto return Result.Success(new AuthResponseDto
{ {
AccessToken = token, AccessToken = token,
RefreshToken = refreshToken, RefreshToken = string.Empty,
UserId = userContract.Id, UserId = user.Id,
Username = userContract.Username, Username = user.Username,
DisplayName = userContract.DisplayName DisplayName = user.DisplayName
}); });
} }
} }

View File

@@ -26,7 +26,6 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; } public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; } public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; } public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; } public DateTime? BannedUntil { get; private set; }
public void Ban() { public void Ban() {
@@ -49,10 +48,9 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber; PhoneNumber = phoneNumber;
} }
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null) public void SetRefreshToken(string? refreshToken)
{ {
RefreshToken = refreshToken; RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
} }
public void SetBannedUntil(DateTime? bannedUntil) public void SetBannedUntil(DateTime? bannedUntil)
@@ -81,8 +79,6 @@ public sealed class User : AggregateRoot<Guid>
BannedUntil = contract.BannedUntil; BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen); SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain; UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
} }
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null) private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -185,9 +181,7 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline, IsOnline = IsOnline,
IsExternal = IsExternal, IsExternal = IsExternal,
Domain = _domain, Domain = _domain,
LastSeen = LastSeen, LastSeen = LastSeen
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
}; };
} }
} }

View File

@@ -50,12 +50,6 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
return Convert.ToBase64String(randomBytes); return Convert.ToBase64String(randomBytes);
} }
public DateTime GetRefreshTokenExpiry()
{
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
return DateTime.UtcNow.AddDays(expiryInDays);
}
public string Generate(Guid userId, string username, string displayName, string? avatar) public string Generate(Guid userId, string username, string displayName, string? avatar)
{ {
var claims = new Claim[] var claims = new Claim[]

View File

@@ -1,100 +0,0 @@
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AuthDbContext))]
[Migration("20270419220000_AddRefreshTokenExpiry")]
partial class AddRefreshTokenExpiry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
.HasAnnotation("Relational:DefaultSchema", "identity");
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokenExpiry : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users");
}
}
}

View File

@@ -1,18 +1,20 @@
using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.RefreshToken;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.ChangePassword;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Auth.Presentation.Endpoints; namespace Knot.Modules.Auth.Presentation.Endpoints;
public static class AuthEndpoints public static class AuthEndpoints
{ {
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword, string ConfirmPassword);
public static void MapAuthEndpoints(this WebApplication app) public static void MapAuthEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("api/auth"); var group = app.MapGroup("api/auth");
@@ -29,16 +31,24 @@ public static class AuthEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized(); return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
}); });
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
});
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) => group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{ {
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct); var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
}).RequireAuthorization(); }).RequireAuthorization();
group.MapPost("change-password", async ([FromBody] ChangePasswordRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(
new ChangePasswordCommand(userContext.UserId, request.OldPassword, request.NewPassword, request.ConfirmPassword),
ct);
if (result.IsSuccess) return Results.Ok();
if (result.Error.Code == "Auth.OldPasswordInvalid")
return Results.StatusCode(StatusCodes.Status403Forbidden);
return Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description });
}).RequireAuthorization();
} }
} }

View File

@@ -1,15 +1,13 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR; using MediatR;
using Microsoft.AspNetCore.SignalR; using System.Linq;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete; namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -21,21 +19,17 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage; private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _uow; private readonly IChatsUnitOfWork _uow;
private readonly IHubContext<ChatHub> _hubContext;
public LeaveOrDeleteChatCommandHandler( public LeaveOrDeleteChatCommandHandler(
IChatRepository chatRepository, IChatRepository chatRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
IFileStorageService fileStorage, IFileStorageService fileStorage,
IChatsUnitOfWork uow, IChatsUnitOfWork uow)
IHubContext<ChatHub> hubContext)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_messageRepository = messageRepository; _messageRepository = messageRepository;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_uow = uow; _uow = uow;
_hubContext = hubContext;
} }
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken) public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
@@ -64,14 +58,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
// DELETE ALL MESSAGES AND FILES FIRST // DELETE ALL MESSAGES AND FILES FIRST
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken); await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
_chatRepository.Remove(chat); _chatRepository.Remove(chat);
// Notify all remaining members that the chat was deleted
foreach (var member in chat.Members)
{
await _hubContext.Clients.User(member.UserId.ToString())
.SendAsync("chat_deleted", chat.Id.ToString(), cancellationToken);
}
} }
await _uow.SaveChangesAsync(cancellationToken); await _uow.SaveChangesAsync(cancellationToken);
@@ -81,8 +67,7 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct) private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
{ {
try try
{ {
// Get all messages directly from Mongo (not paged) // Get all messages directly from Mongo (not paged)
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct); var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);

View File

@@ -32,8 +32,7 @@ public record MessageDetailDto(
bool? PollIsMultipleChoice = null, bool? PollIsMultipleChoice = null,
bool? PollIsAnonymous = null, bool? PollIsAnonymous = null,
bool? PollIsClosed = null, bool? PollIsClosed = null,
List<Guid>? UserVotedOptionIds = null, List<Guid>? UserVotedOptionIds = null
bool IsDeletedForUser = false
); );
public record ReplyToMessageDto( public record ReplyToMessageDto(

View File

@@ -42,16 +42,10 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
if (request.DeleteForAll) if (request.DeleteForAll)
{ {
// Only message sender can delete for everyone
if (message.SenderId == request.UserId) if (message.SenderId == request.UserId)
{ {
message.Delete(); message.Delete();
} }
else
{
// If not the sender, just delete for current user
message.DeleteForUser(request.UserId);
}
} }
else else
{ {
@@ -61,13 +55,24 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
await _messageRepository.UpdateAsync(message, cancellationToken); await _messageRepository.UpdateAsync(message, cancellationToken);
} }
// Notify all clients in the chat about the deletion if (request.DeleteForAll)
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{ {
chatId = request.ChatId, await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
messageIds = request.MessageIds, {
deleteForAll = request.DeleteForAll chatId = request.ChatId,
}); messageIds = request.MessageIds,
deleteForAll = true
});
}
else
{
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = false
});
}
return global::Knot.Shared.Kernel.Result.Success(); return global::Knot.Shared.Kernel.Result.Success();
} }

View File

@@ -3,17 +3,17 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
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.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR; using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages; namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>; public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>> internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{ {
@@ -41,12 +41,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
List<Message> messages; List<Message> messages;
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit; int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
if (request.AfterSequenceId.HasValue) if (request.Pivot.HasValue)
{
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
}
else if (request.Pivot.HasValue)
{ {
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken); messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
} }
@@ -119,8 +114,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
senders.TryGetValue(message.SenderId, out var sender); senders.TryGetValue(message.SenderId, out var sender);
reactionsByMessage.TryGetValue(message.Id, out var reactions); reactionsByMessage.TryGetValue(message.Id, out var reactions);
Message? replyMsg = null; Message? replyMsg = null;
if (message.ReplyToId.HasValue) if (message.ReplyToId.HasValue)
{ {
@@ -147,7 +141,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(), replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
) : null, ) : null,
(message as TextMessage)?.Quote, message is TextMessage tm ? tm.Quote : null,
message.IsEdited, message.IsEdited,
message.IsDeleted, message.IsDeleted,
message.CreatedAt, message.CreatedAt,
@@ -159,23 +153,20 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(message as StoryMessage)?.StoryMediaType, (message as StoryMessage)?.StoryMediaType,
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(), (message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(), new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
reactions?.Select(r => reactions?.Select(r => {
{
senders.TryGetValue(r.UserId, out var ru); senders.TryGetValue(r.UserId, out var ru);
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null); return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
}).ToList() ?? new List<MessageReactionDto>(), }).ToList() ?? new List<MessageReactionDto>(),
(message as CallMessage)?.CallType, (message as CallMessage)?.CallType,
(message as CallMessage)?.CallStatus, (message as CallMessage)?.CallStatus,
(message as CallMessage)?.Duration, (message as CallMessage)?.Duration,
(message as PollMessage)?.Options.Select(o => (message as PollMessage)?.Options.Select(o => {
{
var pm = (PollMessage)message; var pm = (PollMessage)message;
var voters = pm.IsAnonymous == false var voters = pm.IsAnonymous == false
? pm.Votes ? pm.Votes
.Where(v => v.OptionId == o.Id) .Where(v => v.OptionId == o.Id)
.Select(v => .Select(v => {
{
senders.TryGetValue(v.UserId, out var vu); senders.TryGetValue(v.UserId, out var vu);
return vu != null return vu != null
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar) ? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
@@ -183,13 +174,12 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
}) })
.ToList() .ToList()
: null; : null;
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null); return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
}).ToList(), }).ToList(),
(message as PollMessage)?.IsMultipleChoice, (message as PollMessage)?.IsMultipleChoice,
(message as PollMessage)?.IsAnonymous, (message as PollMessage)?.IsAnonymous,
(message as PollMessage)?.IsClosed, (message as PollMessage)?.IsClosed,
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(), (message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
message.IsDeletedForUser(request.UserId)
)); ));
} }

View File

@@ -1,8 +1,7 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Read; namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -12,13 +11,11 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
{ {
private readonly IChatRepository _chatRepository; private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMessageRepository _messageRepository;
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository) public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_messageRepository = messageRepository;
} }
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken) public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
@@ -31,23 +28,6 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId); member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
var messages = await _messageRepository.GetChatMessagesAfterAsync(
request.ChatId,
0,
1000,
cancellationToken);
foreach (var message in messages)
{
if (message.SequenceId <= request.LastReadSequenceId &&
message.SenderId != request.UserId &&
!message.IsReadBy(request.UserId))
{
message.MarkAsRead(request.UserId);
}
}
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(); return Result.Success();

View File

@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR; using MediatR;
@@ -41,8 +41,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken); var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList()); var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
var result = messages.Select(message => var result = messages.Select(message => {
{
var textMessage = message as TextMessage; var textMessage = message as TextMessage;
var mediaMessage = message as MediaMessage; var mediaMessage = message as MediaMessage;
var storyMessage = message as StoryMessage; var storyMessage = message as StoryMessage;
@@ -67,7 +66,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(), mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(), reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList() new List<ReadByDto>()
); );
}).ToList(); }).ToList();

View File

@@ -1,8 +1,8 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
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.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send; namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -192,7 +192,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var senderMember = chat.Members.First(m => m.UserId == request.SenderId); var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
senderMember.UpdateReadCursor(message.Id, message.SequenceId); senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id); senderMember.UpdateDeliveredCursor(message.Id);
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
// 5. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> // 5. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
_messageRepository.Add(message); _messageRepository.Add(message);

View File

@@ -1,26 +1,27 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Security.Claims; using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Modules.Conversations.Application.Messages.Edit;
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; namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -37,7 +38,7 @@ public sealed class ChatHub : Hub
public static int OnlineUsersCount => _userConnections.Count; public static int OnlineUsersCount => _userConnections.Count;
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId); public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
// userId → CallSession (one user can be in only one call at a time) // userId → CallSession (one user can be in only one call at a time)
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new(); private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
// chatId → (startTime, callType) // chatId → (startTime, callType)
@@ -51,16 +52,18 @@ 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,
IUserContext userContext, IUserContext userContext,
IChatRepository chatRepository, IChatRepository chatRepository,
IUserRepository userRepository, IUserRepository userRepository,
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,15 @@ 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,
Context.ConnectionAborted);
}
} }
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
@@ -111,7 +123,22 @@ 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 }); try
{
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
if (!isInvisible)
{
await BroadcastPresenceToVisibleUsersAsync(
"user_offline",
new { userId, lastSeen = DateTime.UtcNow },
_userContext.UserId,
CancellationToken.None);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Presence broadcast on disconnect failed for {UserId}", userId);
}
} }
} }
_cache.Set("Global_OnlineUsersCount", _userConnections.Count); _cache.Set("Global_OnlineUsersCount", _userConnections.Count);
@@ -120,6 +147,56 @@ 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,
CancellationToken cancellationToken)
{
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, cancellationToken);
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
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
@@ -158,7 +235,6 @@ public sealed class ChatHub : Hub
await _sender.Send(command); await _sender.Send(command);
} }
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{ {
ChatId = request.ChatId.ToString(), ChatId = request.ChatId.ToString(),
@@ -262,7 +338,7 @@ public sealed class ChatHub : Hub
{ {
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId }); var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>()); var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
{ {
chatId = request.ChatId, chatId = request.ChatId,
@@ -277,7 +353,7 @@ public sealed class ChatHub : Hub
{ {
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId); var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
var result = await _sender.Send(command); var result = await _sender.Send(command);
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
{ {
chatId = request.ChatId, chatId = request.ChatId,
@@ -329,7 +405,7 @@ public sealed class ChatHub : Hub
public async Task FriendAccepted(FriendSignalRequest request) public async Task FriendAccepted(FriendSignalRequest request)
{ {
if (request == null || string.IsNullOrEmpty(request.FriendId)) return; if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId); _logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId }); await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
} }
@@ -351,8 +427,8 @@ public sealed class ChatHub : Hub
{ {
if (string.IsNullOrEmpty(targetUserId)) if (string.IsNullOrEmpty(targetUserId))
{ {
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId"); _logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
return; return;
} }
if (_userConnections.TryGetValue(targetUserId, out var connectionIds)) if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
@@ -399,15 +475,15 @@ public sealed class ChatHub : Hub
// Track session for history // Track session for history
Guid? chatId = null; Guid? chatId = null;
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId; if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
if (!chatId.HasValue) if (!chatId.HasValue)
{ {
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
if (Guid.TryParse(request.TargetUserId, out var targetId)) if (Guid.TryParse(request.TargetUserId, out var targetId))
{ {
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId)); var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
if (personalChat != null) chatId = personalChat.Id; if (personalChat != null) chatId = personalChat.Id;
} }
} }
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow); var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
@@ -462,10 +538,10 @@ public sealed class ChatHub : Hub
_activeSessionsByUser.TryRemove(request.TargetUserId, out _); _activeSessionsByUser.TryRemove(request.TargetUserId, out _);
if (session.ChatId.HasValue) if (session.ChatId.HasValue)
{ {
int duration = session.IsAnswered && session.AnswerTime.HasValue int duration = session.IsAnswered && session.AnswerTime.HasValue
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds ? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
: 0; : 0;
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed"); string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration); await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
} }
@@ -574,7 +650,7 @@ public sealed class ChatHub : Hub
var userInfo = new ParticipantInfo(userId, username, displayName, avatar); var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
{ {
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType); _activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
return new ConcurrentDictionary<string, ParticipantInfo>(); return new ConcurrentDictionary<string, ParticipantInfo>();

View File

@@ -12,6 +12,7 @@
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" /> <ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" /> <ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" /> <ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -19,17 +19,9 @@ public static class MessagesEndpoints
{ {
var group = app.MapGroup("api/messages").RequireAuthorization(); var group = app.MapGroup("api/messages").RequireAuthorization();
group.MapGet("chat/{chatId:guid}", async ( group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
[FromRoute] Guid chatId,
[FromQuery] string? cursor,
[FromQuery] long? afterSequenceId,
[FromQuery] long? pivot,
[FromQuery] int? limit,
ISender sender,
IUserContext userContext,
CancellationToken ct) =>
{ {
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct); var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
}); });

View File

@@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot<Guid>
public Guid SenderId { get; protected set; } public Guid SenderId { get; protected set; }
public DateTime CreatedAt { get; protected set; } public DateTime CreatedAt { get; protected set; }
public long SequenceId { get; protected set; } public long SequenceId { get; protected set; }
public void SetSequenceId(long sequenceId) public void SetSequenceId(long sequenceId)
{ {
SequenceId = sequenceId; SequenceId = sequenceId;
@@ -26,10 +26,10 @@ public abstract class Message : AggregateRoot<Guid>
// ================== Опциональные метаданные (общего назначения) ================== // ================== Опциональные метаданные (общего назначения) ==================
public Guid? ReplyToId { get; protected set; } public Guid? ReplyToId { get; protected set; }
public Guid? ForwardedFromId { get; protected set; } public Guid? ForwardedFromId { get; protected set; }
// ================== Флаги ================== // ================== Флаги ==================
public MessageState State { get; protected set; } public MessageState State { get; protected set; }
// ================== Абстрактные / Виртуальные свойства ================== // ================== Абстрактные / Виртуальные свойства ==================
public abstract string Type { get; } public abstract string Type { get; }
public abstract string? Content { get; protected set; } public abstract string? Content { get; protected set; }
@@ -42,20 +42,16 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Прочитано ==================
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
// ================== Инфраструктурный конструктор EF ================== // ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
protected Message( protected Message(
Guid id, Guid id,
Guid chatId, Guid chatId,
Guid senderId, Guid senderId,
Guid? replyToId, Guid? replyToId,
Guid? forwardedFromId, Guid? forwardedFromId,
DateTime createdAt, DateTime createdAt,
bool isImported) : base(id) bool isImported) : base(id)
{ {
ChatId = chatId; ChatId = chatId;
@@ -63,7 +59,7 @@ public abstract class Message : AggregateRoot<Guid>
ReplyToId = replyToId; ReplyToId = replyToId;
ForwardedFromId = forwardedFromId; ForwardedFromId = forwardedFromId;
CreatedAt = createdAt; CreatedAt = createdAt;
if (isImported) AddState(MessageState.IsImported); if (isImported) AddState(MessageState.IsImported);
} }
@@ -93,16 +89,6 @@ public abstract class Message : AggregateRoot<Guid>
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }

View File

@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
size = m.Size size = m.Size
}).ToList() ?? (object)Array.Empty<object>(), }).ToList() ?? (object)Array.Empty<object>(),
sender = senderObj, sender = senderObj,
readBy = message.ReadByUsers.Select(id => new { id }).ToList(), readBy = new List<object>(),
storyId = (message as StoryMessage)?.StoryId, storyId = (message as StoryMessage)?.StoryId,
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl, storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
storyMediaType = (message as StoryMessage)?.StoryMediaType, storyMediaType = (message as StoryMessage)?.StoryMediaType,

View File

@@ -95,28 +95,14 @@ public sealed class MessageRepository : IMessageRepository
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
var filter = builder.And(
builder.Eq(m => m.ChatId, chatId),
builder.Gt(m => m.SequenceId, sequenceId)
);
return await _messages.Find(filter)
.SortBy(m => m.SequenceId)
.Limit(limit)
.ToListAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken) public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{ {
var builder = Builders<Message>.Filter; var builder = Builders<Message>.Filter;
// Target message // Target message
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId)); var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken); var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
// Older messages // Older messages
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId)); var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
var older = await _messages.Find(olderFilter) var older = await _messages.Find(olderFilter)

View File

@@ -2,5 +2,9 @@ using System;
namespace Knot.Modules.Profiles.Application.Profiles.DTOs; namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday); public record UpdateProfileRequest(
string? DisplayName,
string? Bio,
DateTime? Birthday,
bool? IsInvisible);
public record UpdateSettingsRequest(bool? HideStoryViews); public record UpdateSettingsRequest(bool? HideStoryViews);

View File

@@ -11,7 +11,8 @@ public sealed record UpdateProfileCommand(
Guid UserId, Guid UserId,
string? DisplayName, string? DisplayName,
string? Bio, string? Bio,
DateTime? Birthday) : ICommand<UserProfileDto>; DateTime? Birthday,
bool? IsInvisible) : ICommand<UserProfileDto>;
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto> internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
{ {
@@ -28,9 +29,13 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
if (profile is null) if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound); return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if ((request.Bio?.Length ?? 0) > 200)
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
profile.DisplayName = request.DisplayName ?? profile.DisplayName; profile.DisplayName = request.DisplayName ?? profile.DisplayName;
profile.About = request.Bio; profile.About = request.Bio;
profile.Birthday = request.Birthday; profile.Birthday = request.Birthday;
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)

View File

@@ -0,0 +1,21 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Profiles.Application.Statuses;
public sealed record ClearUserStatusCommand(Guid UserId) : IRequest<Result<UserProfileDto>>;
public sealed class ClearUserStatusCommandHandler : IRequestHandler<ClearUserStatusCommand, Result<UserProfileDto>>
{
private readonly IProfileStatusWriter _writer;
public ClearUserStatusCommandHandler(IProfileStatusWriter writer)
{
_writer = writer;
}
public Task<Result<UserProfileDto>> Handle(ClearUserStatusCommand request, CancellationToken cancellationToken)
=> _writer.ClearAsync(request.UserId, cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using Knot.Contracts.Profiles.Application.DTOs;
using MediatR;
namespace Knot.Modules.Profiles.Application.Statuses;
public sealed record GetStatusPresetsQuery : IRequest<IReadOnlyList<StatusPresetDto>>;
public sealed class GetStatusPresetsQueryHandler : IRequestHandler<GetStatusPresetsQuery, IReadOnlyList<StatusPresetDto>>
{
public Task<IReadOnlyList<StatusPresetDto>> Handle(GetStatusPresetsQuery request, CancellationToken cancellationToken)
=> Task.FromResult(StatusPresetCatalog.All);
}

View File

@@ -0,0 +1,53 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Profiles.Application.Statuses;
public sealed record SetCustomUserStatusCommand(
Guid UserId,
string? Emoji,
string? Text,
DateTime? ExpiresAt,
string? PresetKey) : IRequest<Result<UserProfileDto>>;
public sealed class SetCustomUserStatusCommandHandler : IRequestHandler<SetCustomUserStatusCommand, Result<UserProfileDto>>
{
private readonly IProfileStatusWriter _writer;
public SetCustomUserStatusCommandHandler(IProfileStatusWriter writer)
{
_writer = writer;
}
public async Task<Result<UserProfileDto>> Handle(SetCustomUserStatusCommand request, CancellationToken cancellationToken)
{
var key = request.PresetKey?.Trim();
if (!string.IsNullOrEmpty(key) && key.Equals("online", StringComparison.OrdinalIgnoreCase))
return await _writer.ClearAsync(request.UserId, cancellationToken);
var emoji = request.Emoji?.Trim() ?? string.Empty;
var text = request.Text?.Trim() ?? string.Empty;
if (!string.IsNullOrEmpty(key))
{
var preset = StatusPresetCatalog.Find(key);
if (preset is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.InvalidPreset);
if (string.IsNullOrEmpty(emoji))
emoji = preset.Emoji;
if (string.IsNullOrEmpty(text))
text = preset.TextRu;
}
if (string.IsNullOrEmpty(emoji) && string.IsNullOrEmpty(text))
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusEmpty);
if (text.Length > 50)
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
var presetForWriter = string.IsNullOrWhiteSpace(key) ? null : key;
return await _writer.SetCustomAsync(request.UserId, emoji, text, request.ExpiresAt, presetForWriter, cancellationToken);
}
}

View File

@@ -0,0 +1,24 @@
using Knot.Contracts.Profiles.Application.DTOs;
namespace Knot.Modules.Profiles.Application.Statuses;
public static class StatusPresetCatalog
{
private static readonly StatusPresetDto[] Items =
[
new() { Id = "online", Emoji = "", TextRu = "Онлайн (стандарт)", TextEn = "Online (standard)" },
new() { Id = "away", Emoji = "🕒", TextRu = "В отъезде", TextEn = "Away" },
new() { Id = "dnd", Emoji = "⛔", TextRu = "Не беспокоить", TextEn = "Do not disturb" },
new() { Id = "sick", Emoji = "🤒", TextRu = "Болен", TextEn = "Sick" },
new() { Id = "angry", Emoji = "💢", TextRu = "Злой", TextEn = "Angry" }
];
public static IReadOnlyList<StatusPresetDto> All => Items;
public static StatusPresetDto? Find(string presetKey)
{
if (string.IsNullOrWhiteSpace(presetKey))
return null;
return Items.FirstOrDefault(x => x.Id.Equals(presetKey.Trim(), StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -11,10 +11,11 @@ public static class DependencyInjection
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>(); services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IUserStatusRepository, UserStatusMongoRepository>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileStatusWriter, ProfileStatusWriter>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfilesUnitOfWork, ProfilesUnitOfWork>(); services.AddScoped<Knot.Contracts.Profiles.Domain.IProfilesUnitOfWork, ProfilesUnitOfWork>();
services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>(); services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>();
// MongoDB Registration
var mongoConnection = configuration.GetConnectionString("MongoConnection") var mongoConnection = configuration.GetConnectionString("MongoConnection")
?? configuration["MONGO_URL"] ?? configuration["MONGO_URL"]
?? "mongodb://mongo:27017"; ?? "mongodb://mongo:27017";

View File

@@ -3,10 +3,6 @@ using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Profiles.Domain; namespace Knot.Modules.Profiles.Domain;
/// <summary>
/// MongoDB-документ профиля пользователя.
/// Id совпадает с UserId из модуля Auth (Postgres).
/// </summary>
public sealed class ProfileDocument public sealed class ProfileDocument
{ {
[BsonId] [BsonId]
@@ -19,12 +15,23 @@ public sealed class ProfileDocument
public string? Bio { get; private set; } public string? Bio { get; private set; }
public string? StatusText { get; private set; }
public string? StatusEmoji { get; private set; }
public DateTime? StatusExpiresAt { get; private set; }
[BsonRepresentation(BsonType.String)]
public Guid? CurrentStatusId { get; private set; }
public string? AvatarUrl { get; private set; } public string? AvatarUrl { get; private set; }
public DateTime? Birthday { get; private set; } public DateTime? Birthday { get; private set; }
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; }
@@ -65,6 +72,26 @@ 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)
{
StatusText = statusText;
StatusEmoji = statusEmoji;
StatusExpiresAt = statusExpiresAt;
}
public void SetCurrentStatusId(Guid? statusId)
=> CurrentStatusId = statusId;
public void ClearMoodStatusFields()
{
StatusText = null;
StatusEmoji = null;
StatusExpiresAt = null;
}
public void UpdateStatus(bool isBanned, bool isDeleted) public void UpdateStatus(bool isBanned, bool isDeleted)
{ {
IsBanned = isBanned; IsBanned = isBanned;

View File

@@ -0,0 +1,26 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Knot.Modules.Profiles.Domain;
public sealed class UserStatusDocument
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }
[BsonRepresentation(BsonType.String)]
public Guid UserId { get; set; }
public string Type { get; set; } = "Custom";
public string Emoji { get; set; } = "";
public string Text { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public BsonDocument Metadata { get; set; } = new();
}

View File

@@ -10,29 +10,39 @@ namespace Knot.Modules.Profiles.Infrastructure.Database;
internal class ProfileRepository : IProfileRepository internal class ProfileRepository : IProfileRepository
{ {
private readonly IMongoCollection<ProfileDocument> _profiles; private readonly IMongoCollection<ProfileDocument> _profiles;
private readonly IUserStatusRepository _statuses;
public ProfileRepository(IMongoDatabase database) public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses)
{ {
_profiles = database.GetCollection<ProfileDocument>("profiles"); _profiles = database.GetCollection<ProfileDocument>("profiles");
_statuses = statuses;
} }
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default) public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
{ {
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct); var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
return profile?.ToDto(); return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
} }
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default) public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
{ {
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct); var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
return profile?.ToDto(); return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
} }
public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default) public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
{ {
var ids = userIds.ToList(); var ids = userIds.ToList();
var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct); var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct);
return profiles.Select(p => p.ToDto()).ToList(); var statusIds = profiles
.Where(p => p.CurrentStatusId.HasValue)
.Select(p => p.CurrentStatusId!.Value)
.Distinct()
.ToList();
var map = statusIds.Count > 0
? await _statuses.GetByIdsAsync(statusIds, ct)
: new Dictionary<Guid, UserStatusDto>();
return profiles.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
} }
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default) public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
@@ -57,7 +67,16 @@ internal class ProfileRepository : IProfileRepository
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter); var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct); docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
} }
return docs.Select(p => p.ToDto()).ToList();
var statusIds = docs
.Where(p => p.CurrentStatusId.HasValue)
.Select(p => p.CurrentStatusId!.Value)
.Distinct()
.ToList();
var map = statusIds.Count > 0
? await _statuses.GetByIdsAsync(statusIds, ct)
: new Dictionary<Guid, UserStatusDto>();
return docs.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
} }
public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default) public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default)
@@ -65,7 +84,7 @@ internal class ProfileRepository : IProfileRepository
var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About); var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About);
await _profiles.InsertOneAsync(profile, null, ct); await _profiles.InsertOneAsync(profile, null, ct);
return Result.Success(profile.ToDto()); return Result.Success(await profile.ToDtoAsync(_statuses, ct));
} }
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default) public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
@@ -80,9 +99,13 @@ internal class ProfileRepository : IProfileRepository
dto.Birthday); dto.Birthday);
document.UpdateAvatar(dto.Avatar); document.UpdateAvatar(dto.Avatar);
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()); var fresh = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct);
if (fresh is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
return Result.Success(await fresh.ToDtoAsync(_statuses, ct));
} }
public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default) public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)

View File

@@ -0,0 +1,73 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Modules.Profiles.Domain;
using Knot.Modules.Profiles.Infrastructure.Mappings;
using Knot.Shared.Kernel;
using MongoDB.Driver;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class ProfileStatusWriter : IProfileStatusWriter
{
private readonly IMongoCollection<ProfileDocument> _profiles;
private readonly IUserStatusRepository _statuses;
public ProfileStatusWriter(IMongoDatabase database, IUserStatusRepository statuses)
{
_profiles = database.GetCollection<ProfileDocument>("profiles");
_statuses = statuses;
}
public async Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default)
{
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if (profile.CurrentStatusId is Guid oldId)
await _statuses.DeleteAsync(oldId, cancellationToken);
var newId = Guid.NewGuid();
var type = string.IsNullOrWhiteSpace(presetKey) ? "Custom" : "Preset";
var dto = new UserStatusDto
{
Id = newId,
Type = type,
Emoji = emoji,
Text = text,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt
};
await _statuses.InsertAsync(dto, userId, cancellationToken);
profile.SetCurrentStatusId(newId);
profile.ClearMoodStatusFields();
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (fresh is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
}
public async Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default)
{
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (profile is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
if (profile.CurrentStatusId is Guid oldId)
await _statuses.DeleteAsync(oldId, cancellationToken);
profile.SetCurrentStatusId(null);
profile.ClearMoodStatusFields();
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
if (fresh is null)
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
}
}

View File

@@ -0,0 +1,62 @@
using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Modules.Profiles.Domain;
using MongoDB.Bson;
using MongoDB.Driver;
namespace Knot.Modules.Profiles.Infrastructure.Database;
internal sealed class UserStatusMongoRepository : IUserStatusRepository
{
private readonly IMongoCollection<UserStatusDocument> _collection;
public UserStatusMongoRepository(IMongoDatabase database)
{
_collection = database.GetCollection<UserStatusDocument>("user_statuses");
}
public async Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
return doc is null ? null : ToDto(doc);
}
public async Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0)
return new Dictionary<Guid, UserStatusDto>();
var docs = await _collection.Find(x => idList.Contains(x.Id)).ToListAsync(cancellationToken);
return docs.ToDictionary(d => d.Id, ToDto);
}
public async Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default)
{
var doc = new UserStatusDocument
{
Id = dto.Id,
UserId = userId,
Type = dto.Type,
Emoji = dto.Emoji,
Text = dto.Text,
CreatedAt = dto.CreatedAt,
ExpiresAt = dto.ExpiresAt,
Metadata = new BsonDocument()
};
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken);
}
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
=> _collection.DeleteOneAsync(x => x.Id == id, cancellationToken);
private static UserStatusDto ToDto(UserStatusDocument d) => new()
{
Id = d.Id,
Type = d.Type,
Emoji = d.Emoji,
Text = d.Text,
CreatedAt = d.CreatedAt,
ExpiresAt = d.ExpiresAt
};
}

View File

@@ -1,13 +1,14 @@
using Knot.Contracts.Profiles.Application.DTOs; using Knot.Contracts.Profiles.Application.DTOs;
using Knot.Contracts.Profiles.Domain;
using Knot.Modules.Profiles.Domain; using Knot.Modules.Profiles.Domain;
namespace Knot.Modules.Profiles.Infrastructure.Mappings; namespace Knot.Modules.Profiles.Infrastructure.Mappings;
public static class ProfileMappings public static class ProfileMappings
{ {
public static UserProfileDto ToDto(this ProfileDocument document) public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap)
{ {
return new UserProfileDto var dto = new UserProfileDto
{ {
UserId = document.Id, UserId = document.Id,
DisplayName = document.DisplayName, DisplayName = document.DisplayName,
@@ -18,7 +19,59 @@ public static class ProfileMappings
LastSeen = null, LastSeen = null,
Birthday = document.Birthday, Birthday = document.Birthday,
IsPremium = false, IsPremium = false,
CreatedAt = document.CreatedAt IsInvisible = document.IsInvisible,
CreatedAt = document.CreatedAt,
Status = null,
StatusText = null,
StatusEmoji = null,
StatusExpiresAt = null,
CurrentStatusId = null
}; };
UserStatusDto? active = null;
if (document.CurrentStatusId is Guid sid && statusMap.TryGetValue(sid, out var loaded) && loaded is not null)
{
var exp = loaded.ExpiresAt;
if (!exp.HasValue || exp.Value > DateTime.UtcNow)
active = loaded;
}
if (active is not null)
{
dto.Status = active;
dto.StatusText = active.Text;
dto.StatusEmoji = active.Emoji;
dto.StatusExpiresAt = active.ExpiresAt;
dto.CurrentStatusId = active.Id;
return dto;
}
var legacyExpired = document.StatusExpiresAt.HasValue && document.StatusExpiresAt.Value <= DateTime.UtcNow;
if (!legacyExpired && (!string.IsNullOrEmpty(document.StatusText) || !string.IsNullOrEmpty(document.StatusEmoji)))
{
dto.StatusText = document.StatusText;
dto.StatusEmoji = document.StatusEmoji;
dto.StatusExpiresAt = document.StatusExpiresAt;
dto.Status = new UserStatusDto
{
Id = Guid.Empty,
Type = "Custom",
Emoji = document.StatusEmoji ?? string.Empty,
Text = document.StatusText ?? string.Empty,
CreatedAt = document.CreatedAt,
ExpiresAt = document.StatusExpiresAt
};
return dto;
}
return dto;
}
public static async Task<UserProfileDto> ToDtoAsync(this ProfileDocument document, IUserStatusRepository statuses, CancellationToken cancellationToken)
{
if (document.CurrentStatusId is not Guid sid)
return ToDtoWithStatusMap(document, new Dictionary<Guid, UserStatusDto>());
var map = await statuses.GetByIdsAsync(new[] { sid }, cancellationToken);
return ToDtoWithStatusMap(document, map);
} }
} }

View File

@@ -17,6 +17,8 @@ public static class ProfilesEndpoints
public static void MapProfilesEndpoints(this WebApplication app) public static void MapProfilesEndpoints(this WebApplication app)
{ {
var group = app.MapGroup("api/profiles").RequireAuthorization(); var group = app.MapGroup("api/profiles").RequireAuthorization();
var userGroup = app.MapGroup("api/user").RequireAuthorization();
var usersGroup = app.MapGroup("api/users").RequireAuthorization();
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) => group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
{ {
@@ -67,16 +69,37 @@ public static class ProfilesEndpoints
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
}); });
group.MapPut("profile", async ([FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, ISender sender, IUserContext userContext, CancellationToken ct) => async Task<IResult> UpdateProfileHandler(
[FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request,
ISender sender,
IUserContext userContext,
CancellationToken ct)
{ {
var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct); var result = await sender.Send(
new UpdateProfileCommand(
userContext.UserId,
request.DisplayName,
request.Bio,
request.Birthday,
request.IsInvisible),
ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
}); }
group.MapPut("profile", UpdateProfileHandler);
group.MapPatch("profile", UpdateProfileHandler);
userGroup.MapPatch("profile", UpdateProfileHandler);
group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) => group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
{ {
var result = await sender.Send(new GetProfileQuery(id), ct); var result = await sender.Send(new GetProfileQuery(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
}); });
usersGroup.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetProfileQuery(id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
});
} }
} }

View File

@@ -0,0 +1,45 @@
using Knot.Modules.Profiles.Application.Statuses;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Profiles.Presentation.Endpoints;
public static class StatusesEndpoints
{
public static void MapStatusesEndpoints(this WebApplication app)
{
var g = app.MapGroup("api/statuses").RequireAuthorization();
g.MapGet("presets", async (ISender sender, CancellationToken ct) =>
{
var list = await sender.Send(new GetStatusPresetsQuery(), ct);
return Results.Ok(list);
});
g.MapPost("custom", async ([FromBody] SetCustomStatusBody body, ISender sender, IUserContext ctx, CancellationToken ct) =>
{
var result = await sender.Send(
new SetCustomUserStatusCommand(ctx.UserId, body.Emoji, body.Text, body.ExpiresAt, body.PresetKey),
ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
g.MapDelete("/", async (ISender sender, IUserContext ctx, CancellationToken ct) =>
{
var result = await sender.Send(new ClearUserStatusCommand(ctx.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
}
public sealed class SetCustomStatusBody
{
public string? Emoji { get; set; }
public string? Text { get; set; }
public DateTime? ExpiresAt { get; set; }
public string? PresetKey { get; set; }
}
}

View File

@@ -6,7 +6,7 @@ export interface UserBasic {
displayName: string; displayName: string;
avatarUrl: string | null; avatarUrl: string | null;
// Fallbacks for compatibility // Fallbacks for compatibility
username?: string; username?: string;
avatar?: string | null; avatar?: string | null;
} }
@@ -15,13 +15,34 @@ export interface UserPresence extends UserBasic {
lastSeen: string; lastSeen: string;
} }
export interface UserStatus {
id: string;
type: string;
emoji?: string | null;
text?: string | null;
createdAt?: string;
expiresAt?: string | null;
}
export interface User extends UserPresence { export interface User extends UserPresence {
bio: string | null; bio: string | null;
birthday: string | null; birthday: string | null;
status?: UserStatus | null;
statusText?: string | null;
statusEmoji?: string | null;
statusExpiresAt?: string | null;
isInvisible?: boolean;
createdAt: string; createdAt: string;
hideStoryViews?: boolean; hideStoryViews?: boolean;
} }
export interface StatusPreset {
id: string;
emoji: string;
textRu: string;
textEn: string;
}
// ─── Chat types ──────────────────────────────────────────────────────── // ─── Chat types ────────────────────────────────────────────────────────
export interface ChatMember { export interface ChatMember {
@@ -51,11 +72,11 @@ export interface Reaction {
id: string; id: string;
emoji: string; emoji: string;
userId: string; userId: string;
user: { user: {
id: string; id: string;
username: string; username: string;
userName?: string; userName?: string;
displayName: string; displayName: string;
avatar?: string | null; avatar?: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
}; };
@@ -84,7 +105,6 @@ export interface Message {
storyMediaType?: string | null; storyMediaType?: string | null;
isEdited: boolean; isEdited: boolean;
isDeleted: boolean; isDeleted: boolean;
isDeletedForUser?: boolean;
scheduledAt?: string | null; scheduledAt?: string | null;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
@@ -96,9 +116,9 @@ export interface Message {
isDeleted?: boolean; isDeleted?: boolean;
quote?: string | null; quote?: string | null;
media?: MediaItem[]; media?: MediaItem[];
sender: { sender: {
id: string; id: string;
username: string; username: string;
userName?: string; userName?: string;
displayName: string; displayName: string;
avatar?: string | null; avatar?: string | null;

View File

@@ -3,74 +3,11 @@ const API_BASE = '/api';
export class HttpClient { export class HttpClient {
private token: string | null = null; private token: string | null = null;
private isRefreshing = false;
private refreshSubscribers: Array<(token: string) => void> = [];
setToken(token: string | null) { setToken(token: string | null) {
this.token = token; this.token = token;
} }
private subscribeTokenRefresh(cb: (token: string) => void) {
this.refreshSubscribers.push(cb);
}
private onRefreshed(token: string) {
this.refreshSubscribers.forEach(cb => cb(token));
this.refreshSubscribers = [];
}
private async handle401(): Promise<string | null> {
if (this.isRefreshing) {
return new Promise(resolve => {
this.subscribeTokenRefresh(token => {
resolve(token);
});
});
}
const refreshToken = localStorage.getItem('knot_refresh_token');
if (!refreshToken) {
return null;
}
this.isRefreshing = true;
try {
const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
localStorage.removeItem('knot_token');
localStorage.removeItem('knot_refresh_token');
this.token = null;
return null;
}
const data = await response.json();
const newToken = data.accessToken;
const newRefreshToken = data.refreshToken;
localStorage.setItem('knot_token', newToken);
if (newRefreshToken) {
localStorage.setItem('knot_refresh_token', newRefreshToken);
}
this.token = newToken;
this.onRefreshed(newToken);
return newToken;
} catch (err) {
localStorage.removeItem('knot_token');
localStorage.removeItem('knot_refresh_token');
this.token = null;
return null;
} finally {
this.isRefreshing = false;
}
}
async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> { async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
const { timeout = 30_000, ...fetchOptions } = options; const { timeout = 30_000, ...fetchOptions } = options;
const controller = new AbortController(); const controller = new AbortController();
@@ -104,36 +41,6 @@ export class HttpClient {
} }
clearTimeout(timer); clearTimeout(timer);
// Handle 401 Unauthorized
if (response.status === 401 && endpoint !== '/auth/refresh') {
const newToken = await this.handle401();
if (newToken) {
// Retry the original request with new token
const retryHeaders: Record<string, string> = {
...computedHeaders,
Authorization: `Bearer ${newToken}`,
};
const retryController = new AbortController();
const retryTimer = timeout > 0 ? setTimeout(() => retryController.abort(), timeout) : undefined;
try {
response = await fetch(`${API_BASE}${endpoint}`, {
...fetchOptions,
headers: retryHeaders,
signal: retryController.signal,
});
} catch (err) {
clearTimeout(retryTimer);
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Время ожидания запроса истекло');
}
throw err;
}
clearTimeout(retryTimer);
}
}
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`; const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`;

View File

@@ -194,8 +194,7 @@ const translations = {
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.', clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
clearHistory: 'Очистить историю', clearHistory: 'Очистить историю',
clearHistoryConfirm: 'Очистить историю?', clearHistoryConfirm: 'Очистить историю?',
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.', deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
deleteGroupChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
pinChat: 'Закрепить чат', pinChat: 'Закрепить чат',
unpinChat: 'Открепить чат', unpinChat: 'Открепить чат',
chatCleared: 'Очищено', chatCleared: 'Очищено',
@@ -287,7 +286,7 @@ const translations = {
mediaTab: 'Медиа', mediaTab: 'Медиа',
gifs: 'GIF', gifs: 'GIF',
filesTab: 'Файлы', filesTab: 'Файлы',
linksTab: 'Ссылки', linksTab: 'Ссылкии',
profileTitle: 'Профиль', profileTitle: 'Профиль',
removeAvatar: 'Удалить аватар', removeAvatar: 'Удалить аватар',
namePlaceholder: 'Имя', namePlaceholder: 'Имя',
@@ -575,8 +574,7 @@ const translations = {
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.', clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
clearHistory: 'Clear history', clearHistory: 'Clear history',
clearHistoryConfirm: 'Clear history?', clearHistoryConfirm: 'Clear history?',
deleteChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.', deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
pinChat: 'Pin chat', pinChat: 'Pin chat',
unpinChat: 'Unpin chat', unpinChat: 'Unpin chat',
chatCleared: 'Chat cleared', chatCleared: 'Chat cleared',

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,6 +25,45 @@ export function normalizeUser(user: any): any {
result.avatar = result.avatarUrl; result.avatar = result.avatarUrl;
} }
if (!result.id && result.userId != null) {
result.id = String(result.userId);
}
if (!result.id && result.UserId != null) {
result.id = String(result.UserId);
}
if (!result.bio && result.about) {
result.bio = result.about;
}
if (!result.about && result.bio) {
result.about = result.bio;
}
if (!result.statusText && result.status_text) {
result.statusText = result.status_text;
}
if (!result.statusEmoji && result.status_emoji) {
result.statusEmoji = result.status_emoji;
}
if (!result.statusExpiresAt && result.status_expires_at) {
result.statusExpiresAt = result.status_expires_at;
}
if (!result.status && result.Status) {
result.status = result.Status;
}
if (result.status && typeof result.status === 'object') {
const s = result.status as Record<string, unknown>;
if (s.expiresAt == null && s.expires_at != null) s.expiresAt = s.expires_at;
if (s.createdAt == null && s.created_at != null) s.createdAt = s.created_at;
}
// 5. Privacy flag
if (typeof result.isInvisible === 'undefined' && typeof result.is_invisible !== 'undefined') {
result.isInvisible = result.is_invisible;
}
return result; return result;
} }
@@ -49,7 +88,13 @@ export function deepNormalize(obj: any): any {
const result = { ...obj }; const result = { ...obj };
// Если это объект, похожий на пользователя (есть id и userName/username) // Если это объект, похожий на пользователя (есть id и userName/username)
if (result.id && (result.userName || result.username)) { if ((result.id || result.userId != null || result.UserId != null) && (result.userName || result.username)) {
if (!result.id && result.userId != null) {
result.id = String(result.userId);
}
if (!result.id && result.UserId != null) {
result.id = String(result.UserId);
}
const normalized = normalizeUser(result); const normalized = normalizeUser(result);
// Продолжаем рекурсию по остальным полям (например, если у пользователя есть вложенные объекты) // Продолжаем рекурсию по остальным полям (например, если у пользователя есть вложенные объекты)
for (const key in normalized) { for (const key in normalized) {

View File

@@ -5,7 +5,6 @@ import type { User } from '../../../core/domain/types';
interface AuthState { interface AuthState {
token: string | null; token: string | null;
refreshToken: string | null;
user: User | null; user: User | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -24,9 +23,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (t) AuthApi.setToken(t); if (t) AuthApi.setToken(t);
return t; return t;
})(), })(),
refreshToken: (() => {
return localStorage.getItem('knot_refresh_token');
})(),
user: null, user: null,
isLoading: true, isLoading: true,
error: null, error: null,
@@ -37,20 +33,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try { try {
const res = await AuthApi.getConfig(); const res = await AuthApi.getConfig();
set({ config: res }); set({ config: res });
} catch { } } catch {}
}, },
login: async (username, password) => { login: async (username, password) => {
try { try {
set({ error: null, isLoading: true }); set({ error: null, isLoading: true });
const { token, refreshToken, user } = await AuthApi.login(username, password); const { token, user } = await AuthApi.login(username, password);
localStorage.setItem('knot_token', token); localStorage.setItem('knot_token', token);
if (refreshToken) {
localStorage.setItem('knot_refresh_token', refreshToken);
}
AuthApi.setToken(token); AuthApi.setToken(token);
connectSocket(token); connectSocket(token);
set({ token, refreshToken, user, isLoading: false }); set({ token, user, isLoading: false });
await get().fetchConfig(); await get().fetchConfig();
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -62,14 +55,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
register: async (username, displayName, password, bio) => { register: async (username, displayName, password, bio) => {
try { try {
set({ error: null, isLoading: true }); set({ error: null, isLoading: true });
const { token, refreshToken, user } = await AuthApi.register(username, displayName, password, bio); const { token, user } = await AuthApi.register(username, displayName, password, bio);
localStorage.setItem('knot_token', token); localStorage.setItem('knot_token', token);
if (refreshToken) {
localStorage.setItem('knot_refresh_token', refreshToken);
}
AuthApi.setToken(token); AuthApi.setToken(token);
connectSocket(token); connectSocket(token);
set({ token, refreshToken, user, isLoading: false }); set({ token, user, isLoading: false });
await get().fetchConfig(); await get().fetchConfig();
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -80,10 +70,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logout: () => { logout: () => {
localStorage.removeItem('knot_token'); localStorage.removeItem('knot_token');
localStorage.removeItem('knot_refresh_token');
AuthApi.setToken(null); AuthApi.setToken(null);
disconnectSocket(); disconnectSocket();
set({ token: null, refreshToken: null, user: null }); set({ token: null, user: null });
}, },
checkAuth: async () => { checkAuth: async () => {
@@ -132,12 +121,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
errorMsg.includes('Недействительный токен') || errorMsg.includes('Недействительный токен') ||
errorMsg.includes('Требуется авторизация') errorMsg.includes('Требуется авторизация')
) { ) {
localStorage.removeItem('knot_token'); localStorage.removeItem('knot_token');
localStorage.removeItem('knot_refresh_token'); set({ token: null, user: null, isLoading: false });
set({ token: null, refreshToken: null, user: null, isLoading: false });
} else { } else {
// Keep the token but stop loading if we're just offline/network error/500 // Keep the token but stop loading if we're just offline/network error/500
set({ isLoading: false }); set({ isLoading: false });
} }
}, },

View File

@@ -3,69 +3,58 @@ import type { User } from '../../../core/domain/types';
export class AuthApi { export class AuthApi {
static async login(username: string, password: string) { static async login(username: string, password: string) {
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/login', { const response = await httpClient.request<any>('/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
return { return {
token: response.accessToken, token: response.accessToken,
refreshToken: response.refreshToken,
user: { user: {
id: response.userId, ...response,
username: response.username, id: response.userId || (response as any).id,
username: response.username || (response as any).userName,
displayName: response.displayName, displayName: response.displayName,
avatar: null avatar: response.avatar || null
} as User } as unknown as User
}; };
} }
static async register(username: string, displayName: string, password: string, bio?: string) { static async register(username: string, displayName: string, password: string, bio?: string) {
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/register', { const response = await httpClient.request<any>('/auth/register', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }), body: JSON.stringify({ username, displayName, password, bio }),
}); });
return { return {
token: response.accessToken, token: response.accessToken,
refreshToken: response.refreshToken,
user: { user: {
id: response.userId, ...response,
username: response.username, id: response.userId || (response as any).id,
username: response.username || (response as any).userName,
displayName: response.displayName, displayName: response.displayName,
avatar: null avatar: response.avatar || null
} as User } as unknown as User
};
}
static async refresh(refreshToken: string) {
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
return {
token: response.accessToken,
refreshToken: response.refreshToken,
user: {
id: response.userId,
username: response.username,
displayName: response.displayName,
avatar: null
} as User
}; };
} }
static async getMe() { static async getMe() {
const response = await httpClient.request<{ userId: string; username: string; displayName: string; avatar: string | null; accessToken?: string }>('/auth/me'); const authRes = await httpClient.request<any>('/auth/me');
const userId = authRes.userId || authRes.id;
// Пытаемся получить расширенный профиль (био, дата рождения)
const profileRes = await httpClient.request<any>(`/profiles/${userId}`).catch(() => ({}));
return { return {
user: { user: {
id: response.userId, ...authRes,
username: response.username, ...profileRes,
displayName: response.displayName, id: userId,
avatar: response.avatar username: authRes.username || authRes.userName || profileRes.userName,
} as User, displayName: authRes.displayName || profileRes.displayName,
token: response.accessToken avatar: authRes.avatar || authRes.avatarUrl || profileRes.avatarUrl
} as unknown as User,
token: authRes.accessToken
}; };
} }
@@ -73,6 +62,13 @@ export class AuthApi {
return httpClient.request<any>('/config'); return httpClient.request<any>('/config');
} }
static async changePassword(data: { oldPassword: string; newPassword: string; confirmPassword: string }) {
return httpClient.request<void>('/auth/change-password', {
method: 'POST',
body: JSON.stringify(data),
});
}
static setToken(token: string | null) { static setToken(token: string | null) {
httpClient.setToken(token); httpClient.setToken(token);
} }

View File

@@ -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;
@@ -124,10 +125,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ isLoadingMessages: true }); set({ isLoadingMessages: true });
const currentMessages = state.messages[chatId] || []; const currentMessages = state.messages[chatId] || [];
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined; const firstSeq = currentMessages[0]?.sequenceId;
const cursor =
!reset && currentMessages.length > 0 && firstSeq != null
? String(firstSeq)
: undefined;
const fetched = await ChatApi.getMessages(chatId, cursor); const fetched = await ChatApi.getMessages(chatId, cursor);
set((state) => { set((state) => {
// Merge fetched messages with any that arrived via socket // Merge fetched messages with any that arrived via socket
const existing = reset ? [] : (state.messages[chatId] || []); const existing = reset ? [] : (state.messages[chatId] || []);
@@ -401,7 +406,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (m.sequenceId <= lastReadSequenceId) { if (m.sequenceId <= lastReadSequenceId) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId); const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m; if (alreadyRead) return m;
// Увеличиваем счётчик только если текущий пользователь читает чужие сообщения
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++; if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] }; return { ...m, readBy: [...(m.readBy || []), { userId }] };
} }
@@ -413,7 +417,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
const updatedChats = state.chats.map((chat) => { const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) { if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg); const updatedLastMessages = chat.messages?.map(updateMsg);
// Уменьшаем unreadCount только если текущий пользователь прочитал сообщения
if (userId === currentUserId) { if (userId === currentUserId) {
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) }; return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
} }
@@ -459,6 +462,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,
@@ -471,22 +477,38 @@ 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 }),
addChat: (chat) => { addChat: (chat) => {
set((state) => { set((state) => {
const existing = state.chats.find((c) => c.id === chat.id); const existing = state.chats.find((c) => c.id === chat.id);
const messagesFromState = state.messages[chat.id] || []; const messagesFromState = state.messages[chat.id] || [];
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []); const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
let unreadCount = chat.unreadCount || 0; let unreadCount = chat.unreadCount || 0;
if (!existing && messagesFromState.length > 0) { if (!existing && messagesFromState.length > 0) {
const userId = useAuthStore.getState().user?.id; const userId = useAuthStore.getState().user?.id;
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length; unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
} }
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount }; const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
if (existing) { if (existing) {
@@ -526,9 +548,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
const existing = state.pinnedMessages[chatId] || []; const existing = state.pinnedMessages[chatId] || [];
if (existing.some(m => m.id === message.id)) return state; if (existing.some(m => m.id === message.id)) return state;
return { return {
pinnedMessages: { pinnedMessages: {
...state.pinnedMessages, ...state.pinnedMessages,
[chatId]: [...existing, message] [chatId]: [...existing, message]
}, },
}; };
}); });
@@ -552,7 +574,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
try { try {
set({ isLoadingMessages: true }); set({ isLoadingMessages: true });
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50); const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
set((state) => ({ set((state) => ({
messages: { ...state.messages, [chatId]: fetched }, messages: { ...state.messages, [chatId]: fetched },
// Since we jumped, we assume there is more history to load above // Since we jumped, we assume there is more history to load above

View File

@@ -33,6 +33,7 @@ export default function ChatPage() {
addTypingUser, addTypingUser,
removeTypingUser, removeTypingUser,
updateUserOnlineStatus, updateUserOnlineStatus,
applyInvisiblePrivacyMode,
setPinnedMessage, setPinnedMessage,
removePinnedMessage, removePinnedMessage,
clearStore, clearStore,
@@ -58,15 +59,13 @@ export default function ChatPage() {
const [groupCallSessionId, setGroupCallSessionId] = useState(0); const [groupCallSessionId, setGroupCallSessionId] = useState(0);
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null); const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
const groupCallOpenRef = useRef(false); const groupCallOpenRef = useRef(false);
const groupCallChatIdRef = useRef(''); const groupCallChatIdRef = useRef('');
const [activeTab, setActiveTab] = useState('chats'); const [activeTab, setActiveTab] = useState('chats');
const { t } = useLang(); const { t } = useLang();
const activeChat = useChatStore((state) => state.activeChat);
useEffect(() => { useEffect(() => {
groupCallOpenRef.current = groupCallOpen; groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId; groupCallChatIdRef.current = groupCallChatId;
@@ -78,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 = () => {
@@ -182,12 +187,7 @@ export default function ChatPage() {
}); });
socket.on('messages_read', (data: any) => { socket.on('messages_read', (data: any) => {
const chatId = data.chatId || data.ChatId; markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
const userId = data.userId || data.UserId;
const lastReadSequenceId = data.lastReadSequenceId || data.LastReadSequenceId || 0;
// Обновляем стейт - добавляем userId в readBy для всех сообщений до lastReadSequenceId
markRead(chatId, userId, lastReadSequenceId);
}); });
socket.on('user_typing', (data: { chatId: string; userId: string }) => { socket.on('user_typing', (data: { chatId: string; userId: string }) => {
@@ -202,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);
}); });
@@ -288,7 +290,7 @@ export default function ChatPage() {
callType: data.callType, callType: data.callType,
chatName: chat.name || 'Group', chatName: chat.name || 'Group',
}); });
// Auto-dismiss after 15 seconds if ignored // Auto-dismiss after 15 seconds if ignored
setTimeout(() => { setTimeout(() => {
setIncomingGroupCall(prev => { setIncomingGroupCall(prev => {
@@ -337,16 +339,6 @@ export default function ChatPage() {
}; };
}, [user?.id]); }, [user?.id]);
// Join chat group when activeChat changes
useEffect(() => {
if (activeChat) {
const socket = getSocket();
if (socket) {
socket.emit('join_chat', activeChat);
}
}
}, [activeChat]);
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => { const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
setCallTarget(targetUser); setCallTarget(targetUser);
setCallType(type); setCallType(type);
@@ -383,6 +375,8 @@ export default function ChatPage() {
setGroupCallOpen(false); setGroupCallOpen(false);
}; };
const activeChat = useChatStore((state) => state.activeChat);
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -398,14 +392,14 @@ export default function ChatPage() {
{activeTab === 'chats' ? ( {activeTab === 'chats' ? (
<> <>
{/* Chat List (Sidebar) */} {/* Chat List (Sidebar) */}
<div <div
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`} className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
> >
<Sidebar /> <Sidebar />
</div> </div>
{/* Selected Chat View (Main Area) */} {/* Selected Chat View (Main Area) */}
<div <div
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`} className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`}
> >
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} /> <ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
@@ -413,31 +407,31 @@ export default function ChatPage() {
</> </>
) : activeTab === 'contacts' ? ( ) : activeTab === 'contacts' ? (
<div className="flex-1 flex flex-row h-full overflow-hidden"> <div className="flex-1 flex flex-row h-full overflow-hidden">
{/* Contacts Sidebar List */} {/* Contacts Sidebar List */}
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased"> <div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} /> <ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
</div> </div>
{/* Right side placeholder / Profile detail */} {/* Right side placeholder / Profile detail */}
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice"> <div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice">
<div className="flex flex-col items-center gap-6 max-w-sm text-center"> <div className="flex flex-col items-center gap-6 max-w-sm text-center">
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner"> <div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
<Users size={48} className="knot-logo-spin opacity-50" /> <Users size={48} className="knot-logo-spin opacity-50" />
</div>
<div>
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
{t('selectContactToChat')}
</p>
</div>
<button
onClick={() => setActiveTab('chats')}
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
>
{t('backToChats')}
</button>
</div> </div>
<div> </div>
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
{t('selectContactToChat')}
</p>
</div>
<button
onClick={() => setActiveTab('chats')}
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
>
{t('backToChats')}
</button>
</div>
</div>
</div> </div>
) : activeTab === 'settings' ? ( ) : activeTab === 'settings' ? (
<SettingsPage /> <SettingsPage />
@@ -447,7 +441,7 @@ export default function ChatPage() {
</div> </div>
)} )}
</main> </main>
<CallModal <CallModal
key={callSessionId} key={callSessionId}
@@ -500,9 +494,9 @@ export default function ChatPage() {
> >
<div className="relative mb-6"> <div className="relative mb-6">
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" /> <div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
<Avatar <Avatar
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null} src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
name={incomingGroupCall.chatName || '?'} name={incomingGroupCall.chatName || '?'}
size="2xl" size="2xl"
className="relative shadow-2xl" className="relative shadow-2xl"
/> />
@@ -514,9 +508,9 @@ export default function ChatPage() {
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'} {incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
</p> </p>
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5"> <p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
{t('groupCall' as any) || 'Групповой звонок'} {t('groupCall' as any) || 'Групповой звонок'}
</p> </p>
<div className="flex items-center gap-8 w-full justify-center"> <div className="flex items-center gap-8 w-full justify-center">
<button <button
onClick={() => { onClick={() => {

View File

@@ -11,6 +11,15 @@ import { ChatApi } from '../../infrastructure/chatApi';
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal'; import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
import Avatar from '../../../../core/presentation/components/ui/Avatar'; import Avatar from '../../../../core/presentation/components/ui/Avatar';
import type { Chat } from '../../../../core/domain/types'; import type { Chat } from '../../../../core/domain/types';
import { UserApi } from '../../../users/infrastructure/userApi';
const userStatusCache = new Map<string, { emoji?: string | null; text?: string | null; isInvisible?: boolean }>();
function profileStatusEmojiText(profile: { status?: { emoji?: string | null; text?: string | null } | null; statusEmoji?: string | null; statusText?: string | null }) {
const emoji = profile?.status?.emoji ?? profile?.statusEmoji ?? null;
const text = profile?.status?.text ?? profile?.statusText ?? null;
return { emoji, text };
}
interface ChatListItemProps { interface ChatListItemProps {
chat: Chat; chat: Chat;
@@ -24,6 +33,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [otherUserStatus, setOtherUserStatus] = useState<{ emoji?: string | null; text?: string | null; isInvisible?: boolean } | null>(null);
const ctxRef = useRef<HTMLDivElement>(null); const ctxRef = useRef<HTMLDivElement>(null);
const myMember = chat.members.find((m) => m.user.id === user?.id); const myMember = chat.members.find((m) => m.user.id === user?.id);
@@ -45,7 +55,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 && !otherUserStatus?.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);
@@ -63,14 +73,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
: lastMessage.media?.[0]?.type === 'video' : lastMessage.media?.[0]?.type === 'video'
? t('video') ? t('video')
: t('file') : t('file')
: lastMessage.type === 'call' : lastMessage.type === 'call'
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t( ? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
lastMessage.callStatus === 'missed' ? 'missedCall' : lastMessage.callStatus === 'missed' ? 'missedCall' :
lastMessage.callStatus === 'declined' ? 'declinedCall' : lastMessage.callStatus === 'declined' ? 'declinedCall' :
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' : lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
'completedCall' 'completedCall'
)}` )}`
: lastMessage.content || '' : lastMessage.content || ''
: ''; : '';
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText); const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
@@ -78,9 +88,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id; const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
// Галочки прочтения // Галочки прочтения
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели) const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
// Для чужих сообщений: не показываем галочки
const isRead = !chat.isImporting && isMine && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const timeStr = !chat.isImporting && lastMessage const timeStr = !chat.isImporting && lastMessage
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS }) ? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
@@ -90,26 +98,59 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const [importStatus, setImportStatus] = useState<{ processed: number, total: number } | null>(null); const [importStatus, setImportStatus] = useState<{ processed: number, total: number } | null>(null);
useEffect(() => { useEffect(() => {
if (!chat.isImporting || !chat.importJobId) { if (chat.type !== 'personal' || !otherMember?.user?.id) {
setImportStatus(null); setOtherUserStatus(null);
return; return;
} }
const otherId = otherMember.user.id;
const cached = userStatusCache.get(otherId);
if (cached) {
setOtherUserStatus(cached);
return;
}
let cancelled = false;
UserApi.getUser(otherId)
.then((profile) => {
if (cancelled) return;
const st = profileStatusEmojiText(profile);
const status = { emoji: st.emoji, text: st.text, isInvisible: !!profile.isInvisible };
userStatusCache.set(otherId, status);
setOtherUserStatus(status);
})
.catch(() => {
if (!cancelled) {
setOtherUserStatus(null);
}
});
return () => {
cancelled = true;
};
}, [chat.type, otherMember?.user?.id]);
useEffect(() => {
if (!chat.isImporting || !chat.importJobId) {
setImportStatus(null);
return;
}
const poll = async () => { const poll = async () => {
try { try {
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`); const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
setImportStatus({ processed: data.processedMessages, total: data.totalMessages }); setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
if (data.status === 'Completed' || data.status === 'Failed') { if (data.status === 'Completed' || data.status === 'Failed') {
loadChats(); loadChats();
}
} catch (e: any) {
if (e.status === 404) {
// Job might have expired or backend restarted
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
} }
} catch (e: any) {
if (e.status === 404) {
// Job might have expired or backend restarted
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
}
}; };
poll(); poll();
@@ -119,9 +160,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const handleClick = () => { const handleClick = () => {
if (chat.isImporting) { if (chat.isImporting) {
// Here we could show a progress modal, but for now just select it // Here we could show a progress modal, but for now just select it
setActiveChat(chat.id); setActiveChat(chat.id);
return; return;
} }
if ((window as any).hasUnsavedAttachments && !isActive) { if ((window as any).hasUnsavedAttachments && !isActive) {
setShowAttachmentConfirm(true); setShowAttachmentConfirm(true);
@@ -133,7 +174,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const proceedWithClick = () => { const proceedWithClick = () => {
setShowAttachmentConfirm(false); setShowAttachmentConfirm(false);
(window as any).hasUnsavedAttachments = false; (window as any).hasUnsavedAttachments = false;
if (isActive) { if (isActive) {
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } })); window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
} else { } else {
@@ -190,8 +231,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<button <button
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20' className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${
}`} isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
}`}
> >
{/* Аватар */} {/* Аватар */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
@@ -200,8 +242,16 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span> <span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
</div> </div>
) : ( ) : (
<div className="avatar-knot-container group-hover:scale-105 transition-transform"> <div className="avatar-knot-container group-hover:scale-105 transition-transform relative">
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} /> <Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
{chat.type === 'personal' && otherUserStatus?.emoji ? (
<span
className="absolute -bottom-0.5 -right-0.5 min-w-[1.25rem] h-5 px-0.5 flex items-center justify-center rounded-lg bg-surface-container text-[13px] leading-none shadow-md ring-2 ring-surface-container-low"
title={otherUserStatus.text || undefined}
>
{otherUserStatus.emoji}
</span>
) : null}
</div> </div>
)} )}
</div> </div>
@@ -226,20 +276,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
)} )}
<div className="flex flex-col gap-1 w-full min-w-0"> <div className="flex flex-col gap-1 w-full min-w-0">
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}> <p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText} {isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
{chat.isImporting && importStatus && importStatus.total > 0 && ( {chat.isImporting && importStatus && importStatus.total > 0 && (
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums"> <span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
{importStatus.processed} / {importStatus.total} {importStatus.processed} / {importStatus.total}
</span> </span>
)} )}
</p> </p>
{chat.isImporting && importStatus && importStatus.total > 0 && ( {chat.isImporting && importStatus && importStatus.total > 0 && (
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5"> <div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
<div <div
className="h-full bg-primary transition-all duration-500 ease-out" className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }} style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
/> />
</div> </div>
)} )}
</div> </div>
</div> </div>

View File

@@ -24,6 +24,7 @@ import { useChatStore } from '../../application/chatStore';
import { httpClient } from '../../../../core/infrastructure/httpClient'; import { httpClient } from '../../../../core/infrastructure/httpClient';
import { useAuthStore } from '../../../auth/application/authStore'; import { useAuthStore } from '../../../auth/application/authStore';
import { ChatApi } from '../../infrastructure/chatApi'; import { ChatApi } from '../../infrastructure/chatApi';
import { UserApi } from '../../../users/infrastructure/userApi';
import { getSocket } from '../../../../core/infrastructure/socket'; import { getSocket } from '../../../../core/infrastructure/socket';
import { isChatMuted, toggleMuteChat } from '../../../../core/utils/sounds'; import { isChatMuted, toggleMuteChat } from '../../../../core/utils/sounds';
import { useLang } from '../../../../core/infrastructure/i18n'; import { useLang } from '../../../../core/infrastructure/i18n';
@@ -75,6 +76,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const [confirmAction, setConfirmAction] = useState<{ message: string; action: () => void } | null>(null); const [confirmAction, setConfirmAction] = useState<{ message: string; action: () => void } | null>(null);
const [scrollReady, setScrollReady] = useState(false); const [scrollReady, setScrollReady] = useState(false);
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]); const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
const [otherUserInvisible, setOtherUserInvisible] = useState(false);
const [otherUserStatusHeader, setOtherUserStatusHeader] = useState<{ emoji?: string | null; text?: string | null } | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -98,13 +101,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...'); NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
const chatStore = useChatStore.getState(); const chatStore = useChatStore.getState();
if (sequenceId !== undefined) { if (sequenceId !== undefined) {
await chatStore.jumpToMessage(activeChat, sequenceId); await chatStore.jumpToMessage(activeChat, sequenceId);
// Wait a bit for React to render // Wait a bit for React to render
setTimeout(() => { setTimeout(() => {
if (!tryScroll()) { if (!tryScroll()) {
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found'); NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
} }
}, 300); }, 300);
} else { } else {
@@ -137,25 +140,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
useEffect(() => { useEffect(() => {
if (!chat?.isImporting || !chat?.importJobId) { if (!chat?.isImporting || !chat?.importJobId) {
setImportStatus(null); setImportStatus(null);
return; return;
} }
const poll = async () => { const poll = async () => {
try { try {
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`); const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status }); setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status });
if (data.status === 'Completed' || data.status === 'Failed') { if (data.status === 'Completed' || data.status === 'Failed') {
setImportStatus(null); setImportStatus(null);
loadChats(); loadChats();
}
} catch (e: any) {
if (e.status === 404) {
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
} }
} catch (e: any) {
if (e.status === 404) {
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
}
}; };
poll(); poll();
@@ -181,37 +184,49 @@ 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 shouldMaskOtherUserPresence = privacyExchangeMode || otherUserInvisible;
useEffect(() => {
if (chat?.type !== 'personal' || !otherMember?.user.id) {
setOtherUserInvisible(false);
setOtherUserStatusHeader(null);
return;
}
let cancelled = false;
UserApi.getUser(otherMember.user.id)
.then((profile) => {
if (!cancelled) {
setOtherUserInvisible(!!profile.isInvisible);
const em = profile?.status?.emoji ?? profile?.statusEmoji ?? null;
const tx = profile?.status?.text ?? profile?.statusText ?? null;
setOtherUserStatusHeader(em || tx ? { emoji: em, text: tx } : null);
}
})
.catch(() => {
if (!cancelled) {
setOtherUserInvisible(false);
setOtherUserStatusHeader(null);
}
});
return () => {
cancelled = true;
};
}, [chat?.type, otherMember?.user.id]);
const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id); const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id);
// Refs and logic for tracking session's first unread message to show the divider exactly once per load // Refs and logic for tracking session's first unread message to show the divider exactly once per load
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null }); const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
// Update sessionUnreadRef when chat changes OR when messages are marked as read if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) {
useEffect(() => { const firstUnreadMsg = chatMessages.find(
if (!activeChat || isLoadingMessages) return; (m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
// Reset on chat change sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
if (activeChat !== sessionUnreadRef.current.chatId) { }
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
} else {
// Update if the first unread message was read (msgId no longer exists in unread list)
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
if (sessionUnreadRef.current.msgId && !firstUnreadMsg) {
// All messages are now read
sessionUnreadRef.current.msgId = null;
} else if (firstUnreadMsg && sessionUnreadRef.current.msgId !== firstUnreadMsg.id) {
// First unread changed (some messages were read)
sessionUnreadRef.current.msgId = firstUnreadMsg.id;
}
}
}, [activeChat, chatMessages, user?.id, isLoadingMessages]);
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
const initialScrollChatId = useRef<string | null>(null); const initialScrollChatId = useRef<string | null>(null);
@@ -289,19 +304,19 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const saveScrollPosition = useCallback((targetChatId?: string) => { const saveScrollPosition = useCallback((targetChatId?: string) => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
const chatId = targetChatId || activeChat; const chatId = targetChatId || activeChat;
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return; if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
// Проверка: сообщения в стейте должны быть от целевого чата // Проверка: сообщения в стейте должны быть от целевого чата
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return; if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40; const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
isAtBottomRef.current = isAtBottomNow; isAtBottomRef.current = isAtBottomNow;
if (isAtBottomNow) { if (isAtBottomNow) {
localStorage.setItem(`chat_at_bottom_${chatId}`, 'true'); localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
localStorage.removeItem(`chat_anchor_${chatId}`); localStorage.removeItem(`chat_anchor_${chatId}`);
return; return;
} }
const messageElements = container.querySelectorAll('[data-message-id]'); const messageElements = container.querySelectorAll('[data-message-id]');
@@ -323,8 +338,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
} }
if (anchor && anchor.id) { if (anchor && anchor.id) {
localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor)); localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor));
localStorage.removeItem(`chat_at_bottom_${chatId}`); localStorage.removeItem(`chat_at_bottom_${chatId}`);
} }
}, [activeChat, scrollReady, chatMessages]); }, [activeChat, scrollReady, chatMessages]);
@@ -332,7 +347,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const restoreScrollPosition = useCallback(() => { const restoreScrollPosition = useCallback(() => {
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
if (!container || !activeChat) return false; if (!container || !activeChat) return false;
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false; if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
if (chatMessages.length === 0) return true; if (chatMessages.length === 0) return true;
@@ -349,16 +364,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
try { try {
const { id, offset } = JSON.parse(saved); const { id, offset } = JSON.parse(saved);
let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement; let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement;
// Поиск ближайшего, если точное сообщение еще не загружено // Поиск ближайшего, если точное сообщение еще не загружено
if (!el) { if (!el) {
const msgIndex = chatMessages.findIndex(m => m.id === id); const msgIndex = chatMessages.findIndex(m => m.id === id);
if (msgIndex !== -1) { if (msgIndex !== -1) {
for (let i = msgIndex; i < chatMessages.length; i++) { for (let i = msgIndex; i < chatMessages.length; i++) {
const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement; const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement;
if (nextEl) { el = nextEl; break; } if (nextEl) { el = nextEl; break; }
} }
} }
} }
if (el) { if (el) {
@@ -387,7 +402,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
useEffect(() => { useEffect(() => {
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return; if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
const container = scrollContainerRef.current; const container = scrollContainerRef.current;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
if (isInitializingRef.current) return; if (isInitializingRef.current) return;
@@ -424,18 +439,18 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Сохраняем позицию старого чата // Сохраняем позицию старого чата
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) { if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
saveScrollPosition(prevChatIdRef.current); saveScrollPosition(prevChatIdRef.current);
} }
setScrollReady(false); setScrollReady(false);
prevChatIdRef.current = activeChat; prevChatIdRef.current = activeChat;
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0; scrollContainerRef.current.scrollTop = 0;
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
isInitializingRef.current = false; isInitializingRef.current = false;
}, 600); }, 600);
return () => clearTimeout(timer); return () => clearTimeout(timer);
@@ -462,7 +477,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150); scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150);
const st = container.scrollTop; const st = container.scrollTop;
const isScrollingUp = st < lastScrollTopRef.current; const isScrollingUp = st < lastScrollTopRef.current;
const stChanged = st !== lastScrollTopRef.current; const stChanged = st !== lastScrollTopRef.current;
@@ -492,7 +507,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const messageElements = container.querySelectorAll('[data-message-id]'); const messageElements = container.querySelectorAll('[data-message-id]');
let currentTopMsgId = null; let currentTopMsgId = null;
for (const el of messageElements) { for (const el of messageElements) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
@@ -525,15 +540,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Принудительный сброс режима (Второй Клик) // Принудительный сброс режима (Второй Клик)
localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true'); localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true');
localStorage.removeItem(`chat_anchor_${activeChat}`); localStorage.removeItem(`chat_anchor_${activeChat}`);
isScrollingToBottomRef.current = true; isScrollingToBottomRef.current = true;
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({ scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight, top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth' behavior: 'smooth'
}); });
} }
setTimeout(() => { setTimeout(() => {
isScrollingToBottomRef.current = false; isScrollingToBottomRef.current = false;
}, 1000); }, 1000);
@@ -592,7 +607,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{ {
root: scrollContainerRef.current, root: scrollContainerRef.current,
threshold: 0.1, threshold: 0.1,
rootMargin: '0px',
} }
); );
@@ -604,26 +618,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
unreadElements.forEach((el: Element) => { unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-id'); const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) { if (id && !sentReadIdsRef.current.has(id)) {
// Check if element is already visible observer.observe(el);
const rect = el.getBoundingClientRect();
const containerRect = scrollContainerRef.current!.getBoundingClientRect();
const isVisible = rect.top >= containerRect.top && rect.bottom <= containerRect.bottom;
if (isVisible) {
// Mark as read immediately without waiting for intersection
const seqId = parseInt(el.getAttribute('data-sequence-id') || '0', 10);
if (seqId > 0) {
socket.emit('read_messages', {
chatId: activeChat,
lastReadMessageId: id,
lastReadSequenceId: seqId,
});
useChatStore.getState().markRead(activeChat, user.id, seqId);
sentReadIdsRef.current.add(id);
}
} else {
observer.observe(el);
}
} }
}); });
}; };
@@ -685,7 +680,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<section className="flex-1 h-full flex flex-col items-center justify-center bg-[#010101] relative overflow-hidden"> <section className="flex-1 h-full flex flex-col items-center justify-center bg-[#010101] relative overflow-hidden">
{/* Background Knot Texture */} {/* Background Knot Texture */}
<div className="absolute inset-0 opacity-[0.04] pointer-events-none flex items-center justify-center"> <div className="absolute inset-0 opacity-[0.04] pointer-events-none flex items-center justify-center">
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span> <span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
</div> </div>
{/* Chat Empty State View */} {/* Chat Empty State View */}
@@ -880,17 +875,33 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<span className="material-symbols-outlined text-on-primary-container text-[20px]">bookmark</span> <span className="material-symbols-outlined text-on-primary-container text-[20px]">bookmark</span>
</div> </div>
) : ( ) : (
<Avatar <div className="relative">
src={chatAvatar} <Avatar
name={chatName} src={chatAvatar}
size="md" name={chatName}
online={isOnline ? true : undefined} size="md"
className="avatar-knot-container group-hover:border-primary/30" online={!shouldMaskOtherUserPresence && isOnline ? true : undefined}
/> className="avatar-knot-container group-hover:border-primary/30"
/>
{chat.type === 'personal' && otherUserStatusHeader?.emoji ? (
<span
className="absolute -bottom-0.5 -right-0.5 min-w-[1.1rem] h-[1.1rem] flex items-center justify-center rounded-md bg-surface-container text-[11px] leading-none shadow ring-2 ring-surface-container-lowest"
title={otherUserStatusHeader.text || undefined}
>
{otherUserStatusHeader.emoji}
</span>
) : null}
</div>
)} )}
</div> </div>
<div className="min-w-0 text-left"> <div className="min-w-0 text-left">
<h3 className="text-lg font-bold font-headline text-on-surface truncate group-hover:text-primary transition-colors leading-none mb-1">{chatName}</h3> <h3 className="text-lg font-bold font-headline text-on-surface truncate group-hover:text-primary transition-colors leading-none mb-1">{chatName}</h3>
{chat.type === 'personal' && otherUserStatusHeader?.text ? (
<p className="text-[11px] font-medium text-on-surface-variant/70 truncate mb-0.5 normal-case tracking-normal">
{otherUserStatusHeader.emoji ? `${otherUserStatusHeader.emoji} ` : ''}
{otherUserStatusHeader.text}
</p>
) : null}
<p className="text-[11px] font-bold uppercase tracking-widest text-on-surface-variant/50"> <p className="text-[11px] font-bold uppercase tracking-widest text-on-surface-variant/50">
{isFavorites {isFavorites
? t('favoritesDescription') ? t('favoritesDescription')
@@ -898,7 +909,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' && shouldMaskOtherUserPresence
? 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')}`
@@ -1191,16 +1204,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between mb-2 px-1"> <div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-black uppercase tracking-widest text-primary"> <span className="text-xs font-black uppercase tracking-widest text-primary">
{importStatus?.status === 'Processing' ? 'Обработка' : {importStatus?.status === 'Processing' ? 'Обработка' :
importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'} importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'}
</span> </span>
<span className="text-xs font-black text-on-surface tabular-nums"> <span className="text-xs font-black text-on-surface tabular-nums">
{importStatus?.processed || 0} / {importStatus?.total || 0} {importStatus?.processed || 0} / {importStatus?.total || 0}
</span> </span>
</div> </div>
<div className="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden border border-outline/10 p-0.5"> <div className="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden border border-outline/10 p-0.5">
<motion.div <motion.div
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: `${Math.min(100, Math.round(((importStatus?.processed || 0) / (importStatus?.total || 1)) * 100))}%` }} animate={{ width: `${Math.min(100, Math.round(((importStatus?.processed || 0) / (importStatus?.total || 1)) * 100))}%` }}
transition={{ type: 'spring', damping: 20 }} transition={{ type: 'spring', damping: 20 }}
@@ -1238,9 +1251,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{chatPinnedMessages.length > 1 && ( {chatPinnedMessages.length > 1 && (
<div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden"> <div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden">
{chatPinnedMessages.map((_, idx) => ( {chatPinnedMessages.map((_, idx) => (
<div <div
key={idx} key={idx}
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`} className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
/> />
))} ))}
</div> </div>
@@ -1265,8 +1278,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''} {t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
</p> </p>
<p className="text-sm text-zinc-300 truncate font-medium"> <p className="text-sm text-zinc-300 truncate font-medium">
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content || {chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')} (chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
</p> </p>
</div> </div>
</button> </button>

View File

@@ -76,9 +76,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const getSearchedUserId = (u: UserPresence & { userId?: string }) => u.id || u.userId || '';
// Keep local state in sync with chat prop // Keep local state in sync with chat prop
useEffect(() => { useEffect(() => {
setGroupName(chat.name || ''); setGroupName(chat.name || '');
setGroupDesc(chat.description || ''); setGroupDesc(chat.description || '');
}, [chat.name, chat.description]); }, [chat.name, chat.description]);
@@ -95,7 +96,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const results = await UserApi.searchUsers(searchQuery); const results = await UserApi.searchUsers(searchQuery);
// Filter out users already in the group // Filter out users already in the group
const memberIds = new Set(chat.members.map((m) => m.user.id)); const memberIds = new Set(chat.members.map((m) => m.user.id));
setSearchResults(results.filter((u) => !memberIds.has(u.id))); setSearchResults(results.filter((u) => {
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
return !!candidateId && !memberIds.has(candidateId);
}));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
@@ -565,10 +569,13 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
<Loader2 size={16} className="text-zinc-500 animate-spin" /> <Loader2 size={16} className="text-zinc-500 animate-spin" />
</div> </div>
)} )}
{searchResults.map((u) => ( {searchResults.map((u) => {
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
if (!candidateId) return null;
return (
<button <button
key={u.id} key={candidateId}
onClick={() => handleAddMember(u.id)} onClick={() => handleAddMember(candidateId)}
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors" className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
> >
<Avatar <Avatar
@@ -583,7 +590,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
</div> </div>
<UserPlus size={14} className="text-knot-400 flex-shrink-0" /> <UserPlus size={14} className="text-knot-400 flex-shrink-0" />
</button> </button>
))} );
})}
{searchQuery.trim() && !isSearching && searchResults.length === 0 && ( {searchQuery.trim() && !isSearching && searchResults.length === 0 && (
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p> <p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
)} )}

View File

@@ -25,13 +25,14 @@ import {
PhoneIncoming, PhoneIncoming,
PhoneOutgoing, PhoneOutgoing,
BarChart2, BarChart2,
Music,
} from 'lucide-react'; } from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore'; import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore'; import { useChatStore } from '../../application/chatStore';
import { getSocket } from '../../../../core/infrastructure/socket'; import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n'; import { useLang } from '../../../../core/infrastructure/i18n';
import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '../../../../core/utils/utils'; import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '../../../../core/utils/utils';
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types'; import { AUDIO_EXTENSIONS, type Message, type MediaItem, type Reaction, type ChatMember } from '../../../../core/domain/types';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox'; import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import LinkPreview from './LinkPreview'; import LinkPreview from './LinkPreview';
import Avatar from '../../../../core/presentation/components/ui/Avatar'; import Avatar from '../../../../core/presentation/components/ui/Avatar';
@@ -355,11 +356,13 @@ function MessageBubble({
return false; return false;
}; };
const isAudioFile = (m: MediaItem) => m.type === 'audio' || AUDIO_EXTENSIONS.some(ext => m.filename?.toLowerCase().endsWith(ext));
const hasImage = media.some((m) => m.type === 'image' || isMediaGif(m)); const hasImage = media.some((m) => m.type === 'image' || isMediaGif(m));
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice'); const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio')); const hasAudio = !hasVoice && (message.type === 'audio' || media.some(isAudioFile));
const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m)); const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m));
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && !isMediaGif(m)); const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && !isMediaGif(m));
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {}; const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {};
(message.reactions || []).forEach((r) => { (message.reactions || []).forEach((r) => {
@@ -610,7 +613,7 @@ function MessageBubble({
return ( return (
<div className={` <div className={`
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''} ${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content || hasVoice || hasAudio || hasFile || message.type === 'poll' ? 'mb-3' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
${isSingleGif ? 'max-w-[260px]' : ''} ${isSingleGif ? 'max-w-[260px]' : ''}
overflow-hidden relative rounded-[1.25rem] overflow-hidden relative rounded-[1.25rem]
`}> `}>
@@ -706,7 +709,7 @@ function MessageBubble({
{/* Голосовое - Optimized Kinetic Layout */} {/* Голосовое - Optimized Kinetic Layout */}
{hasVoice && ( {hasVoice && (
<div className="flex items-center gap-3 min-w-[200px] py-0.5"> <div className={`flex items-center gap-3 min-w-[200px] py-0.5 ${hasImage || hasVideo || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
<audio <audio
ref={audioRef} ref={audioRef}
src={media.find((m) => m.type === 'voice')?.url} src={media.find((m) => m.type === 'voice')?.url}
@@ -765,7 +768,7 @@ function MessageBubble({
{/* Аудио (mp3 файлы) */} {/* Аудио (mp3 файлы) */}
{hasAudio && (() => { {hasAudio && (() => {
const audioMedia = media.find((m) => m.type === 'audio'); const audioMedia = media.find(isAudioFile);
const formatSize = (bytes?: number | null) => { const formatSize = (bytes?: number | null) => {
if (!bytes) return ""; if (!bytes) return "";
if (bytes < 1024) return bytes + " B"; if (bytes < 1024) return bytes + " B";
@@ -775,7 +778,7 @@ function MessageBubble({
}; };
return ( return (
<div className="min-w-[220px]"> <div className={`min-w-[220px] ${hasImage || hasVideo || hasVoice || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
{audioMedia?.filename && ( {audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2 min-w-0"> <div className="flex items-center gap-2 mb-2 min-w-0">
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} /> <Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
@@ -855,7 +858,7 @@ function MessageBubble({
{/* Файлы */} {/* Файлы */}
{hasFile && {hasFile &&
media media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif') .filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && m.type !== 'gif')
.map((m) => { .map((m) => {
const formatSize = (bytes?: number | null) => { const formatSize = (bytes?: number | null) => {
if (!bytes) return ""; if (!bytes) return "";
@@ -872,7 +875,7 @@ function MessageBubble({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5' className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
} transition-all mb-1 group/file`} } transition-all mb-1 group/file ${hasImage || hasVideo || hasVoice || hasAudio || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}
> >
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20' <div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
} group-hover/file:scale-110 transition-transform`}> } group-hover/file:scale-110 transition-transform`}>
@@ -1031,7 +1034,7 @@ function MessageBubble({
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u; const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15; const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
return ( return (
<div className="flex items-end gap-2 text-sm w-full"> <div className={`flex items-end gap-2 text-sm w-full ${hasImage || hasVideo || hasVoice || hasAudio || hasFile || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
<div className="flex-1 min-w-0 w-full"> <div className="flex-1 min-w-0 w-full">
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}> <p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
{renderFormattedText(message.content)} {renderFormattedText(message.content)}

View File

@@ -0,0 +1,31 @@
import { httpClient } from '../../../core/infrastructure/httpClient';
import type { User, StatusPreset } from '../../../core/domain/types';
export class StatusApi {
static async getPresets(): Promise<StatusPreset[]> {
return httpClient.request<StatusPreset[]>('/statuses/presets');
}
static async setCustom(body: {
emoji?: string | null;
text?: string | null;
expiresAt?: string | null;
presetKey?: string | null;
}): Promise<User> {
return httpClient.request<User>('/statuses/custom', {
method: 'POST',
body: JSON.stringify({
emoji: body.emoji ?? '',
text: body.text ?? '',
expiresAt: body.expiresAt ?? null,
presetKey: body.presetKey ?? null,
}),
});
}
static async clear(): Promise<User> {
return httpClient.request<User>('/statuses/', {
method: 'DELETE',
});
}
}

View File

@@ -8,20 +8,29 @@ export class UserApi {
} }
static async getUser(id: string) { static async getUser(id: string) {
// Конечная точка в новом бэкенде: GET /api/profiles/{id} // Основной путь по ТЗ: GET /api/users/{id}; старый профильный путь оставлен на бэкенде.
return httpClient.request<User>(`/profiles/${id}`); return httpClient.request<User>(`/users/${id}`);
} }
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) { static async updateProfile(data: {
// Конечная точка в новом бэкенде: PUT /api/profiles/profile displayName?: string;
return httpClient.request<User>('/profiles/profile', { bio?: string;
method: 'PUT', birthday?: string | null;
body: JSON.stringify(data), isInvisible?: boolean;
}) {
const payload = {
...data,
about: data.bio
};
return httpClient.request<User>('/user/profile', {
method: 'PATCH',
body: JSON.stringify(payload),
}); });
} }
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),
@@ -29,13 +38,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,
}); });
} }

View File

@@ -0,0 +1,140 @@
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, X } from 'lucide-react';
import { AuthApi } from '../../../auth/infrastructure/authApi';
import { useLang } from '../../../../core/infrastructure/i18n';
interface ChangePasswordModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function ChangePasswordModal({ open, onClose, onSuccess }: ChangePasswordModalProps) {
const { lang } = useLang();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const minLengthOk = newPassword.length >= 8;
const matchOk = newPassword.length > 0 && confirmPassword.length > 0 && newPassword === confirmPassword;
const canSubmit = useMemo(
() => oldPassword.length > 0 && minLengthOk && matchOk && !loading,
[oldPassword.length, minLengthOk, matchOk, loading]
);
const resetState = () => {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
setLoading(false);
};
const close = () => {
resetState();
onClose();
};
const submit = async () => {
if (!canSubmit) return;
setLoading(true);
setError(null);
try {
await AuthApi.changePassword({ oldPassword, newPassword, confirmPassword });
resetState();
onSuccess();
} catch (e: any) {
const message = e?.message || (lang === 'ru' ? 'Не удалось сменить пароль' : 'Failed to change password');
setError(message);
} finally {
setLoading(false);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[12000] flex items-center justify-center p-4"
>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={close} />
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-md rounded-[2rem] border border-white/10 bg-[#111111] p-6 shadow-2xl"
>
<button
onClick={close}
className="absolute right-4 top-4 p-2 rounded-xl text-white/50 hover:text-white hover:bg-white/10"
>
<X size={16} />
</button>
<h3 className="text-lg font-black text-white mb-5">
{lang === 'ru' ? 'Смена пароля' : 'Change password'}
</h3>
<div className="space-y-3">
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Текущий пароль' : 'Current password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Новый пароль' : 'New password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Подтвердите пароль' : 'Confirm password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
</div>
<div className="mt-3 space-y-1">
<p className={`text-xs ${minLengthOk ? 'text-emerald-400' : 'text-white/45'}`}>
{lang === 'ru' ? 'Минимум 8 символов' : 'At least 8 characters'}
</p>
<p className={`text-xs ${matchOk ? 'text-emerald-400' : 'text-white/45'}`}>
{lang === 'ru' ? 'Пароли совпадают' : 'Passwords match'}
</p>
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
<div className="mt-6 flex items-center justify-end gap-2">
<button
onClick={close}
className="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider text-white/60 hover:text-white"
>
{lang === 'ru' ? 'Отмена' : 'Cancel'}
</button>
<button
onClick={submit}
disabled={!canSubmit}
className="px-5 py-2 rounded-xl bg-primary text-on-primary text-xs font-black uppercase tracking-wider disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : (lang === 'ru' ? 'Сменить' : 'Change')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -4,30 +4,46 @@ import {
User, Camera, Edit3, Calendar, Info, User, Camera, Edit3, Calendar, Info,
MapPin, AtSign, Check, Loader2, LogOut, MapPin, AtSign, Check, Loader2, LogOut,
ChevronRight, ArrowLeft, Languages, Palette, Trash2, ChevronRight, ArrowLeft, Languages, Palette, Trash2,
Bell, Shield, Eye 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 { StatusApi } from '../../infrastructure/statusApi';
import { getMediaUrl, getInitials } from '../../../../core/utils/utils'; import { getMediaUrl, getInitials } from '../../../../core/utils/utils';
import DatePicker from '../../../../core/presentation/components/ui/DatePicker'; import DatePicker from '../../../../core/presentation/components/ui/DatePicker';
import AvatarCropModal from './AvatarCropModal'; import AvatarCropModal from './AvatarCropModal';
import { Area } from 'react-easy-crop'; import { Area } from 'react-easy-crop';
import { getCroppedImg } from '../../../../core/utils/cropImage'; import { getCroppedImg } from '../../../../core/utils/cropImage';
import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker';
import ChangePasswordModal from './ChangePasswordModal';
import type { StatusPreset } from '../../../../core/domain/types';
const BIO_MAX_LENGTH = 200;
const STATUS_MAX_LENGTH = 50;
export default function SettingsPage() { export default function SettingsPage() {
const { user, updateUser, logout } = useAuthStore(); const { user, updateUser, logout, checkAuth } = useAuthStore();
const { applyInvisiblePrivacyMode } = useChatStore();
const { t, lang, setLang } = useLang(); const { t, lang, setLang } = useLang();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
displayName: user?.displayName || '', displayName: user?.displayName || '',
bio: user?.bio || '', bio: user?.bio || '',
birthday: user?.birthday || '' birthday: user?.birthday || '',
statusText: (user?.status?.text ?? user?.statusText) || '',
statusEmoji: (user?.status?.emoji ?? user?.statusEmoji) || '💬',
statusDuration: 'none' as 'none' | '1h' | '1d',
isInvisible: !!user?.isInvisible
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showStatusPicker, setShowStatusPicker] = useState(false);
// Avatar cropping state const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [updatingInvisible, setUpdatingInvisible] = useState(false);
const [statusPresets, setStatusPresets] = useState<StatusPreset[]>([]);
const [cropModal, setCropModal] = useState<{ const [cropModal, setCropModal] = useState<{
open: boolean, open: boolean,
image: string, image: string,
@@ -36,12 +52,22 @@ export default function SettingsPage() {
const [uploadingAvatar, setUploadingAvatar] = useState(false); const [uploadingAvatar, setUploadingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
StatusApi.getPresets()
.then(setStatusPresets)
.catch(() => setStatusPresets([]));
}, []);
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setFormData({ setFormData({
displayName: user.displayName || '', displayName: user.displayName || '',
bio: user.bio || '', bio: user.bio || '',
birthday: user.birthday || '' birthday: user.birthday || '',
statusText: (user?.status?.text ?? user.statusText) || '',
statusEmoji: (user?.status?.emoji ?? user.statusEmoji) || '💬',
statusDuration: 'none',
isInvisible: !!user.isInvisible
}); });
} }
}, [user]); }, [user]);
@@ -49,12 +75,35 @@ export default function SettingsPage() {
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
setSaving(true); setSaving(true);
try { try {
const payload = { await UserApi.updateProfile({
...formData, displayName: formData.displayName,
birthday: formData.birthday || null bio: formData.bio.slice(0, BIO_MAX_LENGTH),
}; birthday: formData.birthday || null,
const updatedUser = await UserApi.updateProfile(payload); isInvisible: formData.isInvisible,
updateUser(updatedUser); });
const hasText = formData.statusText.trim().length > 0;
const em = (formData.statusEmoji || '').trim();
const hasEmoji = em.length > 0 && em !== '💬';
if (!hasText && !hasEmoji) {
await StatusApi.clear();
} else {
await StatusApi.setCustom({
emoji: em || '💬',
text: formData.statusText.trim().slice(0, STATUS_MAX_LENGTH),
expiresAt:
formData.statusText.trim() && formData.statusDuration !== 'none'
? new Date(
Date.now() +
(formData.statusDuration === '1h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)
).toISOString()
: null,
presetKey: null,
});
}
await checkAuth();
if (formData.isInvisible) {
applyInvisiblePrivacyMode(true);
}
setEditing(false); setEditing(false);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -106,8 +155,45 @@ 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,
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 || '??');
const applyPresetQuick = async (p: StatusPreset) => {
try {
if (p.id === 'online') {
await StatusApi.clear();
} else {
const text = lang === 'ru' ? p.textRu : p.textEn;
await StatusApi.setCustom({ presetKey: p.id, emoji: p.emoji, text });
}
await checkAuth();
} catch (err) {
console.error(err);
}
};
return ( return (
<div className="flex-1 h-full overflow-y-auto px-12 py-16 custom-scrollbar bg-surface-container-lowest"> <div className="flex-1 h-full overflow-y-auto px-12 py-16 custom-scrollbar bg-surface-container-lowest">
<div className="max-w-[700px] mx-auto space-y-12"> <div className="max-w-[700px] mx-auto space-y-12">
@@ -237,7 +323,7 @@ export default function SettingsPage() {
{editing ? ( {editing ? (
<textarea <textarea
value={formData.bio} value={formData.bio}
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))} onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value.slice(0, BIO_MAX_LENGTH) }))}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32" className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
placeholder={t('tellAboutBio')} placeholder={t('tellAboutBio')}
/> />
@@ -246,6 +332,11 @@ export default function SettingsPage() {
{user?.bio || t('noBio')} {user?.bio || t('noBio')}
</p> </p>
)} )}
{editing && (
<p className="text-[10px] font-black uppercase tracking-widest text-white/35 text-right">
{formData.bio.length}/{BIO_MAX_LENGTH}
</p>
)}
</div> </div>
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]"> <div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
@@ -266,6 +357,95 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 text-primary mb-1">
<Smile size={16} />
<span className="text-[10px] font-black uppercase tracking-[0.2em]">
{lang === 'ru' ? 'Статус' : 'Status'}
</span>
</div>
{editing ? (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{statusPresets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => applyPresetQuick(preset)}
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-xs font-bold text-white/80 hover:border-primary/40"
>
{preset.emoji ? `${preset.emoji} ` : ''}
{lang === 'ru' ? preset.textRu : preset.textEn}
</button>
))}
</div>
<div className="flex gap-3">
<button
onClick={() => setShowStatusPicker(true)}
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm font-bold"
>
{formData.statusEmoji}
</button>
<input
type="text"
value={formData.statusText}
onChange={(e) => setFormData((p) => ({ ...p, statusText: e.target.value.slice(0, STATUS_MAX_LENGTH) }))}
className="flex-1 bg-white/5 border border-white/10 rounded-2xl px-4 py-2.5 text-sm font-medium text-white/90 focus:border-primary/50 transition-all outline-none"
placeholder={lang === 'ru' ? 'Что у вас сейчас?' : 'What are you up to?'}
/>
</div>
<div className="flex gap-2">
{[
{ id: 'none', label: lang === 'ru' ? 'Без таймера' : 'No timer' },
{ id: '1h', label: lang === 'ru' ? '1 час' : '1 hour' },
{ id: '1d', label: lang === 'ru' ? '1 день' : '1 day' },
].map((opt) => (
<button
key={opt.id}
onClick={() => setFormData((p) => ({ ...p, statusDuration: opt.id as 'none' | '1h' | '1d' }))}
className={`px-3 py-2 rounded-xl text-xs font-black uppercase tracking-wider border ${
formData.statusDuration === opt.id
? 'bg-primary/20 border-primary/40 text-primary'
: 'bg-white/5 border-white/10 text-white/60'
}`}
>
{opt.label}
</button>
))}
</div>
<div className="flex items-center justify-between">
<p className="text-[10px] font-black uppercase tracking-widest text-white/35">
{formData.statusText.length}/{STATUS_MAX_LENGTH}
</p>
<button
type="button"
onClick={async () => {
setFormData((p) => ({ ...p, statusText: '', statusEmoji: '💬', statusDuration: 'none' }));
try {
await StatusApi.clear();
await checkAuth();
} catch (err) {
console.error(err);
}
}}
className="text-[10px] font-black uppercase tracking-widest text-red-400/80 hover:text-red-400"
>
{lang === 'ru' ? 'Очистить статус' : 'Clear status'}
</button>
</div>
</div>
) : (
<div className="inline-flex px-4 py-2 rounded-full bg-white/5 border border-white/10 text-sm font-bold text-white">
{(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '}
{(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус не указан' : 'No status')}
</div>
)}
</div>
{/* App Settings */} {/* App Settings */}
<div className="space-y-6 pt-6"> <div className="space-y-6 pt-6">
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
@@ -298,6 +478,48 @@ 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
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"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-white/10 flex items-center justify-center text-primary group-hover:scale-105 transition-transform">
<Shield size={16} />
</div>
<span className="text-[11px] font-black uppercase tracking-widest text-white/70 group-hover:text-white transition-colors">
{lang === 'ru' ? 'Сменить пароль' : 'Change password'}
</span>
</div>
<ChevronRight size={16} className="text-white/30" />
</button>
<button <button
onClick={() => { logout(); }} onClick={() => { logout(); }}
className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group" className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group"
@@ -324,6 +546,26 @@ export default function SettingsPage() {
/> />
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence>
{showStatusPicker && (
<EmojiOnlyPicker
onSelect={(emoji) => {
setFormData((p) => ({ ...p, statusEmoji: emoji }));
setShowStatusPicker(false);
}}
onClose={() => setShowStatusPicker(false)}
/>
)}
</AnimatePresence>
<ChangePasswordModal
open={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
onSuccess={() => {
setShowChangePasswordModal(false);
// Access token remains valid; ask user to re-login to ensure security on all devices.
logout();
}}
/>
</div> </div>
); );
} }

View File

@@ -11,7 +11,7 @@ import { useLang } from '../../../../core/infrastructure/i18n';
import { useAuthStore } from '../../../auth/application/authStore'; import { useAuthStore } from '../../../auth/application/authStore';
import { useFriendStore } from '../../../friends/application/friendStore'; import { useFriendStore } from '../../../friends/application/friendStore';
import { ChatApi } from '../../../chats/infrastructure/chatApi'; import { ChatApi } from '../../../chats/infrastructure/chatApi';
import { httpClient } from '../../../../core/infrastructure/httpClient'; import { UserApi } from '../../infrastructure/userApi';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox'; import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types'; import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
@@ -51,7 +51,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
const fetchUser = useCallback(async () => { const fetchUser = useCallback(async () => {
try { try {
const res = await httpClient.request<any>(`/profiles/${userId}`); const res = await UserApi.getUser(userId);
setUser(res); setUser(res);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -242,6 +242,15 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
<div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25"> <div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25">
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span> <span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
</div> </div>
{(Boolean(user?.status?.text ?? user?.statusText) ||
Boolean((user?.status?.emoji ?? user?.statusEmoji) && (user?.status?.emoji ?? user?.statusEmoji) !== '💬')) && (
<div className="px-4 py-1.5 rounded-full bg-white/5 border border-white/10">
<span className="text-xs font-black text-white tracking-wide">
{(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '}
{(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус' : 'Status')}
</span>
</div>
)}
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div> <div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div>
</div> </div>
@@ -263,7 +272,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{/* About Section */} {/* About Section */}
<div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]"> <div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div> <div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div>
<p className="text-base text-white/80 leading-relaxed font-medium">{user.about || t('noBio')}</p> <p className="text-base text-white/80 leading-relaxed font-medium">{user.bio || t('noBio')}</p>
</div> </div>
{/* Tabs Nav */} {/* Tabs Nav */}

View File

@@ -1,7 +1,6 @@
services: services:
db: db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: knot-db
restart: always restart: always
environment: environment:
- POSTGRES_USER - POSTGRES_USER
@@ -10,20 +9,19 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "${DB_PORT:-5432}:5432"
server: server:
build: build:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: knot-server
restart: always restart: always
depends_on: depends_on:
- db - db
- minio - minio
- mongo - mongo
ports: ports:
- "5059:8080" - "${SERVER_PORT:-5059}:8080"
environment: environment:
- DATABASE_URL - DATABASE_URL
- MONGO_CONNECTION - MONGO_CONNECTION
@@ -41,23 +39,21 @@ services:
minio: minio:
image: minio/minio image: minio/minio
container_name: knot-minio
restart: always restart: always
environment: environment:
- MINIO_ROOT_USER - MINIO_ROOT_USER
- MINIO_ROOT_PASSWORD - MINIO_ROOT_PASSWORD
ports: ports:
- "9000:9000" - "${MINIO_API_PORT:-9000}:9000"
- "9001:9001" - "${MINIO_CONSOLE_PORT:-9001}:9001"
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
mongo: mongo:
image: mongo:6-jammy image: mongo:6-jammy
container_name: knot-mongo
restart: always restart: always
ports: ports:
- "27017:27017" - "${MONGO_PORT:-27017}:27017"
volumes: volumes:
- mongo_data:/data/db - mongo_data:/data/db
@@ -65,10 +61,9 @@ services:
build: build:
context: .. context: ..
dockerfile: client-web/Dockerfile dockerfile: client-web/Dockerfile
container_name: knot-web
restart: always restart: always
ports: ports:
- "9090:80" - "${WEB_PORT:-9090}:80"
depends_on: depends_on:
- server - server