Compare commits
18 Commits
bugfix_web
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d5764f6e | ||
|
|
786eaffb33 | ||
|
|
4940f1212f | ||
|
|
c166f1d186 | ||
|
|
71b3d2b491 | ||
|
|
a5d40f28c8 | ||
|
|
9bfc5555bc | ||
|
|
7eea8ff6d1 | ||
|
|
72325f48e5 | ||
|
|
2b51375fbf | ||
|
|
c92289f074 | ||
|
|
33ea792941 | ||
|
|
63fc0e197b | ||
|
|
83ed328dd5 | ||
|
|
0f593e52e0 | ||
|
|
d9462069e2 | ||
|
|
9e715fe3ab | ||
|
|
70acad56fb |
@@ -4,6 +4,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ 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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ 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();
|
||||||
|
|
||||||
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)
|
||||||
@@ -53,4 +56,14 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -5,5 +5,8 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -9,10 +11,12 @@ 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)
|
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||||
{
|
{
|
||||||
_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)
|
||||||
@@ -23,9 +27,18 @@ 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 = string.Empty,
|
AccessToken = newAccessToken,
|
||||||
RefreshToken = string.Empty,
|
RefreshToken = string.Empty,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ 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>
|
||||||
@@ -31,14 +28,21 @@ 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 = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = user.Id,
|
||||||
Username = user.Username,
|
Username = user.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = user.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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>;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
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;
|
||||||
@@ -9,9 +8,6 @@ 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,
|
||||||
@@ -19,9 +15,6 @@ 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;
|
||||||
@@ -48,16 +41,13 @@ 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,
|
||||||
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
|||||||
request.Email,
|
request.Email,
|
||||||
request.Bio);
|
request.Bio);
|
||||||
|
|
||||||
// 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
|
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
repoImpl?.Add(user);
|
||||||
repoWithDomainUserAdd?.Add(user);
|
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
// Get the saved user as contract
|
||||||
|
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 = string.Empty,
|
RefreshToken = refreshToken,
|
||||||
UserId = user.Id,
|
UserId = userContract.Id,
|
||||||
Username = user.Username,
|
Username = userContract.Username,
|
||||||
DisplayName = user.DisplayName
|
DisplayName = userContract.DisplayName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ 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() {
|
||||||
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
PhoneNumber = phoneNumber;
|
PhoneNumber = phoneNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetRefreshToken(string? refreshToken)
|
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||||
{
|
{
|
||||||
RefreshToken = refreshToken;
|
RefreshToken = refreshToken;
|
||||||
|
RefreshTokenExpiry = expiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetBannedUntil(DateTime? bannedUntil)
|
public void SetBannedUntil(DateTime? bannedUntil)
|
||||||
@@ -79,6 +81,8 @@ 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)
|
||||||
@@ -181,7 +185,9 @@ 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ 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[]
|
||||||
|
|||||||
100
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal file
100
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
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.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 MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ 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);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using System.Linq;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Shared.Kernel.Storage;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
||||||
|
|
||||||
@@ -19,17 +21,21 @@ 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)
|
||||||
@@ -58,6 +64,14 @@ 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);
|
||||||
@@ -68,6 +82,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);
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ 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(
|
||||||
|
|||||||
@@ -42,10 +42,16 @@ 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
|
||||||
{
|
{
|
||||||
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.DeleteForAll)
|
// Notify all clients in the chat about the deletion
|
||||||
{
|
|
||||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
messageIds = request.MessageIds,
|
messageIds = request.MessageIds,
|
||||||
deleteForAll = true
|
deleteForAll = request.DeleteForAll
|
||||||
});
|
});
|
||||||
}
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||||
|
|
||||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,12 @@ 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.Pivot.HasValue)
|
if (request.AfterSequenceId.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);
|
||||||
}
|
}
|
||||||
@@ -115,6 +120,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)
|
||||||
{
|
{
|
||||||
@@ -141,7 +147,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 is TextMessage tm ? tm.Quote : null,
|
(message as TextMessage)?.Quote,
|
||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.IsDeleted,
|
message.IsDeleted,
|
||||||
message.CreatedAt,
|
message.CreatedAt,
|
||||||
@@ -153,20 +159,23 @@ 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),
|
||||||
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
|
||||||
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)
|
||||||
@@ -179,7 +188,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using MediatR;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
|
||||||
@@ -11,11 +12,13 @@ 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)
|
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
|
||||||
{
|
{
|
||||||
_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)
|
||||||
@@ -28,6 +31,23 @@ 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();
|
||||||
|
|||||||
@@ -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,7 +41,8 @@ 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;
|
||||||
@@ -66,7 +67,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>(),
|
||||||
new List<ReadByDto>()
|
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
|
||||||
);
|
);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +192,7 @@ 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);
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
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.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 Microsoft.Extensions.Caching.Memory;
|
||||||
using Knot.Contracts.Auth.Domain;
|
using Microsoft.Extensions.Logging;
|
||||||
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;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
@@ -158,6 +158,7 @@ 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(),
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ public static class MessagesEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("api/messages").RequireAuthorization();
|
var group = app.MapGroup("api/messages").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapGet("chat/{chatId:guid}", async (
|
||||||
|
[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), ct);
|
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ 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) { }
|
||||||
|
|
||||||
@@ -89,6 +93,16 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 = new List<object>(),
|
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
|
||||||
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,
|
||||||
|
|||||||
@@ -95,6 +95,20 @@ 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;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ 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;
|
||||||
|
|||||||
@@ -3,11 +3,74 @@ 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();
|
||||||
@@ -41,6 +104,36 @@ 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}`;
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ const translations = {
|
|||||||
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
||||||
clearHistory: 'Очистить историю',
|
clearHistory: 'Очистить историю',
|
||||||
clearHistoryConfirm: 'Очистить историю?',
|
clearHistoryConfirm: 'Очистить историю?',
|
||||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
|
||||||
|
deleteGroupChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
|
||||||
pinChat: 'Закрепить чат',
|
pinChat: 'Закрепить чат',
|
||||||
unpinChat: 'Открепить чат',
|
unpinChat: 'Открепить чат',
|
||||||
chatCleared: 'Очищено',
|
chatCleared: 'Очищено',
|
||||||
@@ -574,7 +575,8 @@ 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.',
|
deleteChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
||||||
|
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',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom"
|
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:pt-8 lg:pb-14 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||||
>
|
>
|
||||||
<div className="hidden lg:flex mb-10 flex-col items-center">
|
<div className="hidden lg:flex mb-10 flex-col items-center">
|
||||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||||
@@ -46,7 +46,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
className="lg:mt-auto lg:mb-4 group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||||
onClick={() => onTabChange('settings')}
|
onClick={() => onTabChange('settings')}
|
||||||
>
|
>
|
||||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -23,6 +24,9 @@ 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,
|
||||||
@@ -33,17 +37,20 @@ 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, user } = await AuthApi.login(username, password);
|
const { token, refreshToken, 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, user, isLoading: false });
|
set({ token, refreshToken, 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);
|
||||||
@@ -55,11 +62,14 @@ 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, user } = await AuthApi.register(username, displayName, password, bio);
|
const { token, refreshToken, 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, user, isLoading: false });
|
set({ token, refreshToken, 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);
|
||||||
@@ -70,9 +80,10 @@ 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, user: null });
|
set({ token: null, refreshToken: null, user: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
@@ -122,7 +133,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
errorMsg.includes('Требуется авторизация')
|
errorMsg.includes('Требуется авторизация')
|
||||||
) {
|
) {
|
||||||
localStorage.removeItem('knot_token');
|
localStorage.removeItem('knot_token');
|
||||||
set({ token: null, user: null, isLoading: false });
|
localStorage.removeItem('knot_refresh_token');
|
||||||
|
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 });
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ 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; userId: string; username: string; displayName: string }>('/auth/login', {
|
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/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,
|
id: response.userId,
|
||||||
username: response.username,
|
username: response.username,
|
||||||
@@ -20,13 +21,32 @@ export class AuthApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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; userId: string; username: string; displayName: string }>('/auth/register', {
|
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/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: {
|
||||||
|
id: response.userId,
|
||||||
|
username: response.username,
|
||||||
|
displayName: response.displayName,
|
||||||
|
avatar: null
|
||||||
|
} 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: {
|
user: {
|
||||||
id: response.userId,
|
id: response.userId,
|
||||||
username: response.username,
|
username: response.username,
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ 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 }] };
|
||||||
}
|
}
|
||||||
@@ -412,6 +413,7 @@ 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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export default function ChatPage() {
|
|||||||
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;
|
||||||
@@ -180,7 +182,12 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('messages_read', (data: any) => {
|
socket.on('messages_read', (data: any) => {
|
||||||
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
|
const chatId = data.chatId || data.ChatId;
|
||||||
|
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 }) => {
|
||||||
@@ -330,6 +337,16 @@ 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);
|
||||||
@@ -366,8 +383,6 @@ 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 }}
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
|
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
|
||||||
|
|
||||||
// Галочки прочтения
|
// Галочки прочтения
|
||||||
const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||||
|
// Для чужих сообщений: не показываем галочки
|
||||||
|
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 })
|
||||||
@@ -188,8 +190,7 @@ 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)] ${
|
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'
|
||||||
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Аватар */}
|
{/* Аватар */}
|
||||||
|
|||||||
@@ -187,12 +187,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
// 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 });
|
||||||
|
|
||||||
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) {
|
// Update sessionUnreadRef when chat changes OR when messages are marked as read
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChat || isLoadingMessages) return;
|
||||||
|
|
||||||
|
// Reset on chat change
|
||||||
|
if (activeChat !== sessionUnreadRef.current.chatId) {
|
||||||
const firstUnreadMsg = chatMessages.find(
|
const firstUnreadMsg = chatMessages.find(
|
||||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||||
);
|
);
|
||||||
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
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);
|
||||||
|
|
||||||
@@ -573,6 +592,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
{
|
{
|
||||||
root: scrollContainerRef.current,
|
root: scrollContainerRef.current,
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
|
rootMargin: '0px',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -584,8 +604,27 @@ 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
|
||||||
|
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);
|
observer.observe(el);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1377,11 +1416,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{typingInChat.length > 0 && (
|
{/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
|
||||||
<div className="px-4 pb-1">
|
|
||||||
<TypingIndicator />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,14 +25,13 @@ 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 { AUDIO_EXTENSIONS, type Message, type MediaItem, type Reaction, type ChatMember } from '../../../../core/domain/types';
|
import type { Message, MediaItem, Reaction, 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';
|
||||||
@@ -356,13 +355,11 @@ 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(isAudioFile));
|
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
|
||||||
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' && !isAudioFile(m) && !isMediaGif(m));
|
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && !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) => {
|
||||||
@@ -455,7 +452,7 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMine && (
|
{!isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||||
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||||
@@ -472,7 +469,7 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||||
{!isMine && showAvatar && (
|
{!isMine && showAvatar && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||||
<button
|
<button
|
||||||
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
||||||
onClick={() => onViewProfile?.(message.senderId)}
|
onClick={() => onViewProfile?.(message.senderId)}
|
||||||
@@ -768,7 +765,7 @@ function MessageBubble({
|
|||||||
|
|
||||||
{/* Аудио (mp3 файлы) */}
|
{/* Аудио (mp3 файлы) */}
|
||||||
{hasAudio && (() => {
|
{hasAudio && (() => {
|
||||||
const audioMedia = media.find(isAudioFile);
|
const audioMedia = media.find((m) => m.type === 'audio');
|
||||||
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";
|
||||||
@@ -858,7 +855,7 @@ function MessageBubble({
|
|||||||
{/* Файлы */}
|
{/* Файлы */}
|
||||||
{hasFile &&
|
{hasFile &&
|
||||||
media
|
media
|
||||||
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && m.type !== 'gif')
|
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif')
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const formatSize = (bytes?: number | null) => {
|
const formatSize = (bytes?: number | null) => {
|
||||||
if (!bytes) return "";
|
if (!bytes) return "";
|
||||||
@@ -1088,7 +1085,7 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMine && (
|
{isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||||
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||||
|
|||||||
@@ -331,9 +331,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const processFiles = useCallback((files: File[]) => {
|
||||||
const files = Array.from(e.target.files || []);
|
|
||||||
if (files.length > 0) {
|
|
||||||
const { addNotification } = useNotificationStore.getState();
|
const { addNotification } = useNotificationStore.getState();
|
||||||
const newAttachments: Attachment[] = [];
|
const newAttachments: Attachment[] = [];
|
||||||
|
|
||||||
@@ -349,8 +347,15 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
tooLarge = true;
|
tooLarge = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isVideo = file.type.startsWith('video/');
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||||
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
|
|
||||||
|
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||||
|
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||||
|
|
||||||
|
newAttachments.push({ file, type, preview });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||||
@@ -358,38 +363,38 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
|
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
setAttachments(prev => [...prev, ...newAttachments]);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}, [attachments, t]);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (files.length > 0) processFiles(files);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
setShowAttachMenu(false);
|
setShowAttachMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) processFiles(files);
|
||||||
const { addNotification } = useNotificationStore.getState();
|
|
||||||
const newAttachments: Attachment[] = [];
|
|
||||||
|
|
||||||
let limitExceeded = false;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (attachments.length + newAttachments.length >= 20) {
|
|
||||||
limitExceeded = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const isVideo = file.type.startsWith('video/');
|
|
||||||
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
|
|
||||||
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
|
||||||
|
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
setShowAttachMenu(false);
|
setShowAttachMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
const items = Array.from(e.clipboardData.items);
|
||||||
|
const files = items
|
||||||
|
.filter(item => item.kind === 'file')
|
||||||
|
.map(item => item.getAsFile())
|
||||||
|
.filter((f): f is File => f !== null);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFiles(files);
|
||||||
|
// If we only pasted files, don't paste the filename/text representation in the textarea
|
||||||
|
if (items.every(item => item.kind === 'file')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Запись голосового
|
// Запись голосового
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -569,41 +574,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
const { addNotification } = useNotificationStore.getState();
|
processFiles(files);
|
||||||
const newAttachments: Attachment[] = [];
|
|
||||||
|
|
||||||
let tooLarge = false;
|
|
||||||
let limitExceeded = false;
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (attachments.length + newAttachments.length >= 20) {
|
|
||||||
limitExceeded = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
tooLarge = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVideo = file.type.startsWith('video/');
|
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus'];
|
|
||||||
const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext));
|
|
||||||
|
|
||||||
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
|
||||||
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
|
||||||
|
|
||||||
newAttachments.push({ file, type, preview });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
|
||||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
|
||||||
|
|
||||||
setAttachments(prev => [...prev, ...newAttachments]);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const hasContent = text.trim() || attachments.length > 0;
|
const hasContent = text.trim() || attachments.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -856,6 +831,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onContextMenu={handleInputContextMenu}
|
onContextMenu={handleInputContextMenu}
|
||||||
|
onPaste={handlePaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
||||||
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
||||||
|
|||||||
Reference in New Issue
Block a user