18 Commits
Author SHA1 Message Date
Халимов Рустам 600e43eec5 Merge remote-tracking branch 'origin/bugfix_web' into bugfix_web 2026-04-17 00:47:47 +03:00
Халимов Рустам 7b251686b2 Исправление подгрузки данных профиля 2026-04-17 00:43:07 +03:00
max 00682b2977 e# speciallyse enter a commit message to explain why this merge is
necessary,
if it merges an updated upstream into a topic branch.
2026-04-17 00:42:48 +03:00
max e05572fc3f Исправил неработющую функцию добавления пользователя через чат в группу 2026-04-17 00:42:03 +03:00
Халимов Рустам c264b7df27 исправил ошибки компиляции TypeScript 2026-04-17 00:37:37 +03:00
Халимов Рустам 56f75ae32b Получение данных профиля 2026-04-17 00:33:45 +03:00
Халимов Рустам ca9cf27716 О себе, редактирование в профиле 2026-04-17 00:26:38 +03:00
Халимов Рустам 7225e3272e Убрал аватары и имена внутри чата личных чатов 2026-04-16 23:54:19 +03:00
Халимов Рустам 86ae06beb6 Отступы в баблах 2026-04-16 23:48:36 +03:00
Халимов Рустам 454f70f716 Вставка и перетаскивание в поле ввода 2026-04-16 23:42:54 +03:00
Халимов Рустам e700609d30 Дубликат печати, разметка 2026-04-16 23:36:12 +03:00
Халимов Рустам 88f39aa51f Change for env 2026-04-16 22:42:26 +03:00
Халимов Рустам c3dbbaa7b8 Rename all 2026-04-16 22:30:37 +03:00
Халимов Рустам 2eb4f48ca0 Test create 2026-04-16 22:28:41 +03:00
Халимов Рустам 0c1adaab6c Rename web 2026-04-16 22:26:04 +03:00
Халимов Рустам a52726d0e6 Only web 2026-04-16 22:24:41 +03:00
Халимов Рустам d812a7a40c Change services name 2026-04-16 22:21:58 +03:00
Халимов Рустам c8b4fed25a Change ports 2026-04-16 22:18:04 +03:00
44 changed files with 390 additions and 895 deletions
@@ -4,7 +4,6 @@ public interface IJwtTokenProvider
{
string GenerateAccessToken(Guid userId, string username);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
string Generate(Guid userId, string username, string displayName, string? avatar);
string Generate(Domain.UserContract user);
}
@@ -7,7 +7,5 @@ public static class AuthErrors
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
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");
}
@@ -19,12 +19,4 @@ public class UserContract
public bool IsExternal { get; set; }
public string? Domain { 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,7 +13,6 @@ public interface IMessageRepository
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>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken);
@@ -23,9 +23,6 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
@@ -56,14 +53,4 @@ public abstract class Message : AggregateRoot<Guid>
if (!_deletedFor.Exists(x => x.UserId == 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);
}
+1 -2
View File
@@ -13,8 +13,7 @@
"Secret": "knot_super_secret_key_1234567890_knot",
"Issuer": "Knot",
"Audience": "KnotUsers",
"ExpiryInMinutes": 1440,
"RefreshExpiryInDays": 30
"ExpiryInMinutes": 1440
},
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
}
@@ -5,8 +5,5 @@ namespace Knot.Modules.Auth.Application.Abstractions;
public interface IJwtTokenProvider
{
string Generate(User user);
string Generate(Guid userId, string username, string displayName, string? avatar);
string GenerateRefreshToken();
DateTime GetRefreshTokenExpiry();
}
@@ -1,6 +1,4 @@
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;
@@ -11,12 +9,10 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
public GetMeQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
@@ -27,18 +23,9 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
}
// 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
{
AccessToken = newAccessToken,
AccessToken = string.Empty,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
@@ -5,6 +5,9 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Login;
/// <summary>
/// . AuthResponseDto.
/// </summary>
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
@@ -28,21 +31,14 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
}
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
string 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
{
AccessToken = token,
RefreshToken = refreshToken,
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
});
}
}
@@ -1,7 +0,0 @@
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
@@ -1,65 +0,0 @@
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Auth.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
private readonly IJwtTokenProvider _tokenProvider;
public RefreshTokenCommandHandler(
IUserRepository userRepository,
IJwtTokenProvider tokenProvider)
{
_userRepository = userRepository;
_tokenProvider = tokenProvider;
}
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
}
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
if (user == null)
{
return Result.Failure<AuthResponseDto>(
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
}
// Check if refresh token has expired
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
{
// Clear expired refresh token
user.SetRefreshToken(null, null);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Failure<AuthResponseDto>(
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
}
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
await _userRepository.UpdateAsync(user, cancellationToken);
return Result.Success(new AuthResponseDto
{
AccessToken = newAccessToken,
RefreshToken = newRefreshToken,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName,
Avatar = user.Avatar
});
}
}
@@ -1,4 +1,5 @@
using BCrypt.Net;
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Contracts.Settings.Application.Abstractions;
@@ -8,6 +9,9 @@ using Knot.Shared.Kernel;
namespace Knot.Modules.Auth.Application.Users.Register;
/// <summary>
/// .
/// </summary>
public sealed record RegisterUserCommand(
string Username,
string Password,
@@ -15,6 +19,9 @@ public sealed record RegisterUserCommand(
string? Email,
string? Bio) : ICommand<AuthResponseDto>;
/// <summary>
/// .
/// </summary>
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
{
private readonly IUserRepository _userRepository;
@@ -41,13 +48,16 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
}
// 1. username
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
{
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
}
// 2.
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
// 3.
var user = User.Create(
request.Username,
passwordHash,
@@ -55,33 +65,21 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
request.Email,
request.Bio);
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
repoImpl?.Add(user);
// 4. - Domain User
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
repoWithDomainUserAdd?.Add(user);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// 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);
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
return Result.Success(new AuthResponseDto
{
AccessToken = token,
RefreshToken = refreshToken,
UserId = userContract.Id,
Username = userContract.Username,
DisplayName = userContract.DisplayName
RefreshToken = string.Empty,
UserId = user.Id,
Username = user.Username,
DisplayName = user.DisplayName
});
}
}
+2 -8
View File
@@ -26,7 +26,6 @@ public sealed class User : AggregateRoot<Guid>
public bool IsBanned { get; private set; }
public string? PhoneNumber { get; private set; }
public string? RefreshToken { get; private set; }
public DateTime? RefreshTokenExpiry { get; private set; }
public DateTime? BannedUntil { get; private set; }
public void Ban() {
@@ -49,10 +48,9 @@ public sealed class User : AggregateRoot<Guid>
PhoneNumber = phoneNumber;
}
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
public void SetRefreshToken(string? refreshToken)
{
RefreshToken = refreshToken;
RefreshTokenExpiry = expiry;
}
public void SetBannedUntil(DateTime? bannedUntil)
@@ -81,8 +79,6 @@ public sealed class User : AggregateRoot<Guid>
BannedUntil = contract.BannedUntil;
SetOnline(contract.IsOnline, contract.LastSeen);
UserDomain = contract.Domain;
RefreshToken = contract.RefreshToken;
RefreshTokenExpiry = contract.RefreshTokenExpiry;
}
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
@@ -185,9 +181,7 @@ public sealed class User : AggregateRoot<Guid>
IsOnline = IsOnline,
IsExternal = IsExternal,
Domain = _domain,
LastSeen = LastSeen,
RefreshToken = RefreshToken,
RefreshTokenExpiry = RefreshTokenExpiry
LastSeen = LastSeen
};
}
}
@@ -50,12 +50,6 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
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)
{
var claims = new Claim[]
@@ -1,100 +0,0 @@
using System;
using Knot.Modules.Auth.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AuthDbContext))]
[Migration("20270419220000_AddRefreshTokenExpiry")]
partial class AddRefreshTokenExpiry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
.HasAnnotation("Relational:DefaultSchema", "identity");
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime?>("BannedUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("Bio")
.HasColumnType("text");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Domain")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<bool>("HideStatus")
.HasColumnType("boolean");
b.Property<bool>("HideStoryViews")
.HasColumnType("boolean");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsExternal")
.HasColumnType("boolean");
b.Property<bool>("IsOnline")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSeen")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<string>("RefreshToken")
.HasColumnType("text");
b.Property<DateTime?>("RefreshTokenExpiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users", "identity");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Knot.Modules.Auth.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokenExpiry : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users",
type: "timestamp with time zone",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RefreshTokenExpiry",
schema: "identity",
table: "Users");
}
}
}
@@ -1,13 +1,12 @@
using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.RefreshToken;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
namespace Knot.Modules.Auth.Presentation.Endpoints;
@@ -29,12 +28,6 @@ public static class AuthEndpoints
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) =>
{
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
@@ -1,15 +1,13 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR;
using Microsoft.AspNetCore.SignalR;
using System.Linq;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel.Storage;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -21,21 +19,17 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _uow;
private readonly IHubContext<ChatHub> _hubContext;
public LeaveOrDeleteChatCommandHandler(
IChatRepository chatRepository,
IMessageRepository messageRepository,
IFileStorageService fileStorage,
IChatsUnitOfWork uow,
IHubContext<ChatHub> hubContext)
IChatsUnitOfWork uow)
{
_chatRepository = chatRepository;
_messageRepository = messageRepository;
_fileStorage = fileStorage;
_uow = uow;
_hubContext = hubContext;
}
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
@@ -64,14 +58,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
// DELETE ALL MESSAGES AND FILES FIRST
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
_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);
@@ -82,7 +68,6 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
{
try
{
// Get all messages directly from Mongo (not paged)
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
@@ -32,8 +32,7 @@ public record MessageDetailDto(
bool? PollIsMultipleChoice = null,
bool? PollIsAnonymous = null,
bool? PollIsClosed = null,
List<Guid>? UserVotedOptionIds = null,
bool IsDeletedForUser = false
List<Guid>? UserVotedOptionIds = null
);
public record ReplyToMessageDto(
@@ -42,16 +42,10 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
if (request.DeleteForAll)
{
// Only message sender can delete for everyone
if (message.SenderId == request.UserId)
{
message.Delete();
}
else
{
// If not the sender, just delete for current user
message.DeleteForUser(request.UserId);
}
}
else
{
@@ -61,13 +55,24 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
await _messageRepository.UpdateAsync(message, cancellationToken);
}
// Notify all clients in the chat about the deletion
if (request.DeleteForAll)
{
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
{
chatId = request.ChatId,
messageIds = request.MessageIds,
deleteForAll = request.DeleteForAll
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();
}
@@ -3,17 +3,17 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
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.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{
@@ -41,12 +41,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
List<Message> messages;
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
if (request.AfterSequenceId.HasValue)
{
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
}
else if (request.Pivot.HasValue)
if (request.Pivot.HasValue)
{
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
}
@@ -120,7 +115,6 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
senders.TryGetValue(message.SenderId, out var sender);
reactionsByMessage.TryGetValue(message.Id, out var reactions);
Message? replyMsg = null;
if (message.ReplyToId.HasValue)
{
@@ -147,7 +141,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
) : null,
(message as TextMessage)?.Quote,
message is TextMessage tm ? tm.Quote : null,
message.IsEdited,
message.IsDeleted,
message.CreatedAt,
@@ -159,23 +153,20 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(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>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
reactions?.Select(r =>
{
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
reactions?.Select(r => {
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);
}).ToList() ?? new List<MessageReactionDto>(),
(message as CallMessage)?.CallType,
(message as CallMessage)?.CallStatus,
(message as CallMessage)?.Duration,
(message as PollMessage)?.Options.Select(o =>
{
(message as PollMessage)?.Options.Select(o => {
var pm = (PollMessage)message;
var voters = pm.IsAnonymous == false
? pm.Votes
.Where(v => v.OptionId == o.Id)
.Select(v =>
{
.Select(v => {
senders.TryGetValue(v.UserId, out var vu);
return vu != null
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
@@ -188,8 +179,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(message as PollMessage)?.IsMultipleChoice,
(message as PollMessage)?.IsAnonymous,
(message as PollMessage)?.IsClosed,
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
message.IsDeletedForUser(request.UserId)
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
));
}
@@ -1,8 +1,7 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -12,13 +11,11 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
{
private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMessageRepository _messageRepository;
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
{
_chatRepository = chatRepository;
_unitOfWork = unitOfWork;
_messageRepository = messageRepository;
}
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
@@ -31,23 +28,6 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
// Обновляем 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);
return Result.Success();
@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using MediatR;
@@ -41,8 +41,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
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 mediaMessage = message as MediaMessage;
var storyMessage = message as StoryMessage;
@@ -67,7 +66,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
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>(),
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
new List<ReadByDto>()
);
}).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.Domain;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -192,7 +192,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id);
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
// 5.
_messageRepository.Add(message);
@@ -1,26 +1,26 @@
using System.Collections.Concurrent;
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 Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -158,7 +158,6 @@ public sealed class ChatHub : Hub
await _sender.Send(command);
}
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{
ChatId = request.ChatId.ToString(),
@@ -19,17 +19,9 @@ public static class MessagesEndpoints
{
var group = app.MapGroup("api/messages").RequireAuthorization();
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) =>
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
});
@@ -42,10 +42,6 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Прочитано ==================
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
// ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { }
@@ -93,16 +89,6 @@ public abstract class Message : AggregateRoot<Guid>
_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
}).ToList() ?? (object)Array.Empty<object>(),
sender = senderObj,
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
readBy = new List<object>(),
storyId = (message as StoryMessage)?.StoryId,
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
storyMediaType = (message as StoryMessage)?.StoryMediaType,
@@ -95,20 +95,6 @@ public sealed class MessageRepository : IMessageRepository
.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)
{
var builder = Builders<Message>.Filter;
-1
View File
@@ -84,7 +84,6 @@ export interface Message {
storyMediaType?: string | null;
isEdited: boolean;
isDeleted: boolean;
isDeletedForUser?: boolean;
scheduledAt?: string | null;
createdAt: string;
updatedAt?: string;
@@ -3,74 +3,11 @@ const API_BASE = '/api';
export class HttpClient {
private token: string | null = null;
private isRefreshing = false;
private refreshSubscribers: Array<(token: string) => void> = [];
setToken(token: string | null) {
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> {
const { timeout = 30_000, ...fetchOptions } = options;
const controller = new AbortController();
@@ -104,36 +41,6 @@ export class HttpClient {
}
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) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`;
+2 -4
View File
@@ -194,8 +194,7 @@ const translations = {
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
clearHistory: 'Очистить историю',
clearHistoryConfirm: 'Очистить историю?',
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
deleteGroupChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
pinChat: 'Закрепить чат',
unpinChat: 'Открепить чат',
chatCleared: 'Очищено',
@@ -575,8 +574,7 @@ const translations = {
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
clearHistory: 'Clear history',
clearHistoryConfirm: 'Clear history?',
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.',
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
pinChat: 'Pin chat',
unpinChat: 'Unpin chat',
chatCleared: 'Chat cleared',
+8
View File
@@ -25,6 +25,14 @@ export function normalizeUser(user: any): any {
result.avatar = result.avatarUrl;
}
// 3. Приведение bio (Settings -> bio, Profiles -> about)
if (!result.bio && result.about) {
result.bio = result.about;
}
if (!result.about && result.bio) {
result.about = result.bio;
}
return result;
}
@@ -5,7 +5,6 @@ import type { User } from '../../../core/domain/types';
interface AuthState {
token: string | null;
refreshToken: string | null;
user: User | null;
isLoading: boolean;
error: string | null;
@@ -24,9 +23,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (t) AuthApi.setToken(t);
return t;
})(),
refreshToken: (() => {
return localStorage.getItem('knot_refresh_token');
})(),
user: null,
isLoading: true,
error: null,
@@ -43,14 +39,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (username, password) => {
try {
set({ error: null, isLoading: true });
const { token, refreshToken, user } = await AuthApi.login(username, password);
const { token, user } = await AuthApi.login(username, password);
localStorage.setItem('knot_token', token);
if (refreshToken) {
localStorage.setItem('knot_refresh_token', refreshToken);
}
AuthApi.setToken(token);
connectSocket(token);
set({ token, refreshToken, user, isLoading: false });
set({ token, user, isLoading: false });
await get().fetchConfig();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
@@ -62,14 +55,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
register: async (username, displayName, password, bio) => {
try {
set({ error: null, isLoading: true });
const { token, refreshToken, user } = await AuthApi.register(username, displayName, password, bio);
const { token, user } = await AuthApi.register(username, displayName, password, bio);
localStorage.setItem('knot_token', token);
if (refreshToken) {
localStorage.setItem('knot_refresh_token', refreshToken);
}
AuthApi.setToken(token);
connectSocket(token);
set({ token, refreshToken, user, isLoading: false });
set({ token, user, isLoading: false });
await get().fetchConfig();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
@@ -80,10 +70,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logout: () => {
localStorage.removeItem('knot_token');
localStorage.removeItem('knot_refresh_token');
AuthApi.setToken(null);
disconnectSocket();
set({ token: null, refreshToken: null, user: null });
set({ token: null, user: null });
},
checkAuth: async () => {
@@ -133,8 +122,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
errorMsg.includes('Требуется авторизация')
) {
localStorage.removeItem('knot_token');
localStorage.removeItem('knot_refresh_token');
set({ token: null, refreshToken: null, user: null, isLoading: false });
set({ token: null, user: null, isLoading: false });
} else {
// Keep the token but stop loading if we're just offline/network error/500
set({ isLoading: false });
@@ -3,69 +3,58 @@ import type { User } from '../../../core/domain/types';
export class AuthApi {
static async login(username: string, password: string) {
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
const response = await httpClient.request<any>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
return {
token: response.accessToken,
refreshToken: response.refreshToken,
user: {
id: response.userId,
username: response.username,
...response,
id: response.userId || (response as any).id,
username: response.username || (response as any).userName,
displayName: response.displayName,
avatar: null
} as User
avatar: response.avatar || null
} as unknown as User
};
}
static async register(username: string, displayName: string, password: string, bio?: string) {
const response = await httpClient.request<{ accessToken: string; refreshToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
const response = await httpClient.request<any>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, displayName, password, bio }),
});
return {
token: response.accessToken,
refreshToken: response.refreshToken,
user: {
id: response.userId,
username: response.username,
...response,
id: response.userId || (response as any).id,
username: response.username || (response as any).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: {
id: response.userId,
username: response.username,
displayName: response.displayName,
avatar: null
} as User
avatar: response.avatar || null
} as unknown as User
};
}
static async getMe() {
const response = await httpClient.request<{ userId: string; username: string; displayName: string; avatar: string | null; accessToken?: string }>('/auth/me');
const authRes = await httpClient.request<any>('/auth/me');
const userId = authRes.userId || authRes.id;
// Пытаемся получить расширенный профиль (био, дата рождения)
const profileRes = await httpClient.request<any>(`/profiles/${userId}`).catch(() => ({}));
return {
user: {
id: response.userId,
username: response.username,
displayName: response.displayName,
avatar: response.avatar
} as User,
token: response.accessToken
...authRes,
...profileRes,
id: userId,
username: authRes.username || authRes.userName || profileRes.userName,
displayName: authRes.displayName || profileRes.displayName,
avatar: authRes.avatar || authRes.avatarUrl || profileRes.avatarUrl
} as unknown as User,
token: authRes.accessToken
};
}
@@ -401,7 +401,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (m.sequenceId <= lastReadSequenceId) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m;
// Увеличиваем счётчик только если текущий пользователь читает чужие сообщения
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] };
}
@@ -413,7 +412,6 @@ export const useChatStore = create<ChatState>((set, get) => ({
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg);
// Уменьшаем unreadCount только если текущий пользователь прочитал сообщения
if (userId === currentUserId) {
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
}
@@ -65,8 +65,6 @@ export default function ChatPage() {
const [activeTab, setActiveTab] = useState('chats');
const { t } = useLang();
const activeChat = useChatStore((state) => state.activeChat);
useEffect(() => {
groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId;
@@ -182,12 +180,7 @@ export default function ChatPage() {
});
socket.on('messages_read', (data: any) => {
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);
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
});
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
@@ -337,16 +330,6 @@ export default function ChatPage() {
};
}, [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') => {
setCallTarget(targetUser);
setCallType(type);
@@ -383,6 +366,8 @@ export default function ChatPage() {
setGroupCallOpen(false);
};
const activeChat = useChatStore((state) => state.activeChat);
return (
<motion.div
initial={{ opacity: 0 }}
@@ -78,9 +78,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
// Галочки прочтения
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
// Для чужих сообщений: не показываем галочки
const isRead = !chat.isImporting && isMine && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const timeStr = !chat.isImporting && lastMessage
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
@@ -190,7 +188,8 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
}`}
>
{/* Аватар */}
@@ -187,31 +187,12 @@ 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
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
// 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) {
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) {
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
} else {
// Update if the first unread message was read (msgId no longer exists in unread list)
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
if (sessionUnreadRef.current.msgId && !firstUnreadMsg) {
// All messages are now read
sessionUnreadRef.current.msgId = null;
} else if (firstUnreadMsg && sessionUnreadRef.current.msgId !== firstUnreadMsg.id) {
// First unread changed (some messages were read)
sessionUnreadRef.current.msgId = firstUnreadMsg.id;
}
}
}, [activeChat, chatMessages, user?.id, isLoadingMessages]);
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
const initialScrollChatId = useRef<string | null>(null);
@@ -592,7 +573,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{
root: scrollContainerRef.current,
threshold: 0.1,
rootMargin: '0px',
}
);
@@ -604,27 +584,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-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);
}
}
});
};
@@ -76,6 +76,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const fileInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const getSearchedUserId = (u: UserPresence & { userId?: string }) => u.id || u.userId || '';
// Keep local state in sync with chat prop
useEffect(() => {
@@ -95,7 +96,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const results = await UserApi.searchUsers(searchQuery);
// Filter out users already in the group
const memberIds = new Set(chat.members.map((m) => m.user.id));
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
setSearchResults(results.filter((u) => {
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
return !!candidateId && !memberIds.has(candidateId);
}));
} catch (e) {
console.error(e);
} finally {
@@ -565,10 +569,13 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
<Loader2 size={16} className="text-zinc-500 animate-spin" />
</div>
)}
{searchResults.map((u) => (
{searchResults.map((u) => {
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
if (!candidateId) return null;
return (
<button
key={u.id}
onClick={() => handleAddMember(u.id)}
key={candidateId}
onClick={() => handleAddMember(candidateId)}
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
>
<Avatar
@@ -583,7 +590,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
</div>
<UserPlus size={14} className="text-knot-400 flex-shrink-0" />
</button>
))}
);
})}
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
)}
@@ -25,13 +25,14 @@ import {
PhoneIncoming,
PhoneOutgoing,
BarChart2,
Music,
} from 'lucide-react';
import { useAuthStore } from '../../../auth/application/authStore';
import { useChatStore } from '../../application/chatStore';
import { getSocket } from '../../../../core/infrastructure/socket';
import { useLang } from '../../../../core/infrastructure/i18n';
import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '../../../../core/utils/utils';
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
import { AUDIO_EXTENSIONS, type Message, type MediaItem, type Reaction, type ChatMember } from '../../../../core/domain/types';
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
import LinkPreview from './LinkPreview';
import Avatar from '../../../../core/presentation/components/ui/Avatar';
@@ -355,11 +356,13 @@ function MessageBubble({
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 hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
const hasAudio = !hasVoice && (message.type === 'audio' || media.some(isAudioFile));
const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m));
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && !isMediaGif(m));
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && !isMediaGif(m));
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {};
(message.reactions || []).forEach((r) => {
@@ -610,7 +613,7 @@ function MessageBubble({
return (
<div className={`
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content || hasVoice || hasAudio || hasFile || message.type === 'poll' ? 'mb-3' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
${isSingleGif ? 'max-w-[260px]' : ''}
overflow-hidden relative rounded-[1.25rem]
`}>
@@ -706,7 +709,7 @@ function MessageBubble({
{/* Голосовое - Optimized Kinetic Layout */}
{hasVoice && (
<div className="flex items-center gap-3 min-w-[200px] py-0.5">
<div className={`flex items-center gap-3 min-w-[200px] py-0.5 ${hasImage || hasVideo || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
<audio
ref={audioRef}
src={media.find((m) => m.type === 'voice')?.url}
@@ -765,7 +768,7 @@ function MessageBubble({
{/* Аудио (mp3 файлы) */}
{hasAudio && (() => {
const audioMedia = media.find((m) => m.type === 'audio');
const audioMedia = media.find(isAudioFile);
const formatSize = (bytes?: number | null) => {
if (!bytes) return "";
if (bytes < 1024) return bytes + " B";
@@ -775,7 +778,7 @@ function MessageBubble({
};
return (
<div className="min-w-[220px]">
<div className={`min-w-[220px] ${hasImage || hasVideo || hasVoice || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2 min-w-0">
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
@@ -855,7 +858,7 @@ function MessageBubble({
{/* Файлы */}
{hasFile &&
media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif')
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && m.type !== 'gif')
.map((m) => {
const formatSize = (bytes?: number | null) => {
if (!bytes) return "";
@@ -872,7 +875,7 @@ function MessageBubble({
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
} transition-all mb-1 group/file`}
} transition-all mb-1 group/file ${hasImage || hasVideo || hasVoice || hasAudio || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}
>
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
} group-hover/file:scale-110 transition-transform`}>
@@ -1031,7 +1034,7 @@ function MessageBubble({
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
return (
<div className="flex items-end gap-2 text-sm w-full">
<div className={`flex items-end gap-2 text-sm w-full ${hasImage || hasVideo || hasVoice || hasAudio || hasFile || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
<div className="flex-1 min-w-0 w-full">
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
{renderFormattedText(message.content)}
@@ -14,9 +14,13 @@ export class UserApi {
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) {
// Конечная точка в новом бэкенде: PUT /api/profiles/profile
const payload = {
...data,
about: data.bio // Дублируем для совместимости
};
return httpClient.request<User>('/profiles/profile', {
method: 'PUT',
body: JSON.stringify(data),
body: JSON.stringify(payload),
});
}
@@ -263,7 +263,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{/* About Section */}
<div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]">
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div>
<p className="text-base text-white/80 leading-relaxed font-medium">{user.about || t('noBio')}</p>
<p className="text-base text-white/80 leading-relaxed font-medium">{user.bio || t('noBio')}</p>
</div>
{/* Tabs Nav */}
+6 -11
View File
@@ -1,7 +1,6 @@
services:
db:
image: postgres:15-alpine
container_name: knot-db
restart: always
environment:
- POSTGRES_USER
@@ -10,20 +9,19 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
- "${DB_PORT:-5432}:5432"
server:
build:
context: ../backend
dockerfile: Dockerfile
container_name: knot-server
restart: always
depends_on:
- db
- minio
- mongo
ports:
- "5059:8080"
- "${SERVER_PORT:-5059}:8080"
environment:
- DATABASE_URL
- MONGO_CONNECTION
@@ -41,23 +39,21 @@ services:
minio:
image: minio/minio
container_name: knot-minio
restart: always
environment:
- MINIO_ROOT_USER
- MINIO_ROOT_PASSWORD
ports:
- "9000:9000"
- "9001:9001"
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
mongo:
image: mongo:6-jammy
container_name: knot-mongo
restart: always
ports:
- "27017:27017"
- "${MONGO_PORT:-27017}:27017"
volumes:
- mongo_data:/data/db
@@ -65,10 +61,9 @@ services:
build:
context: ..
dockerfile: client-web/Dockerfile
container_name: knot-web
restart: always
ports:
- "9090:80"
- "${WEB_PORT:-9090}:80"
depends_on:
- server