6 Commits
37 changed files with 1280 additions and 801 deletions
@@ -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);
@@ -19,13 +19,16 @@ public abstract class Message : AggregateRoot<Guid>
public bool IsEdited => HasState(MessageState.IsEdited); public bool IsEdited => HasState(MessageState.IsEdited);
public bool IsDeleted => HasState(MessageState.IsDeleted); public bool IsDeleted => HasState(MessageState.IsDeleted);
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: base(id) : base(id)
{ {
ChatId = chatId; ChatId = chatId;
@@ -42,15 +45,25 @@ public abstract class Message : AggregateRoot<Guid>
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId); public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
public virtual void Delete() => AddState(MessageState.IsDeleted); public virtual void Delete() => AddState(MessageState.IsDeleted);
public virtual void Edit(string newContent) public virtual void Edit(string newContent)
{ {
Content = newContent; Content = newContent;
AddState(MessageState.IsEdited); AddState(MessageState.IsEdited);
} }
public void DeleteForUser(Guid userId) public void DeleteForUser(Guid userId)
{ {
if (!_deletedFor.Exists(x => x.UserId == userId)) if (!_deletedFor.Exists(x => x.UserId == userId))
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }
+3 -2
View File
@@ -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>
/// . 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>
/// .
/// </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>
/// .
/// </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. 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.
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3.
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. - 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
}); });
} }
} }
+8 -2
View File
@@ -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[]
@@ -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,14 @@
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.Modules.Conversations.Infrastructure.SignalR;
using Knot.Contracts.Messaging.Application.Abstractions; 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 +20,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 +63,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);
@@ -67,7 +80,8 @@ 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,
{ messageIds = request.MessageIds,
chatId = request.ChatId, deleteForAll = request.DeleteForAll
messageIds = request.MessageIds, });
deleteForAll = true
});
}
else
{
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = false
});
}
return global::Knot.Shared.Kernel.Result.Success(); return global::Knot.Shared.Kernel.Result.Success();
} }
@@ -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);
} }
@@ -114,7 +119,8 @@ 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)
@@ -174,12 +183,13 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
}) })
.ToList() .ToList()
: null; : null;
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null); return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
}).ToList(), }).ToList(),
(message as PollMessage)?.IsMultipleChoice, (message as PollMessage)?.IsMultipleChoice,
(message as PollMessage)?.IsAnonymous, (message as PollMessage)?.IsAnonymous,
(message as PollMessage)?.IsClosed, (message as PollMessage)?.IsClosed,
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList() (message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
message.IsDeletedForUser(request.UserId)
)); ));
} }
@@ -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. // 5.
_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;
@@ -37,7 +37,7 @@ public sealed class ChatHub : Hub
public static int OnlineUsersCount => _userConnections.Count; public static int OnlineUsersCount => _userConnections.Count;
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId); public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
// userId → CallSession (one user can be in only one call at a time) // userId → CallSession (one user can be in only one call at a time)
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new(); private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
// chatId → (startTime, callType) // chatId → (startTime, callType)
@@ -53,12 +53,12 @@ public sealed class ChatHub : Hub
private readonly IUserDisplayNameProvider _userProvider; private readonly IUserDisplayNameProvider _userProvider;
public ChatHub( public ChatHub(
ISender sender, ISender sender,
IUserContext userContext, IUserContext userContext,
IChatRepository chatRepository, IChatRepository chatRepository,
IUserRepository userRepository, IUserRepository userRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
ILogger<ChatHub> logger, ILogger<ChatHub> logger,
IMemoryCache cache, IMemoryCache cache,
IUserDisplayNameProvider userProvider) IUserDisplayNameProvider userProvider)
{ {
@@ -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(),
@@ -261,7 +262,7 @@ public sealed class ChatHub : Hub
{ {
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId }); var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>()); var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
{ {
chatId = request.ChatId, chatId = request.ChatId,
@@ -276,7 +277,7 @@ public sealed class ChatHub : Hub
{ {
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId); var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
var result = await _sender.Send(command); var result = await _sender.Send(command);
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
{ {
chatId = request.ChatId, chatId = request.ChatId,
@@ -328,7 +329,7 @@ public sealed class ChatHub : Hub
public async Task FriendAccepted(FriendSignalRequest request) public async Task FriendAccepted(FriendSignalRequest request)
{ {
if (request == null || string.IsNullOrEmpty(request.FriendId)) return; if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId); _logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId }); await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
} }
@@ -350,8 +351,8 @@ public sealed class ChatHub : Hub
{ {
if (string.IsNullOrEmpty(targetUserId)) if (string.IsNullOrEmpty(targetUserId))
{ {
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId"); _logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
return; return;
} }
if (_userConnections.TryGetValue(targetUserId, out var connectionIds)) if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
@@ -398,15 +399,15 @@ public sealed class ChatHub : Hub
// Track session for history // Track session for history
Guid? chatId = null; Guid? chatId = null;
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId; if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
if (!chatId.HasValue) if (!chatId.HasValue)
{ {
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
if (Guid.TryParse(request.TargetUserId, out var targetId)) if (Guid.TryParse(request.TargetUserId, out var targetId))
{ {
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId)); var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
if (personalChat != null) chatId = personalChat.Id; if (personalChat != null) chatId = personalChat.Id;
} }
} }
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow); var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
@@ -461,10 +462,10 @@ public sealed class ChatHub : Hub
_activeSessionsByUser.TryRemove(request.TargetUserId, out _); _activeSessionsByUser.TryRemove(request.TargetUserId, out _);
if (session.ChatId.HasValue) if (session.ChatId.HasValue)
{ {
int duration = session.IsAnswered && session.AnswerTime.HasValue int duration = session.IsAnswered && session.AnswerTime.HasValue
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds ? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
: 0; : 0;
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed"); string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration); await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
} }
@@ -573,7 +574,7 @@ public sealed class ChatHub : Hub
var userInfo = new ParticipantInfo(userId, username, displayName, avatar); var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
{ {
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType); _activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
return new ConcurrentDictionary<string, ParticipantInfo>(); return new ConcurrentDictionary<string, ParticipantInfo>();
@@ -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);
}); });
+24 -10
View File
@@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot<Guid>
public Guid SenderId { get; protected set; } public Guid SenderId { get; protected set; }
public DateTime CreatedAt { get; protected set; } public DateTime CreatedAt { get; protected set; }
public long SequenceId { get; protected set; } public long SequenceId { get; protected set; }
public void SetSequenceId(long sequenceId) public void SetSequenceId(long sequenceId)
{ {
SequenceId = sequenceId; SequenceId = sequenceId;
@@ -26,10 +26,10 @@ public abstract class Message : AggregateRoot<Guid>
// ================== Опциональные метаданные (общего назначения) ================== // ================== Опциональные метаданные (общего назначения) ==================
public Guid? ReplyToId { get; protected set; } public Guid? ReplyToId { get; protected set; }
public Guid? ForwardedFromId { get; protected set; } public Guid? ForwardedFromId { get; protected set; }
// ================== Флаги ================== // ================== Флаги ==================
public MessageState State { get; protected set; } public MessageState State { get; protected set; }
// ================== Абстрактные / Виртуальные свойства ================== // ================== Абстрактные / Виртуальные свойства ==================
public abstract string Type { get; } public abstract string Type { get; }
public abstract string? Content { get; protected set; } public abstract string? Content { get; protected set; }
@@ -42,16 +42,20 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Прочитано ==================
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
// ================== Инфраструктурный конструктор EF ================== // ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
protected Message( protected Message(
Guid id, Guid id,
Guid chatId, Guid chatId,
Guid senderId, Guid senderId,
Guid? replyToId, Guid? replyToId,
Guid? forwardedFromId, Guid? forwardedFromId,
DateTime createdAt, DateTime createdAt,
bool isImported) : base(id) bool isImported) : base(id)
{ {
ChatId = chatId; ChatId = chatId;
@@ -59,7 +63,7 @@ public abstract class Message : AggregateRoot<Guid>
ReplyToId = replyToId; ReplyToId = replyToId;
ForwardedFromId = forwardedFromId; ForwardedFromId = forwardedFromId;
CreatedAt = createdAt; CreatedAt = createdAt;
if (isImported) AddState(MessageState.IsImported); if (isImported) AddState(MessageState.IsImported);
} }
@@ -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,14 +95,28 @@ public sealed class MessageRepository : IMessageRepository
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{
var builder = Builders<Message>.Filter;
var filter = builder.And(
builder.Eq(m => m.ChatId, chatId),
builder.Gt(m => m.SequenceId, sequenceId)
);
return await _messages.Find(filter)
.SortBy(m => m.SequenceId)
.Limit(limit)
.ToListAsync(cancellationToken);
}
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken) public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{ {
var builder = Builders<Message>.Filter; var builder = Builders<Message>.Filter;
// Target message // Target message
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId)); var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken); var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
// Older messages // Older messages
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId)); var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
var older = await _messages.Find(olderFilter) var older = await _messages.Find(olderFilter)
+9 -8
View File
@@ -6,7 +6,7 @@ export interface UserBasic {
displayName: string; displayName: string;
avatarUrl: string | null; avatarUrl: string | null;
// Fallbacks for compatibility // Fallbacks for compatibility
username?: string; username?: string;
avatar?: string | null; avatar?: string | null;
} }
@@ -51,11 +51,11 @@ export interface Reaction {
id: string; id: string;
emoji: string; emoji: string;
userId: string; userId: string;
user: { user: {
id: string; id: string;
username: string; username: string;
userName?: string; userName?: string;
displayName: string; displayName: string;
avatar?: string | null; avatar?: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
}; };
@@ -84,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;
@@ -95,9 +96,9 @@ export interface Message {
isDeleted?: boolean; isDeleted?: boolean;
quote?: string | null; quote?: string | null;
media?: MediaItem[]; media?: MediaItem[];
sender: { sender: {
id: string; id: string;
username: string; username: string;
userName?: string; userName?: string;
displayName: string; displayName: string;
avatar?: string | null; avatar?: string | null;
@@ -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}`;
@@ -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 () => {
@@ -121,11 +132,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
errorMsg.includes('Недействительный токен') || errorMsg.includes('Недействительный токен') ||
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,
@@ -125,9 +125,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ isLoadingMessages: true }); set({ isLoadingMessages: true });
const currentMessages = state.messages[chatId] || []; const currentMessages = state.messages[chatId] || [];
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined; const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
const fetched = await ChatApi.getMessages(chatId, cursor); const fetched = await ChatApi.getMessages(chatId, cursor);
set((state) => { set((state) => {
// Merge fetched messages with any that arrived via socket // Merge fetched messages with any that arrived via socket
const existing = reset ? [] : (state.messages[chatId] || []); const existing = reset ? [] : (state.messages[chatId] || []);
@@ -401,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) };
} }
@@ -475,16 +477,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
addChat: (chat) => { addChat: (chat) => {
set((state) => { set((state) => {
const existing = state.chats.find((c) => c.id === chat.id); const existing = state.chats.find((c) => c.id === chat.id);
const messagesFromState = state.messages[chat.id] || []; const messagesFromState = state.messages[chat.id] || [];
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []); const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
let unreadCount = chat.unreadCount || 0; let unreadCount = chat.unreadCount || 0;
if (!existing && messagesFromState.length > 0) { if (!existing && messagesFromState.length > 0) {
const userId = useAuthStore.getState().user?.id; const userId = useAuthStore.getState().user?.id;
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length; unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
} }
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount }; const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
if (existing) { if (existing) {
@@ -524,9 +526,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
const existing = state.pinnedMessages[chatId] || []; const existing = state.pinnedMessages[chatId] || [];
if (existing.some(m => m.id === message.id)) return state; if (existing.some(m => m.id === message.id)) return state;
return { return {
pinnedMessages: { pinnedMessages: {
...state.pinnedMessages, ...state.pinnedMessages,
[chatId]: [...existing, message] [chatId]: [...existing, message]
}, },
}; };
}); });
@@ -550,7 +552,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
try { try {
set({ isLoadingMessages: true }); set({ isLoadingMessages: true });
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50); const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
set((state) => ({ set((state) => ({
messages: { ...state.messages, [chatId]: fetched }, messages: { ...state.messages, [chatId]: fetched },
// Since we jumped, we assume there is more history to load above // Since we jumped, we assume there is more history to load above
@@ -58,7 +58,7 @@ export default function ChatPage() {
const [groupCallSessionId, setGroupCallSessionId] = useState(0); const [groupCallSessionId, setGroupCallSessionId] = useState(0);
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null); const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
const groupCallOpenRef = useRef(false); const groupCallOpenRef = useRef(false);
const groupCallChatIdRef = useRef(''); const groupCallChatIdRef = useRef('');
@@ -180,7 +180,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 }) => {
@@ -281,7 +286,7 @@ export default function ChatPage() {
callType: data.callType, callType: data.callType,
chatName: chat.name || 'Group', chatName: chat.name || 'Group',
}); });
// Auto-dismiss after 15 seconds if ignored // Auto-dismiss after 15 seconds if ignored
setTimeout(() => { setTimeout(() => {
setIncomingGroupCall(prev => { setIncomingGroupCall(prev => {
@@ -383,14 +388,14 @@ export default function ChatPage() {
{activeTab === 'chats' ? ( {activeTab === 'chats' ? (
<> <>
{/* Chat List (Sidebar) */} {/* Chat List (Sidebar) */}
<div <div
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`} className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
> >
<Sidebar /> <Sidebar />
</div> </div>
{/* Selected Chat View (Main Area) */} {/* Selected Chat View (Main Area) */}
<div <div
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`} className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`}
> >
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} /> <ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
@@ -398,31 +403,31 @@ export default function ChatPage() {
</> </>
) : activeTab === 'contacts' ? ( ) : activeTab === 'contacts' ? (
<div className="flex-1 flex flex-row h-full overflow-hidden"> <div className="flex-1 flex flex-row h-full overflow-hidden">
{/* Contacts Sidebar List */} {/* Contacts Sidebar List */}
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased"> <div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} /> <ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
</div> </div>
{/* Right side placeholder / Profile detail */} {/* Right side placeholder / Profile detail */}
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice"> <div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice">
<div className="flex flex-col items-center gap-6 max-w-sm text-center"> <div className="flex flex-col items-center gap-6 max-w-sm text-center">
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner"> <div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
<Users size={48} className="knot-logo-spin opacity-50" /> <Users size={48} className="knot-logo-spin opacity-50" />
</div>
<div>
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
{t('selectContactToChat')}
</p>
</div>
<button
onClick={() => setActiveTab('chats')}
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
>
{t('backToChats')}
</button>
</div> </div>
</div> <div>
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
{t('selectContactToChat')}
</p>
</div>
<button
onClick={() => setActiveTab('chats')}
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
>
{t('backToChats')}
</button>
</div>
</div>
</div> </div>
) : activeTab === 'settings' ? ( ) : activeTab === 'settings' ? (
<SettingsPage /> <SettingsPage />
@@ -432,7 +437,7 @@ export default function ChatPage() {
</div> </div>
)} )}
</main> </main>
<CallModal <CallModal
key={callSessionId} key={callSessionId}
@@ -485,9 +490,9 @@ export default function ChatPage() {
> >
<div className="relative mb-6"> <div className="relative mb-6">
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" /> <div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
<Avatar <Avatar
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null} src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
name={incomingGroupCall.chatName || '?'} name={incomingGroupCall.chatName || '?'}
size="2xl" size="2xl"
className="relative shadow-2xl" className="relative shadow-2xl"
/> />
@@ -499,9 +504,9 @@ export default function ChatPage() {
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'} {incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
</p> </p>
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5"> <p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
{t('groupCall' as any) || 'Групповой звонок'} {t('groupCall' as any) || 'Групповой звонок'}
</p> </p>
<div className="flex items-center gap-8 w-full justify-center"> <div className="flex items-center gap-8 w-full justify-center">
<button <button
onClick={() => { onClick={() => {
@@ -63,14 +63,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
: lastMessage.media?.[0]?.type === 'video' : lastMessage.media?.[0]?.type === 'video'
? t('video') ? t('video')
: t('file') : t('file')
: lastMessage.type === 'call' : lastMessage.type === 'call'
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t( ? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
lastMessage.callStatus === 'missed' ? 'missedCall' : lastMessage.callStatus === 'missed' ? 'missedCall' :
lastMessage.callStatus === 'declined' ? 'declinedCall' : lastMessage.callStatus === 'declined' ? 'declinedCall' :
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' : lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
'completedCall' 'completedCall'
)}` )}`
: lastMessage.content || '' : lastMessage.content || ''
: ''; : '';
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText); const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
@@ -78,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 })
@@ -89,25 +91,25 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
useEffect(() => { useEffect(() => {
if (!chat.isImporting || !chat.importJobId) { if (!chat.isImporting || !chat.importJobId) {
setImportStatus(null); setImportStatus(null);
return; return;
} }
const poll = async () => { const poll = async () => {
try { try {
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`); const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
setImportStatus({ processed: data.processedMessages, total: data.totalMessages }); setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
if (data.status === 'Completed' || data.status === 'Failed') { if (data.status === 'Completed' || data.status === 'Failed') {
loadChats(); loadChats();
}
} catch (e: any) {
if (e.status === 404) {
// Job might have expired or backend restarted
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
} }
} catch (e: any) {
if (e.status === 404) {
// Job might have expired or backend restarted
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
}
}; };
poll(); poll();
@@ -117,9 +119,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const handleClick = () => { const handleClick = () => {
if (chat.isImporting) { if (chat.isImporting) {
// Here we could show a progress modal, but for now just select it // Here we could show a progress modal, but for now just select it
setActiveChat(chat.id); setActiveChat(chat.id);
return; return;
} }
if ((window as any).hasUnsavedAttachments && !isActive) { if ((window as any).hasUnsavedAttachments && !isActive) {
setShowAttachmentConfirm(true); setShowAttachmentConfirm(true);
@@ -131,7 +133,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const proceedWithClick = () => { const proceedWithClick = () => {
setShowAttachmentConfirm(false); setShowAttachmentConfirm(false);
(window as any).hasUnsavedAttachments = false; (window as any).hasUnsavedAttachments = false;
if (isActive) { if (isActive) {
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } })); window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
} else { } else {
@@ -188,9 +190,8 @@ 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' }`}
}`}
> >
{/* Аватар */} {/* Аватар */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
@@ -200,7 +201,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
</div> </div>
) : ( ) : (
<div className="avatar-knot-container group-hover:scale-105 transition-transform"> <div className="avatar-knot-container group-hover:scale-105 transition-transform">
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} /> <Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
</div> </div>
)} )}
</div> </div>
@@ -225,20 +226,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
)} )}
<div className="flex flex-col gap-1 w-full min-w-0"> <div className="flex flex-col gap-1 w-full min-w-0">
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}> <p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText} {isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
{chat.isImporting && importStatus && importStatus.total > 0 && ( {chat.isImporting && importStatus && importStatus.total > 0 && (
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums"> <span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
{importStatus.processed} / {importStatus.total} {importStatus.processed} / {importStatus.total}
</span> </span>
)} )}
</p> </p>
{chat.isImporting && importStatus && importStatus.total > 0 && ( {chat.isImporting && importStatus && importStatus.total > 0 && (
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5"> <div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
<div <div
className="h-full bg-primary transition-all duration-500 ease-out" className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }} style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
/> />
</div> </div>
)} )}
</div> </div>
</div> </div>
File diff suppressed because it is too large Load Diff