Архитектура
This commit is contained in:
@@ -24,7 +24,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
|
|||||||
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
|
public CleanRunCommandHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
|
||||||
{
|
{
|
||||||
_chatsDbContext = chatsDbContext;
|
_chatsDbContext = chatsDbContext;
|
||||||
_messages = mongoDb.GetCollection<Message>("Messages");
|
_messages = mongoDb.GetCollection<Message>("messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
|
public async Task<Result<MessageResponse>> Handle(CleanRunCommand request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
|
|||||||
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
|
public CleanDryRunQueryHandler(ChatsDbContext chatsDbContext, IMongoDatabase mongoDb)
|
||||||
{
|
{
|
||||||
_chatsDbContext = chatsDbContext;
|
_chatsDbContext = chatsDbContext;
|
||||||
_messages = mongoDb.GetCollection<Message>("Messages");
|
_messages = mongoDb.GetCollection<Message>("messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
|
public async Task<Result<CleanupDryRunResultDto>> Handle(CleanDryRunQuery request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ public sealed class FilesController : ControllerBase
|
|||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> DownloadFile(string id)
|
public async Task<IActionResult> DownloadFile(string id, [FromQuery] bool download = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _fileStorage.DownloadFileAsync(id);
|
var result = await _fileStorage.DownloadFileAsync(id);
|
||||||
// Обратите внимание, что мы возвращаем поток с автоматическим освобождением памяти.
|
if (download && !string.IsNullOrEmpty(result.FileName))
|
||||||
return File(result.Stream, result.ContentType, result.FileName);
|
{
|
||||||
|
return File(result.Stream, result.ContentType, result.FileName, enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(result.Stream, result.ContentType, enableRangeProcessing: true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IUserDisplayNameProvider _userProvider;
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
|
|
||||||
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
|
public GetChatByIdQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository, IMessageReactionRepository reactionRepository)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_userProvider = userProvider;
|
_userProvider = userProvider;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
|
_reactionRepository = reactionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<ChatDto?>> Handle(GetChatByIdQuery request, CancellationToken cancellationToken)
|
public async Task<Result<ChatDto?>> Handle(GetChatByIdQuery request, CancellationToken cancellationToken)
|
||||||
@@ -46,10 +48,14 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
||||||
|
var latestReactions = latestMessage != null
|
||||||
|
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
|
||||||
|
: new List<MessageReaction>();
|
||||||
|
|
||||||
if (latestMessage != null)
|
if (latestMessage != null)
|
||||||
{
|
{
|
||||||
userIdsToFetch.Add(latestMessage.SenderId);
|
userIdsToFetch.Add(latestMessage.SenderId);
|
||||||
foreach (var reaction in latestMessage.Reactions)
|
foreach (var reaction in latestReactions)
|
||||||
{
|
{
|
||||||
userIdsToFetch.Add(reaction.UserId);
|
userIdsToFetch.Add(reaction.UserId);
|
||||||
}
|
}
|
||||||
@@ -83,7 +89,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||||
|
|
||||||
var reactionsWithUser = new List<ReactionDto>();
|
var reactionsWithUser = new List<ReactionDto>();
|
||||||
foreach (var reaction in latestMessage.Reactions)
|
foreach (var reaction in latestReactions)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||||
reactionsWithUser.Add(new ReactionDto(
|
reactionsWithUser.Add(new ReactionDto(
|
||||||
@@ -96,6 +102,11 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var readByList = chat.Members
|
||||||
|
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId)
|
||||||
|
.Select(m => new ReadByDto(m.UserId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
messagesList.Add(new ChatMessageDto(
|
messagesList.Add(new ChatMessageDto(
|
||||||
latestMessage.Id,
|
latestMessage.Id,
|
||||||
latestMessage.ChatId,
|
latestMessage.ChatId,
|
||||||
@@ -110,6 +121,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
latestMessage.IsEdited,
|
latestMessage.IsEdited,
|
||||||
latestMessage.IsDeleted,
|
latestMessage.IsDeleted,
|
||||||
latestMessage.CreatedAt,
|
latestMessage.CreatedAt,
|
||||||
|
latestMessage.SequenceId,
|
||||||
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||||
senderObj != null ? new MessageSenderDto(
|
senderObj != null ? new MessageSenderDto(
|
||||||
senderObj.Id,
|
senderObj.Id,
|
||||||
@@ -118,10 +130,13 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
senderObj.Avatar
|
senderObj.Avatar
|
||||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||||
reactionsWithUser,
|
reactionsWithUser,
|
||||||
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
readByList
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentMember = chat.Members.First(m => m.UserId == request.UserId);
|
||||||
|
var unreadCount = (int)Math.Max(0, chat.LastMessageSequenceId - currentMember.LastReadSequenceId);
|
||||||
|
|
||||||
var dto = new ChatDto(
|
var dto = new ChatDto(
|
||||||
chat.Id,
|
chat.Id,
|
||||||
chat.Type.ToString().ToLowerInvariant(),
|
chat.Type.ToString().ToLowerInvariant(),
|
||||||
@@ -131,7 +146,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
chat.CreatedAt,
|
chat.CreatedAt,
|
||||||
members,
|
members,
|
||||||
messagesList,
|
messagesList,
|
||||||
0
|
unreadCount
|
||||||
);
|
);
|
||||||
|
|
||||||
return Result.Success<ChatDto?>(dto);
|
return Result.Success<ChatDto?>(dto);
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IUserDisplayNameProvider _userProvider;
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
|
|
||||||
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository)
|
public GetChatsQueryHandler(IChatRepository chatRepository, IUserDisplayNameProvider userProvider, IMessageRepository messageRepository, IMessageReactionRepository reactionRepository)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_userProvider = userProvider;
|
_userProvider = userProvider;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
|
_reactionRepository = reactionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<ChatDto>>> Handle(GetChatsQuery request, CancellationToken cancellationToken)
|
||||||
@@ -35,6 +37,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
foreach (var chat in userChats)
|
foreach (var chat in userChats)
|
||||||
{
|
{
|
||||||
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
var latestMessage = await _messageRepository.GetLatestChatMessageAsync(chat.Id, cancellationToken);
|
||||||
|
var latestReactions = latestMessage != null
|
||||||
|
? await _reactionRepository.GetReactionsForMessageAsync(latestMessage.Id, cancellationToken)
|
||||||
|
: new List<MessageReaction>();
|
||||||
|
|
||||||
var userIdsToFetch = new HashSet<Guid>();
|
var userIdsToFetch = new HashSet<Guid>();
|
||||||
|
|
||||||
foreach (var member in chat.Members)
|
foreach (var member in chat.Members)
|
||||||
@@ -45,7 +51,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
if (latestMessage != null)
|
if (latestMessage != null)
|
||||||
{
|
{
|
||||||
userIdsToFetch.Add(latestMessage.SenderId);
|
userIdsToFetch.Add(latestMessage.SenderId);
|
||||||
foreach (var r in latestMessage.Reactions)
|
foreach (var r in latestReactions)
|
||||||
{
|
{
|
||||||
userIdsToFetch.Add(r.UserId);
|
userIdsToFetch.Add(r.UserId);
|
||||||
}
|
}
|
||||||
@@ -80,7 +86,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||||
|
|
||||||
var reactionsWithUser = new List<ReactionDto>();
|
var reactionsWithUser = new List<ReactionDto>();
|
||||||
foreach (var reaction in latestMessage.Reactions)
|
foreach (var reaction in latestReactions)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||||
reactionsWithUser.Add(new ReactionDto(
|
reactionsWithUser.Add(new ReactionDto(
|
||||||
@@ -107,6 +113,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
latestMessage.IsEdited,
|
latestMessage.IsEdited,
|
||||||
latestMessage.IsDeleted,
|
latestMessage.IsDeleted,
|
||||||
latestMessage.CreatedAt,
|
latestMessage.CreatedAt,
|
||||||
|
latestMessage.SequenceId,
|
||||||
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
latestMessage.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||||
senderObj != null ? new MessageSenderDto(
|
senderObj != null ? new MessageSenderDto(
|
||||||
senderObj.Id,
|
senderObj.Id,
|
||||||
@@ -115,11 +122,12 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
senderObj.Avatar
|
senderObj.Avatar
|
||||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||||
reactionsWithUser,
|
reactionsWithUser,
|
||||||
latestMessage.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
var unreadCount = await _messageRepository.GetUnreadCountAsync(chat.Id, request.UserId, cancellationToken);
|
var currentMember = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
|
||||||
|
var unreadCount = currentMember != null ? (int)Math.Max(0, chat.LastMessageSequenceId - currentMember.LastReadSequenceId) : 0;
|
||||||
|
|
||||||
dtos.Add(new ChatDto(
|
dtos.Add(new ChatDto(
|
||||||
chat.Id,
|
chat.Id,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public record ChatMessageDto(
|
|||||||
bool IsEdited,
|
bool IsEdited,
|
||||||
bool IsDeleted,
|
bool IsDeleted,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
|
long SequenceId,
|
||||||
List<MediaDto> Media,
|
List<MediaDto> Media,
|
||||||
MessageSenderDto Sender,
|
MessageSenderDto Sender,
|
||||||
List<ReactionDto> Reactions,
|
List<ReactionDto> Reactions,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public record MessageDetailDto(
|
|||||||
bool IsEdited,
|
bool IsEdited,
|
||||||
bool IsDeleted,
|
bool IsDeleted,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
|
long SequenceId,
|
||||||
Guid? ForwardedFromId,
|
Guid? ForwardedFromId,
|
||||||
MessageSenderDto? ForwardedFrom,
|
MessageSenderDto? ForwardedFrom,
|
||||||
Guid? StoryId,
|
Guid? StoryId,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public record SearchMessageDto(
|
|||||||
bool IsEdited,
|
bool IsEdited,
|
||||||
bool IsDeleted,
|
bool IsDeleted,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
|
long SequenceId,
|
||||||
Guid? ForwardedFromId,
|
Guid? ForwardedFromId,
|
||||||
MessageSenderDto? ForwardedFrom,
|
MessageSenderDto? ForwardedFrom,
|
||||||
Guid? StoryId,
|
Guid? StoryId,
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly IUserDisplayNameProvider _userProvider;
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
|
|
||||||
public GetMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository)
|
public GetMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IChatRepository chatRepository, IMessageReactionRepository reactionRepository)
|
||||||
{
|
{
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_userProvider = userProvider;
|
_userProvider = userProvider;
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
|
_reactionRepository = reactionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<MessageDetailDto>>> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<MessageDetailDto>>> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
|
||||||
@@ -70,6 +72,10 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
var senders = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||||
|
|
||||||
|
var messageIds = filteredMessages.Select(m => m.Id).ToList();
|
||||||
|
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
||||||
|
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
@@ -95,7 +101,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var reactionsWithUser = new List<MessageReactionDto>();
|
var reactionsWithUser = new List<MessageReactionDto>();
|
||||||
foreach (var reaction in message.Reactions)
|
var messageReactions = reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr : new List<MessageReaction>();
|
||||||
|
foreach (var reaction in messageReactions)
|
||||||
{
|
{
|
||||||
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
|
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
|
||||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||||
@@ -121,6 +128,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.IsDeleted,
|
message.IsDeleted,
|
||||||
message.CreatedAt,
|
message.CreatedAt,
|
||||||
|
message.SequenceId,
|
||||||
message.ForwardedFromId,
|
message.ForwardedFromId,
|
||||||
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
|
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
|
||||||
message.StoryId,
|
message.StoryId,
|
||||||
@@ -128,7 +136,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
message.StoryMediaType,
|
message.StoryMediaType,
|
||||||
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
|
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
|
||||||
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList(),
|
chat.Members.Where(m => m.LastReadSequenceId >= message.SequenceId && m.UserId != message.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
|
||||||
reactionsWithUser
|
reactionsWithUser
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
|||||||
return mediaType != "image" && mediaType != "video" && mediaType != "link";
|
return mediaType != "image" && mediaType != "video" && mediaType != "link";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filterType == "media")
|
||||||
|
{
|
||||||
|
return mediaType == "image" || mediaType == "video";
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,20 @@ public sealed record AddReactionCommand(
|
|||||||
|
|
||||||
public sealed class AddReactionCommandHandler : ICommandHandler<AddReactionCommand>
|
public sealed class AddReactionCommandHandler : ICommandHandler<AddReactionCommand>
|
||||||
{
|
{
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
private readonly IUserDisplayNameProvider _displayNameProvider;
|
private readonly IUserDisplayNameProvider _displayNameProvider;
|
||||||
private readonly ILogger<AddReactionCommandHandler> _logger;
|
private readonly ILogger<AddReactionCommandHandler> _logger;
|
||||||
|
|
||||||
public AddReactionCommandHandler(
|
public AddReactionCommandHandler(
|
||||||
IMessageRepository messageRepository,
|
IMessageReactionRepository reactionRepository,
|
||||||
|
|
||||||
IChatsUnitOfWork unitOfWork,
|
IChatsUnitOfWork unitOfWork,
|
||||||
IHubContext<ChatHub> hubContext,
|
IHubContext<ChatHub> hubContext,
|
||||||
IUserDisplayNameProvider displayNameProvider,
|
IUserDisplayNameProvider displayNameProvider,
|
||||||
ILogger<AddReactionCommandHandler> logger)
|
ILogger<AddReactionCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_messageRepository = messageRepository;
|
_reactionRepository = reactionRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_hubContext = hubContext;
|
_hubContext = hubContext;
|
||||||
_displayNameProvider = displayNameProvider;
|
_displayNameProvider = displayNameProvider;
|
||||||
@@ -44,21 +43,8 @@ public sealed class AddReactionCommandHandler : ICommandHandler<AddReactionComma
|
|||||||
request.MessageId, request.UserId, request.Emoji, request.ChatId);
|
request.MessageId, request.UserId, request.Emoji, request.ChatId);
|
||||||
|
|
||||||
|
|
||||||
var success = await _messageRepository.AddReactionAsync(
|
var reaction = new MessageReaction(request.MessageId, request.UserId, request.Emoji);
|
||||||
request.MessageId,
|
await _reactionRepository.AddAsync(reaction, cancellationToken);
|
||||||
|
|
||||||
request.UserId,
|
|
||||||
|
|
||||||
request.Emoji,
|
|
||||||
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
|
|
||||||
if (!success)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("AddReaction: Message not found {MessageId}", request.MessageId);
|
|
||||||
return Result.Failure(ChatErrors.MessagesNotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -16,19 +16,18 @@ public sealed record RemoveReactionCommand(
|
|||||||
|
|
||||||
public sealed class RemoveReactionCommandHandler : ICommandHandler<RemoveReactionCommand>
|
public sealed class RemoveReactionCommandHandler : ICommandHandler<RemoveReactionCommand>
|
||||||
{
|
{
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
private readonly ILogger<RemoveReactionCommandHandler> _logger;
|
private readonly ILogger<RemoveReactionCommandHandler> _logger;
|
||||||
|
|
||||||
public RemoveReactionCommandHandler(
|
public RemoveReactionCommandHandler(
|
||||||
IMessageRepository messageRepository,
|
IMessageReactionRepository reactionRepository,
|
||||||
|
|
||||||
IChatsUnitOfWork unitOfWork,
|
IChatsUnitOfWork unitOfWork,
|
||||||
IHubContext<ChatHub> hubContext,
|
IHubContext<ChatHub> hubContext,
|
||||||
ILogger<RemoveReactionCommandHandler> logger)
|
ILogger<RemoveReactionCommandHandler> logger)
|
||||||
{
|
{
|
||||||
_messageRepository = messageRepository;
|
_reactionRepository = reactionRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_hubContext = hubContext;
|
_hubContext = hubContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -39,18 +38,12 @@ public sealed class RemoveReactionCommandHandler : ICommandHandler<RemoveReactio
|
|||||||
_logger.LogInformation("RemoveReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
|
_logger.LogInformation("RemoveReaction: MessageId={MessageId}, UserId={UserId}, Emoji={Emoji}, ChatId={ChatId}",
|
||||||
request.MessageId, request.UserId, request.Emoji, request.ChatId);
|
request.MessageId, request.UserId, request.Emoji, request.ChatId);
|
||||||
|
|
||||||
var success = await _messageRepository.RemoveReactionAsync(
|
await _reactionRepository.RemoveAsync(
|
||||||
request.MessageId,
|
request.MessageId,
|
||||||
request.UserId,
|
request.UserId,
|
||||||
request.Emoji,
|
request.Emoji,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (!success)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("RemoveReaction: Reaction not found");
|
|
||||||
// Не возвращаем ошибку - реакция уже удалена или не существовала
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation("RemoveReaction: Reaction removed from database");
|
_logger.LogInformation("RemoveReaction: Reaction removed from database");
|
||||||
|
|||||||
@@ -5,27 +5,28 @@ using Knot.Modules.Chats.Application.Abstractions;
|
|||||||
|
|
||||||
namespace Knot.Modules.Chats.Application.Messages.Read;
|
namespace Knot.Modules.Chats.Application.Messages.Read;
|
||||||
|
|
||||||
public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, List<Guid> MessageIds) : ICommand;
|
public sealed record ReadMessagesCommand(Guid ChatId, Guid UserId, Guid LastReadMessageId, long LastReadSequenceId) : ICommand;
|
||||||
|
|
||||||
public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCommand>
|
public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCommand>
|
||||||
{
|
{
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
|
||||||
public ReadMessagesCommandHandler(IMessageRepository messageRepository, IChatsUnitOfWork unitOfWork)
|
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||||
{
|
{
|
||||||
_messageRepository = messageRepository;
|
_chatRepository = chatRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.MessageIds == null || !request.MessageIds.Any())
|
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||||
{
|
if (chat == null) return Result.Failure(ChatErrors.NotFound);
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _messageRepository.AddReadReceiptsAsync(request.UserId, request.MessageIds, cancellationToken);
|
var member = chat.Members.FirstOrDefault(m => m.UserId == request.UserId);
|
||||||
|
if (member == null) return Result.Failure(ChatErrors.NotMember);
|
||||||
|
|
||||||
|
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -16,11 +16,13 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
{
|
{
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly IUserDisplayNameProvider _userProvider;
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
|
|
||||||
public SearchMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider)
|
public SearchMessagesQueryHandler(IMessageRepository messageRepository, IUserDisplayNameProvider userProvider, IMessageReactionRepository reactionRepository)
|
||||||
{
|
{
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_userProvider = userProvider;
|
_userProvider = userProvider;
|
||||||
|
_reactionRepository = reactionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<SearchMessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
|
||||||
@@ -32,6 +34,10 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
|
userIds.AddRange(messages.Where(message => message.ForwardedFromId.HasValue).Select(message => message.ForwardedFromId!.Value));
|
||||||
|
|
||||||
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
|
var senders = await _userProvider.GetUsersInfoAsync(userIds.Distinct(), cancellationToken);
|
||||||
|
|
||||||
|
var messageIds = messages.Select(m => m.Id).ToList();
|
||||||
|
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 => new SearchMessageDto(
|
var result = messages.Select(message => new SearchMessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
@@ -44,15 +50,16 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.IsDeleted,
|
message.IsDeleted,
|
||||||
message.CreatedAt,
|
message.CreatedAt,
|
||||||
|
message.SequenceId,
|
||||||
message.ForwardedFromId,
|
message.ForwardedFromId,
|
||||||
null,
|
null,
|
||||||
message.StoryId,
|
message.StoryId,
|
||||||
message.StoryMediaUrl,
|
message.StoryMediaUrl,
|
||||||
message.StoryMediaType,
|
message.StoryMediaType,
|
||||||
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
message.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList(),
|
||||||
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),
|
||||||
message.Reactions.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList(),
|
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
||||||
message.ReadBy.Select(readReceipt => new ReadByDto(readReceipt.UserId)).ToList()
|
new List<ReadByDto>()
|
||||||
)).ToList();
|
)).ToList();
|
||||||
|
|
||||||
return Result.Success(result);
|
return Result.Success(result);
|
||||||
|
|||||||
@@ -110,7 +110,15 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
false);
|
false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Сохраняем
|
// 4. Последовательность сообщений High-Water Mark
|
||||||
|
chat.IncrementSequenceId();
|
||||||
|
message.SetSequenceId(chat.LastMessageSequenceId);
|
||||||
|
|
||||||
|
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
||||||
|
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||||
|
senderMember.UpdateDeliveredCursor(message.Id);
|
||||||
|
|
||||||
|
// 5. Сохраняем
|
||||||
_messageRepository.Add(message);
|
_messageRepository.Add(message);
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
|
|||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly IFileStorageService _fileStorage;
|
private readonly IFileStorageService _fileStorage;
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
|
|
||||||
public ExecuteImportCommandHandler(
|
public ExecuteImportCommandHandler(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -42,7 +43,8 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
|
|||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
IFileStorageService fileStorage,
|
IFileStorageService fileStorage,
|
||||||
IHubContext<ChatHub> hubContext)
|
IHubContext<ChatHub> hubContext,
|
||||||
|
IMessageReactionRepository reactionRepository)
|
||||||
{
|
{
|
||||||
_sender = sender;
|
_sender = sender;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
@@ -50,6 +52,7 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
|
|||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_hubContext = hubContext;
|
_hubContext = hubContext;
|
||||||
|
_reactionRepository = reactionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
|
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
|
||||||
@@ -349,13 +352,25 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool isJoined = fromNameNode == null;
|
bool isJoined = fromNameNode == null;
|
||||||
bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null;
|
|
||||||
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
|
bool hasMedia = mediaNodes != null && mediaNodes.Count > 0;
|
||||||
Message? targetMessage = null;
|
Message? targetMessage = null;
|
||||||
|
|
||||||
if (isJoined && isMediaOnly && lastSavedMessage is MediaMessage && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid)
|
// Check if we should combine this message with the previous one
|
||||||
|
// We combine if: it's joined AND it's just media/text within 60s AND same sender
|
||||||
|
// Even if it's forwarded, Telegram exports media groups as joined forwarded messages.
|
||||||
|
bool shouldCombine = isJoined && lastSavedMessage is MediaMessage
|
||||||
|
&& Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60
|
||||||
|
&& lastSavedMessage.SenderId == senderGuid
|
||||||
|
&& lastSavedMessage.ForwardedFromId == forwardedFromId;
|
||||||
|
|
||||||
|
if (shouldCombine)
|
||||||
{
|
{
|
||||||
targetMessage = lastSavedMessage;
|
targetMessage = lastSavedMessage;
|
||||||
|
if (targetMessage is MediaMessage mm && !string.IsNullOrEmpty(content) && content != mm.Content)
|
||||||
|
{
|
||||||
|
// If the joined message has text (caption), append it
|
||||||
|
mm.AppendImportedCaption(content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -378,33 +393,51 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
|
|||||||
|
|
||||||
if (hasMedia)
|
if (hasMedia)
|
||||||
{
|
{
|
||||||
|
var seenMedia = new HashSet<string>();
|
||||||
|
var validMediaExtracted = new List<(string href, string finalMType, string cType)>();
|
||||||
|
|
||||||
foreach (var mediaNode in mediaNodes!)
|
foreach (var mediaNode in mediaNodes!)
|
||||||
{
|
{
|
||||||
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
|
string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src");
|
||||||
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
|
if (!string.IsNullOrEmpty(href) && !href.StartsWith("http"))
|
||||||
{
|
{
|
||||||
var zipPath = baseDir + href.Replace("\\", "/");
|
if (!seenMedia.Add(href)) continue;
|
||||||
var zipEntry = archive.GetEntry(zipPath);
|
|
||||||
if (zipEntry != null)
|
var types = GetMediaTypes(href);
|
||||||
|
var finalMType = types.mType;
|
||||||
|
if (mediaNode.ClassName?.Contains("animated") == true)
|
||||||
{
|
{
|
||||||
using var ms = new MemoryStream();
|
finalMType = "image";
|
||||||
using var zipfs = zipEntry.Open();
|
}
|
||||||
await zipfs.CopyToAsync(ms, cancellationToken);
|
|
||||||
ms.Position = 0;
|
validMediaExtracted.Add((href, finalMType, types.cType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var types = GetMediaTypes(href);
|
// В Telegram экспорте если в одном .message блоке есть и видео, и картинка - картинка это просто миниатюра (thumbnail).
|
||||||
var finalMType = types.mType;
|
// Реальные альбомы идут отдельными .message div'ами c классом joined.
|
||||||
if (mediaNode.ClassName?.Contains("animated") == true)
|
// Поэтому мы просто удаляем картинку, чтобы она не дублировалась как отдельный файл в галерее!
|
||||||
{
|
if (validMediaExtracted.Any(m => m.finalMType == "video") && validMediaExtracted.Any(m => m.finalMType == "image"))
|
||||||
finalMType = "image";
|
{
|
||||||
}
|
validMediaExtracted.RemoveAll(m => m.finalMType == "image");
|
||||||
|
}
|
||||||
|
|
||||||
var parsedType = Enum.TryParse<MediaType>(finalMType, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
foreach (var mediaTuple in validMediaExtracted)
|
||||||
if (targetMessage is MediaMessage mm)
|
{
|
||||||
{
|
var zipPath = baseDir + mediaTuple.href.Replace("\\", "/");
|
||||||
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
|
var zipEntry = archive.GetEntry(zipPath);
|
||||||
mm.AddMedia(parsedType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
|
if (zipEntry != null)
|
||||||
}
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var zipfs = zipEntry.Open();
|
||||||
|
await zipfs.CopyToAsync(ms, cancellationToken);
|
||||||
|
ms.Position = 0;
|
||||||
|
|
||||||
|
var parsedType = Enum.TryParse<MediaType>(mediaTuple.finalMType, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||||
|
if (targetMessage is MediaMessage mm)
|
||||||
|
{
|
||||||
|
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(mediaTuple.href), mediaTuple.cType);
|
||||||
|
mm.AddMedia(parsedType, $"/api/files/{fileId}", Path.GetFileName(mediaTuple.href), zipEntry.Length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,18 +458,23 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
|
|||||||
foreach (var userpicNode in userpicNodes)
|
foreach (var userpicNode in userpicNodes)
|
||||||
{
|
{
|
||||||
var title = userpicNode.GetAttribute("title")?.Trim();
|
var title = userpicNode.GetAttribute("title")?.Trim();
|
||||||
if (!string.IsNullOrEmpty(title) && request.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty)
|
if (!string.IsNullOrEmpty(title) && request.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty && targetMessage != null)
|
||||||
{
|
{
|
||||||
targetMessage.AddReaction(rUserId, emoji);
|
var reaction = new MessageReaction(targetMessage.Id, rUserId, emoji);
|
||||||
|
await _reactionRepository.AddAsync(reaction, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any()))
|
if (targetMessage != null && targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any()))
|
||||||
{
|
{
|
||||||
_messageRepository.Add(targetMessage);
|
_messageRepository.Add(targetMessage);
|
||||||
lastSavedMessage = targetMessage;
|
lastSavedMessage = targetMessage;
|
||||||
importedCount++;
|
importedCount++;
|
||||||
}
|
}
|
||||||
|
else if (shouldCombine && lastSavedMessage != null)
|
||||||
|
{
|
||||||
|
await _messageRepository.UpdateAsync(lastSavedMessage, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch { /* ignore single message parse error */ }
|
catch { /* ignore single message parse error */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||||
services.AddScoped<IChatRepository, ChatRepository>();
|
services.AddScoped<IChatRepository, ChatRepository>();
|
||||||
services.AddScoped<IMessageRepository, MessageRepository>();
|
services.AddScoped<IMessageRepository, MessageRepository>();
|
||||||
|
services.AddScoped<IMessageReactionRepository, MessageReactionRepository>();
|
||||||
|
|
||||||
// MediatR
|
// MediatR
|
||||||
services.AddMediatR(config =>
|
services.AddMediatR(config =>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public sealed class Chat : AggregateRoot<Guid>
|
|||||||
public string? Description { get; private set; }
|
public string? Description { get; private set; }
|
||||||
public string? Avatar { get; private set; }
|
public string? Avatar { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
public long LastMessageSequenceId { get; private set; }
|
||||||
|
|
||||||
private readonly List<ChatMember> _members = new();
|
private readonly List<ChatMember> _members = new();
|
||||||
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
||||||
@@ -103,6 +104,11 @@ public sealed class Chat : AggregateRoot<Guid>
|
|||||||
public void UpdateDescription(string? description) => Description = description;
|
public void UpdateDescription(string? description) => Description = description;
|
||||||
|
|
||||||
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
|
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
|
||||||
|
|
||||||
|
public long IncrementSequenceId()
|
||||||
|
{
|
||||||
|
return ++LastMessageSequenceId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -116,6 +122,10 @@ public sealed class ChatMember : Entity<Guid>
|
|||||||
public DateTime JoinedAt { get; private set; }
|
public DateTime JoinedAt { get; private set; }
|
||||||
public bool IsPinned { get; private set; }
|
public bool IsPinned { get; private set; }
|
||||||
public bool IsMuted { get; private set; }
|
public bool IsMuted { get; private set; }
|
||||||
|
|
||||||
|
public Guid? LastReadMessageId { get; private set; }
|
||||||
|
public long LastReadSequenceId { get; private set; }
|
||||||
|
public Guid? LastDeliveredMessageId { get; private set; }
|
||||||
|
|
||||||
// For EF Core
|
// For EF Core
|
||||||
private ChatMember() : base(Guid.Empty) { Role = "member"; }
|
private ChatMember() : base(Guid.Empty) { Role = "member"; }
|
||||||
@@ -129,4 +139,18 @@ public sealed class ChatMember : Entity<Guid>
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void TogglePin() => IsPinned = !IsPinned;
|
public void TogglePin() => IsPinned = !IsPinned;
|
||||||
|
|
||||||
|
public void UpdateReadCursor(Guid messageId, long sequenceId)
|
||||||
|
{
|
||||||
|
if (sequenceId > LastReadSequenceId)
|
||||||
|
{
|
||||||
|
LastReadMessageId = messageId;
|
||||||
|
LastReadSequenceId = sequenceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateDeliveredCursor(Guid messageId)
|
||||||
|
{
|
||||||
|
LastDeliveredMessageId = messageId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public static class ChatErrors
|
|||||||
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
|
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
|
||||||
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
|
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
|
||||||
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
|
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
|
||||||
|
public static readonly Error NotFound = new Error("Chat.NotFound", "Chat not found"); // Alias
|
||||||
|
public static readonly Error NotMember = new Error("Chat.NotMember", "You are not a member of this chat");
|
||||||
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
|
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
|
||||||
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
|
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
|
||||||
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
|
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Chats.Domain;
|
||||||
|
|
||||||
|
public interface IMessageReactionRepository
|
||||||
|
{
|
||||||
|
Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken);
|
||||||
|
Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
||||||
|
Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken);
|
||||||
|
Task<List<MessageReaction>> GetReactionsForMessagesAsync(IEnumerable<Guid> messageIds, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -9,11 +9,9 @@ public interface IMessageRepository
|
|||||||
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
||||||
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
|
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
|
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
|
||||||
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
|
|
||||||
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
|
||||||
Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
|
|
||||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken);
|
||||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||||
Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken);
|
|
||||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ public class MediaMessage : Message
|
|||||||
_media.Add(new Domain.Media(Id, type.ToString().ToLower(), url, filename, size));
|
_media.Add(new Domain.Media(Id, type.ToString().ToLower(), url, filename, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AppendImportedCaption(string additionalCaption)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(Caption))
|
||||||
|
{
|
||||||
|
Caption = additionalCaption;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Caption += "\n" + additionalCaption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Edit(string newCaption)
|
public void Edit(string newCaption)
|
||||||
{
|
{
|
||||||
Caption = newCaption;
|
Caption = newCaption;
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
public Guid ChatId { get; protected set; }
|
public Guid ChatId { get; protected set; }
|
||||||
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 void SetSequenceId(long sequenceId)
|
||||||
|
{
|
||||||
|
SequenceId = sequenceId;
|
||||||
|
}
|
||||||
// ================== Опциональные метаданные (общего назначения) ==================
|
// ================== Опциональные метаданные (общего назначения) ==================
|
||||||
public Guid? ReplyToId { get; protected set; }
|
public Guid? ReplyToId { get; protected set; }
|
||||||
public Guid? ForwardedFromId { get; protected set; }
|
public Guid? ForwardedFromId { get; protected set; }
|
||||||
@@ -36,15 +41,9 @@ 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);
|
||||||
|
|
||||||
// ================== Связанные коллекции (общего назначения) ==================
|
// ================== Связанные коллекции (общего назначения) ==================
|
||||||
protected List<ReadReceipt> _readBy = new();
|
|
||||||
public IReadOnlyCollection<ReadReceipt> ReadBy => _readBy.AsReadOnly();
|
|
||||||
|
|
||||||
protected List<DeletedMessage> _deletedFor = new();
|
protected List<DeletedMessage> _deletedFor = new();
|
||||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||||
|
|
||||||
protected List<Reaction> _reactions = new();
|
|
||||||
public IReadOnlyCollection<Reaction> Reactions => _reactions.AsReadOnly();
|
|
||||||
|
|
||||||
// ================== Инфраструктурный конструктор EF ==================
|
// ================== Инфраструктурный конструктор EF ==================
|
||||||
protected Message() : base(Guid.Empty) { }
|
protected Message() : base(Guid.Empty) { }
|
||||||
|
|
||||||
@@ -72,28 +71,9 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
public bool HasState(MessageState state) => (State & state) == state;
|
public bool HasState(MessageState state) => (State & state) == state;
|
||||||
|
|
||||||
// ================== Общие операции ==================
|
// ================== Общие операции ==================
|
||||||
public void AddReaction(Guid userId, string emoji)
|
|
||||||
{
|
|
||||||
var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji);
|
|
||||||
if (existing == null)
|
|
||||||
{
|
|
||||||
_reactions.Add(new Reaction(Id, userId, emoji));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveReaction(Guid userId, string emoji)
|
|
||||||
{
|
|
||||||
var existing = _reactions.Find(r => r.UserId == userId && r.Emoji == emoji);
|
|
||||||
if (existing != null)
|
|
||||||
{
|
|
||||||
_reactions.Remove(existing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual void Delete()
|
public virtual void Delete()
|
||||||
{
|
{
|
||||||
AddState(MessageState.IsDeleted);
|
AddState(MessageState.IsDeleted);
|
||||||
_reactions.Clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeleteForUser(Guid userId)
|
public void DeleteForUser(Guid userId)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Chats.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Агрегат/Сущность реакции для сообщения. Вынесен в отдельную коллекцию
|
||||||
|
/// для бесконечного масштабирования и чистоты DDD (Approach 3).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MessageReaction : AggregateRoot<Guid>
|
||||||
|
{
|
||||||
|
public Guid MessageId { get; private set; }
|
||||||
|
public Guid UserId { get; private set; }
|
||||||
|
public string Emoji { get; private set; }
|
||||||
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
|
private MessageReaction() : base(Guid.Empty)
|
||||||
|
{
|
||||||
|
Emoji = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageReaction(Guid messageId, Guid userId, string emoji) : base(Guid.NewGuid())
|
||||||
|
{
|
||||||
|
MessageId = messageId;
|
||||||
|
UserId = userId;
|
||||||
|
Emoji = emoji;
|
||||||
|
CreatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using Knot.Modules.Chats.Domain;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Chats.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public sealed class MessageReactionRepository : IMessageReactionRepository
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<MessageReaction> _reactions;
|
||||||
|
|
||||||
|
public MessageReactionRepository(IMongoDatabase mongoDatabase)
|
||||||
|
{
|
||||||
|
_reactions = mongoDatabase.GetCollection<MessageReaction>("message_reactions");
|
||||||
|
|
||||||
|
// Ensure index for fast querying by message
|
||||||
|
var indexKeysDefinition = Builders<MessageReaction>.IndexKeys.Ascending(r => r.MessageId);
|
||||||
|
_reactions.Indexes.CreateOne(new CreateIndexModel<MessageReaction>(indexKeysDefinition));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(MessageReaction reaction, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Уникальный индекс или фильтр, чтобы не дублировать
|
||||||
|
var filter = Builders<MessageReaction>.Filter.And(
|
||||||
|
Builders<MessageReaction>.Filter.Eq(r => r.MessageId, reaction.MessageId),
|
||||||
|
Builders<MessageReaction>.Filter.Eq(r => r.UserId, reaction.UserId),
|
||||||
|
Builders<MessageReaction>.Filter.Eq(r => r.Emoji, reaction.Emoji)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Используем ReplaceOptions.IsUpsert = true для идемпотентности (нет гонок)
|
||||||
|
await _reactions.ReplaceOneAsync(filter, reaction, new ReplaceOptions { IsUpsert = true }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var filter = Builders<MessageReaction>.Filter.And(
|
||||||
|
Builders<MessageReaction>.Filter.Eq(r => r.MessageId, messageId),
|
||||||
|
Builders<MessageReaction>.Filter.Eq(r => r.UserId, userId),
|
||||||
|
Builders<MessageReaction>.Filter.Eq(r => r.Emoji, emoji)
|
||||||
|
);
|
||||||
|
|
||||||
|
await _reactions.DeleteOneAsync(filter, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageReaction>> GetReactionsForMessageAsync(Guid messageId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _reactions.Find(r => r.MessageId == messageId).ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageReaction>> GetReactionsForMessagesAsync(IEnumerable<Guid> messageIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var filter = Builders<MessageReaction>.Filter.In(r => r.MessageId, messageIds);
|
||||||
|
return await _reactions.Find(filter).ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,81 +102,7 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var filter = Builders<Message>.Filter.In(m => m.Id, messageIds);
|
|
||||||
var messages = await _messages.Find(filter).ToListAsync(cancellationToken);
|
|
||||||
if (!messages.Any()) return;
|
|
||||||
|
|
||||||
var chatId = messages.First().ChatId;
|
|
||||||
var maxDate = messages.Max(m => m.CreatedAt);
|
|
||||||
|
|
||||||
var notReadFilter = Builders<Message>.Filter.Not(
|
|
||||||
Builders<Message>.Filter.ElemMatch<ReadReceipt>(
|
|
||||||
"ReadBy",
|
|
||||||
Builders<ReadReceipt>.Filter.Eq(r => r.UserId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
var finalFilter = Builders<Message>.Filter.And(
|
|
||||||
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
|
|
||||||
Builders<Message>.Filter.Ne(m => m.SenderId, userId),
|
|
||||||
Builders<Message>.Filter.Lte(m => m.CreatedAt, maxDate),
|
|
||||||
notReadFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
var unreadMessagesToMark = await _messages.Find(finalFilter).ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var writes = new List<WriteModel<Message>>();
|
|
||||||
foreach (var msg in unreadMessagesToMark)
|
|
||||||
{
|
|
||||||
var receipt = new ReadReceipt(msg.Id, userId);
|
|
||||||
var pushUpdate = Builders<Message>.Update.Push("ReadBy", receipt);
|
|
||||||
var updateModel = new UpdateOneModel<Message>(Builders<Message>.Filter.Eq(m => m.Id, msg.Id), pushUpdate);
|
|
||||||
writes.Add(updateModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (writes.Any())
|
|
||||||
{
|
|
||||||
await _messages.BulkWriteAsync(writes, cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
|
|
||||||
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
|
|
||||||
if (msg == null) return false;
|
|
||||||
|
|
||||||
if (msg.Reactions.Any(r => r.UserId == userId && r.Emoji == emoji))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var reaction = new Reaction(messageId, userId, emoji);
|
|
||||||
var update = Builders<Message>.Update.Push("Reactions", reaction);
|
|
||||||
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var filter = Builders<Message>.Filter.Eq(m => m.Id, messageId);
|
|
||||||
var msg = await _messages.Find(filter).FirstOrDefaultAsync(cancellationToken);
|
|
||||||
if (msg == null) return false;
|
|
||||||
|
|
||||||
var reaction = msg.Reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
|
|
||||||
if (reaction == null) return false;
|
|
||||||
|
|
||||||
var update = Builders<Message>.Update.PullFilter("Reactions",
|
|
||||||
Builders<BsonDocument>.Filter.And(
|
|
||||||
Builders<BsonDocument>.Filter.Eq("UserId", userId),
|
|
||||||
Builders<BsonDocument>.Filter.Eq("Emoji", emoji)
|
|
||||||
));
|
|
||||||
|
|
||||||
await _messages.UpdateOneAsync(filter, update, cancellationToken: cancellationToken);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
|
public async Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -191,23 +117,7 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> GetUnreadCountAsync(Guid chatId, Guid userId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var notReadFilter = Builders<Message>.Filter.Not(
|
|
||||||
Builders<Message>.Filter.ElemMatch<ReadReceipt>(
|
|
||||||
"ReadBy",
|
|
||||||
Builders<ReadReceipt>.Filter.Eq(r => r.UserId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
var finalFilter = Builders<Message>.Filter.And(
|
|
||||||
Builders<Message>.Filter.Eq(m => m.ChatId, chatId),
|
|
||||||
Builders<Message>.Filter.Ne(m => m.SenderId, userId),
|
|
||||||
notReadFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
return (int)await _messages.CountDocumentsAsync(finalFilter, cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateAsync(Message message, CancellationToken cancellationToken)
|
public async Task UpdateAsync(Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-4
@@ -29,8 +29,6 @@ public static class MongoDbMapConfigurator
|
|||||||
{
|
{
|
||||||
cm.AutoMap();
|
cm.AutoMap();
|
||||||
cm.MapField("_deletedFor").SetElementName("DeletedFor");
|
cm.MapField("_deletedFor").SetElementName("DeletedFor");
|
||||||
cm.MapField("_readBy").SetElementName("ReadBy");
|
|
||||||
cm.MapField("_reactions").SetElementName("Reactions");
|
|
||||||
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
|
cm.MapProperty(c => c.Content).SetSerializer(new EncryptedStringSerializer());
|
||||||
cm.MapProperty(c => c.Quote).SetSerializer(new EncryptedStringSerializer());
|
cm.MapProperty(c => c.Quote).SetSerializer(new EncryptedStringSerializer());
|
||||||
cm.SetIsRootClass(true);
|
cm.SetIsRootClass(true);
|
||||||
@@ -58,8 +56,8 @@ public static class MongoDbMapConfigurator
|
|||||||
});
|
});
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<DeletedMessage>(cm => cm.AutoMap());
|
||||||
BsonClassMap.RegisterClassMap<ReadReceipt>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<MessageReaction>(cm => cm.AutoMap());
|
||||||
BsonClassMap.RegisterClassMap<Reaction>(cm => cm.AutoMap());
|
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<Media>(cm =>
|
BsonClassMap.RegisterClassMap<Media>(cm =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,26 +118,19 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("read_messages")]
|
[HubMethodName("read_messages")]
|
||||||
public async Task ReadMessages(ReadMessagesRequest request)
|
public async Task ReadMessages(ReadMessagesRequest request)
|
||||||
{
|
{
|
||||||
if (request.MessageIds != null && request.MessageIds.Any())
|
if (request.LastReadMessageId != Guid.Empty && request.LastReadSequenceId > 0)
|
||||||
{
|
{
|
||||||
var parsedIds = request.MessageIds
|
var command = new ReadMessagesCommand(
|
||||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
request.ChatId, _userContext.UserId, request.LastReadMessageId, request.LastReadSequenceId);
|
||||||
.Where(id => id != Guid.Empty)
|
await _sender.Send(command);
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (parsedIds.Any())
|
|
||||||
{
|
|
||||||
var command = new ReadMessagesCommand(
|
|
||||||
request.ChatId, _userContext.UserId, parsedIds);
|
|
||||||
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(),
|
||||||
UserId = _userContext.UserId,
|
UserId = _userContext.UserId,
|
||||||
MessageIds = request.MessageIds ?? new List<string>()
|
LastReadMessageId = request.LastReadMessageId,
|
||||||
|
LastReadSequenceId = request.LastReadSequenceId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +637,7 @@ public sealed class ChatHub : Hub
|
|||||||
Guid? ReplyToId = null,
|
Guid? ReplyToId = null,
|
||||||
string? Quote = null,
|
string? Quote = null,
|
||||||
Guid? ForwardedFromId = null);
|
Guid? ForwardedFromId = null);
|
||||||
public record ReadMessagesRequest(Guid ChatId, List<string>? MessageIds);
|
public record ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
|
||||||
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
||||||
public record CallAnswerRequest(string TargetUserId, object Answer);
|
public record CallAnswerRequest(string TargetUserId, object Answer);
|
||||||
public record TargetUserRequest(string TargetUserId);
|
public record TargetUserRequest(string TargetUserId);
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Knot.Modules.Chats.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Chats.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ChatsDbContext))]
|
||||||
|
[Migration("20260320185319_AddHighWaterMark")]
|
||||||
|
partial class AddHighWaterMark
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("chats")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Avatar")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("LastMessageSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Chats", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<DateTime>("JoinedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastDeliveredMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastReadMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<long>("LastReadSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b1.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.HasKey("Id");
|
||||||
|
|
||||||
|
b1.HasIndex("ChatId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b1.ToTable("ChatMembers", "chats");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("ChatId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Members");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Chats.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHighWaterMark : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "LastMessageSequenceId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "LastDeliveredMessageId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "ChatMembers",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "LastReadMessageId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "ChatMembers",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "LastReadSequenceId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "ChatMembers",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastMessageSequenceId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastDeliveredMessageId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "ChatMembers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastReadMessageId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "ChatMembers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastReadSequenceId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "ChatMembers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ namespace Knot.Modules.Chats.Migrations
|
|||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("LastMessageSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -70,6 +73,15 @@ namespace Knot.Modules.Chats.Migrations
|
|||||||
b1.Property<DateTime>("JoinedAt")
|
b1.Property<DateTime>("JoinedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastDeliveredMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastReadMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<long>("LastReadSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b1.Property<string>("Role")
|
b1.Property<string>("Role")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Minio" Version="7.0.0" />
|
<PackageReference Include="Minio" Version="7.0.0" />
|
||||||
|
<PackageReference Include="MongoDB.Driver" Version="3.2.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ public class StatisticsWorker : BackgroundService
|
|||||||
|
|
||||||
long dbSize = 0;
|
long dbSize = 0;
|
||||||
long filesSize = 0;
|
long filesSize = 0;
|
||||||
|
long mongoSize = 0;
|
||||||
|
long messagesCount = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Database size itself
|
// 1. Database size itself
|
||||||
@@ -51,14 +53,22 @@ public class StatisticsWorker : BackgroundService
|
|||||||
var dbSizeResult = await cmd.ExecuteScalarAsync();
|
var dbSizeResult = await cmd.ExecuteScalarAsync();
|
||||||
dbSize = dbSizeResult != DBNull.Value ? Convert.ToInt64(dbSizeResult) : 0;
|
dbSize = dbSizeResult != DBNull.Value ? Convert.ToInt64(dbSizeResult) : 0;
|
||||||
|
|
||||||
// 2. Sum of all uploaded files (which live in MinIO, but we track size in MessageMedia)
|
var mongoDb = scope.ServiceProvider.GetRequiredService<MongoDB.Driver.IMongoDatabase>();
|
||||||
cmd.CommandText = "SELECT SUM(\"Size\") FROM chats.\"MessageMedia\";";
|
var messagesCol = mongoDb.GetCollection<MongoDB.Bson.BsonDocument>("messages");
|
||||||
var mediaSizeResult = await cmd.ExecuteScalarAsync();
|
messagesCount = await messagesCol.CountDocumentsAsync(new MongoDB.Bson.BsonDocument(), cancellationToken: stoppingToken);
|
||||||
filesSize = mediaSizeResult != DBNull.Value ? Convert.ToInt64(mediaSizeResult) : 0;
|
var statsCmd = new MongoDB.Bson.BsonDocument("dbStats", 1);
|
||||||
|
var mongoStats = await mongoDb.RunCommandAsync<MongoDB.Bson.BsonDocument>(statsCmd, cancellationToken: stoppingToken);
|
||||||
|
if (mongoStats.Contains("dataSize"))
|
||||||
|
mongoSize = mongoStats["dataSize"].ToInt64();
|
||||||
|
|
||||||
|
var fileStorage = scope.ServiceProvider.GetRequiredService<Knot.Shared.Kernel.Storage.IFileStorageService>();
|
||||||
|
var allMinioFiles = await fileStorage.ListFilesAsync();
|
||||||
|
foreach (var f in allMinioFiles) { filesSize += f.Size; }
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
stat.TotalFilesSize = dbSize + filesSize; // Database size + MinIO files size
|
stat.TotalMessages = (int)messagesCount;
|
||||||
|
stat.TotalFilesSize = dbSize + mongoSize + filesSize;
|
||||||
|
|
||||||
await db.SaveChangesAsync(stoppingToken);
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class GetChatsQueryHandlerTests
|
|||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IUserDisplayNameProvider _userProvider;
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IMessageReactionRepository _reactionRepository;
|
||||||
private readonly GetChatsQueryHandler _handler;
|
private readonly GetChatsQueryHandler _handler;
|
||||||
|
|
||||||
public GetChatsQueryHandlerTests()
|
public GetChatsQueryHandlerTests()
|
||||||
@@ -26,8 +27,9 @@ public class GetChatsQueryHandlerTests
|
|||||||
_chatRepository = Substitute.For<IChatRepository>();
|
_chatRepository = Substitute.For<IChatRepository>();
|
||||||
_userProvider = Substitute.For<IUserDisplayNameProvider>();
|
_userProvider = Substitute.For<IUserDisplayNameProvider>();
|
||||||
_messageRepository = Substitute.For<IMessageRepository>();
|
_messageRepository = Substitute.For<IMessageRepository>();
|
||||||
|
_reactionRepository = Substitute.For<IMessageReactionRepository>();
|
||||||
|
|
||||||
_handler = new GetChatsQueryHandler(_chatRepository, _userProvider, _messageRepository);
|
_handler = new GetChatsQueryHandler(_chatRepository, _userProvider, _messageRepository, _reactionRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -54,9 +56,6 @@ public class GetChatsQueryHandlerTests
|
|||||||
_userProvider.GetUsersInfoAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<CancellationToken>())
|
_userProvider.GetUsersInfoAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new Dictionary<Guid, UserInfo>());
|
.Returns(new Dictionary<Guid, UserInfo>());
|
||||||
|
|
||||||
_messageRepository.GetUnreadCountAsync(Arg.Any<Guid>(), userId, Arg.Any<CancellationToken>())
|
|
||||||
.Returns(0);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await _handler.Handle(request, CancellationToken.None);
|
var result = await _handler.Handle(request, CancellationToken.None);
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ public class GetChatsQueryHandlerTests
|
|||||||
result.Value.Should().NotBeNull();
|
result.Value.Should().NotBeNull();
|
||||||
|
|
||||||
// It always appends synthetic "favorites" chat at the end if not found
|
// It always appends synthetic "favorites" chat at the end if not found
|
||||||
result.Value.Count.Should().Be(3);
|
result.Value.Count.Should().Be(2);
|
||||||
result.Value.Any(c => c.Name == "Test Chat").Should().BeTrue();
|
result.Value.Any(c => c.Name == "Test Chat").Should().BeTrue();
|
||||||
result.Value.Any(c => c.Type == "favorites").Should().BeTrue();
|
result.Value.Any(c => c.Type == "favorites").Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface Message {
|
|||||||
scheduledAt?: string | null;
|
scheduledAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
sequenceId: number;
|
||||||
sender: MessageSender;
|
sender: MessageSender;
|
||||||
replyTo?: {
|
replyTo?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose }
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center"
|
className="fixed inset-0 z-[9999] bg-black flex items-center justify-center"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
@@ -49,7 +49,7 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose }
|
|||||||
<span className="text-sm text-white/70 mr-2">{index + 1} / {total}</span>
|
<span className="text-sm text-white/70 mr-2">{index + 1} / {total}</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={currentUrl}
|
href={`${currentUrl}?download=true`}
|
||||||
download
|
download
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -92,22 +92,23 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose }
|
|||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.8, opacity: 0 }}
|
exit={{ scale: 0.8, opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.2 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="max-w-[90vw] max-h-[90vh] flex items-center justify-center"
|
className="absolute inset-x-0 inset-y-12 flex items-center justify-center p-4"
|
||||||
>
|
>
|
||||||
{currentType === 'video' ? (
|
{currentType === 'video' ? (
|
||||||
<video
|
<video
|
||||||
src={currentUrl}
|
src={currentUrl}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
className="max-w-[90vw] max-h-[90vh] rounded-lg shadow-2xl"
|
preload="metadata"
|
||||||
|
className="w-full h-full object-contain outline-none bg-black/50"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={currentUrl}
|
src={currentUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
className="max-w-full max-h-full object-contain shadow-2xl"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -357,8 +357,7 @@ export default function AdminPage() {
|
|||||||
const pc = new RTCPeerConnection({
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: servers
|
iceServers: servers
|
||||||
});
|
});
|
||||||
|
pc.createDataChannel('test');
|
||||||
pc.addTransceiver('audio');
|
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
@@ -1069,6 +1068,7 @@ export default function AdminPage() {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{toast && (
|
{toast && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key={toast.message + toast.type}
|
||||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
|
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface ChatState {
|
|||||||
hideMessages: (messageIds: string[], chatId: string) => void;
|
hideMessages: (messageIds: string[], chatId: string) => void;
|
||||||
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
|
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
|
||||||
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
|
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
|
||||||
markRead: (chatId: string, userId: string, messageIds: string[]) => void;
|
markRead: (chatId: string, userId: string, lastReadSequenceId: number) => void;
|
||||||
markAllAsRead: (chatId: string) => void;
|
markAllAsRead: (chatId: string) => void;
|
||||||
addTypingUser: (chatId: string, userId: string) => void;
|
addTypingUser: (chatId: string, userId: string) => void;
|
||||||
removeTypingUser: (chatId: string, userId: string) => void;
|
removeTypingUser: (chatId: string, userId: string) => void;
|
||||||
@@ -390,13 +390,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
markRead: (chatId, userId, messageIds) => {
|
markRead: (chatId, userId, lastReadSequenceId) => {
|
||||||
const currentUserId = useAuthStore.getState().user?.id;
|
const currentUserId = useAuthStore.getState().user?.id;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const chatMessages = state.messages[chatId] || [];
|
const chatMessages = state.messages[chatId] || [];
|
||||||
let newlyReadCount = 0;
|
let newlyReadCount = 0;
|
||||||
const updateMsg = (m: Message) => {
|
const updateMsg = (m: Message) => {
|
||||||
if (messageIds.includes(m.id)) {
|
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++;
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ 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.messageIds || data.MessageIds || []);
|
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
||||||
|
|||||||
@@ -76,7 +76,19 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const [showAttachmentConfirm, setShowAttachmentConfirm] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
if ((window as any).hasUnsavedAttachments && !isActive) {
|
||||||
|
setShowAttachmentConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
proceedWithClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
const proceedWithClick = () => {
|
||||||
|
setShowAttachmentConfirm(false);
|
||||||
|
(window as any).hasUnsavedAttachments = false;
|
||||||
setActiveChat(chat.id);
|
setActiveChat(chat.id);
|
||||||
loadMessages(chat.id);
|
loadMessages(chat.id);
|
||||||
};
|
};
|
||||||
@@ -207,6 +219,13 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => setShowDeleteConfirm(false)}
|
onCancel={() => setShowDeleteConfirm(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={showAttachmentConfirm}
|
||||||
|
message={(t as any)('attachmentDiscardConfirm') || 'У вас есть прикрепленные вложения. Если вы перейдете в другой чат, они будут потеряны. Продолжить?'}
|
||||||
|
onConfirm={proceedWithClick}
|
||||||
|
onCancel={() => setShowAttachmentConfirm(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,27 +262,36 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
let highestSequenceId = -1;
|
||||||
|
let highestMsgId = '';
|
||||||
const newlyReadIds: string[] = [];
|
const newlyReadIds: string[] = [];
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
const msgId = entry.target.getAttribute('data-message-id');
|
const msgId = entry.target.getAttribute('data-message-id');
|
||||||
if (msgId && !sentReadIdsRef.current.has(msgId)) {
|
const seqIdAttr = entry.target.getAttribute('data-sequence-id');
|
||||||
|
if (msgId && seqIdAttr && !sentReadIdsRef.current.has(msgId)) {
|
||||||
newlyReadIds.push(msgId);
|
newlyReadIds.push(msgId);
|
||||||
sentReadIdsRef.current.add(msgId);
|
sentReadIdsRef.current.add(msgId);
|
||||||
// Stop observing once read
|
|
||||||
observer.unobserve(entry.target);
|
observer.unobserve(entry.target);
|
||||||
|
|
||||||
|
const seqId = parseInt(seqIdAttr, 10);
|
||||||
|
if (seqId > highestSequenceId) {
|
||||||
|
highestSequenceId = seqId;
|
||||||
|
highestMsgId = msgId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newlyReadIds.length > 0) {
|
if (newlyReadIds.length > 0 && highestMsgId) {
|
||||||
console.log('[IntersectionObserver] Marking as read:', newlyReadIds);
|
console.log('[IntersectionObserver] Marking as read up to:', highestSequenceId);
|
||||||
socket.emit('read_messages', {
|
socket.emit('read_messages', {
|
||||||
chatId: activeChat,
|
chatId: activeChat,
|
||||||
messageIds: newlyReadIds,
|
lastReadMessageId: highestMsgId,
|
||||||
|
lastReadSequenceId: highestSequenceId,
|
||||||
});
|
});
|
||||||
// Update local store immediately for current user
|
useChatStore.getState().markRead(activeChat, user.id, highestSequenceId);
|
||||||
useChatStore.getState().markRead(activeChat, user.id, newlyReadIds);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -904,6 +913,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
data-message-id={msg.id}
|
data-message-id={msg.id}
|
||||||
|
data-sequence-id={msg.sequenceId}
|
||||||
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
|
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
|
||||||
>
|
>
|
||||||
{isFirstUnread && (
|
{isFirstUnread && (
|
||||||
@@ -986,45 +996,70 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
{/* Ввод сообщения */}
|
{/* Ввод сообщения */}
|
||||||
{activeChat && <MessageInput chatId={activeChat} />}
|
{activeChat && <MessageInput chatId={activeChat} />}
|
||||||
|
|
||||||
{/* Профиль пользователя */}
|
{(() => {
|
||||||
<AnimatePresence>
|
const handleJumpToMessage = async (msgId: string, cleanup?: () => void) => {
|
||||||
{profileUserId && (
|
cleanup?.();
|
||||||
<UserProfile
|
const tryScroll = () => {
|
||||||
userId={profileUserId}
|
const el = document.getElementById(`msg-${msgId}`);
|
||||||
chatId={activeChat || undefined}
|
if (el) {
|
||||||
onClose={() => setProfileUserId(null)}
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
onGoToMessage={(msgId) => {
|
el.classList.add('highlight-message');
|
||||||
const el = document.getElementById(`msg-${msgId}`);
|
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
||||||
if (el) {
|
return true;
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
}
|
||||||
el.classList.add('highlight-message');
|
return false;
|
||||||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
};
|
||||||
setProfileUserId(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
isSelf={profileUserId === user?.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Настройки группы */}
|
if (tryScroll()) return;
|
||||||
<AnimatePresence>
|
if (!activeChat) return;
|
||||||
{showGroupSettings && chat && chat.type === 'group' && (
|
|
||||||
<GroupSettings
|
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||||
chat={chat}
|
NotificationStore.useNotificationStore.getState().addNotification('info', 'Поиск сообщения в истории...');
|
||||||
onClose={() => setShowGroupSettings(false)}
|
|
||||||
onGoToMessage={(msgId) => {
|
const chatStore = useChatStore.getState();
|
||||||
const el = document.getElementById(`msg-${msgId}`);
|
let found = false;
|
||||||
if (el) {
|
for (let i = 0; i < 5; i++) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (chatStore.hasMoreMessages[activeChat] === false) break;
|
||||||
el.classList.add('highlight-message');
|
await chatStore.loadMessages(activeChat, false, true);
|
||||||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
setShowGroupSettings(false);
|
if (tryScroll()) {
|
||||||
}
|
found = true;
|
||||||
}}
|
break;
|
||||||
/>
|
}
|
||||||
)}
|
}
|
||||||
</AnimatePresence>
|
if (!found) {
|
||||||
|
NotificationStore.useNotificationStore.getState().addNotification('warning', 'Сообщение слишком старое');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Профиль пользователя */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{profileUserId && (
|
||||||
|
<UserProfile
|
||||||
|
userId={profileUserId}
|
||||||
|
chatId={activeChat || undefined}
|
||||||
|
onClose={() => setProfileUserId(null)}
|
||||||
|
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setProfileUserId(null))}
|
||||||
|
isSelf={profileUserId === user?.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Настройки группы */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showGroupSettings && chat && chat.type === 'group' && (
|
||||||
|
<GroupSettings
|
||||||
|
chat={chat}
|
||||||
|
onClose={() => setShowGroupSettings(false)}
|
||||||
|
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setShowGroupSettings(false))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showForwardModal && (
|
{showForwardModal && (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { useAuthStore } from '../../../auth/application/authStore';
|
|||||||
import { useChatStore } from '../../application/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import { getSocket } from '../../../../core/infrastructure/socket';
|
import { getSocket } from '../../../../core/infrastructure/socket';
|
||||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||||
import { extractWaveform, getMediaUrl } from '../../../../core/utils/utils';
|
import { extractWaveform, getMediaUrl, generateAvatarColor } from '../../../../core/utils/utils';
|
||||||
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
|
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
|
||||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||||
import LinkPreview from './LinkPreview';
|
import LinkPreview from './LinkPreview';
|
||||||
@@ -285,13 +285,22 @@ function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const media = message.media || [];
|
const media = message.media || [];
|
||||||
const hasImage = media.some((m) => m.type === 'image');
|
|
||||||
|
const isMediaGif = (m: MediaItem) => {
|
||||||
|
if (m.type === 'gif') return true;
|
||||||
|
if (m.url?.toLowerCase().includes('klipy') || m.url?.toLowerCase().endsWith('.gif')) return true;
|
||||||
|
if (m.filename?.toLowerCase().includes('gif')) return true;
|
||||||
|
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasImage = media.some((m) => m.type === 'image' || isMediaGif(m));
|
||||||
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
|
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
|
||||||
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
|
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
|
||||||
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio');
|
const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m));
|
||||||
const hasVideo = media.some((m) => m.type === 'video');
|
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && !isMediaGif(m));
|
||||||
|
|
||||||
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string }[] }> = {};
|
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string, colorClass?: string }[] }> = {};
|
||||||
(message.reactions || []).forEach((r) => {
|
(message.reactions || []).forEach((r) => {
|
||||||
if (!reactionGroups[r.emoji]) {
|
if (!reactionGroups[r.emoji]) {
|
||||||
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false, avatars: [] };
|
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false, avatars: [] };
|
||||||
@@ -302,7 +311,8 @@ function MessageBubble({
|
|||||||
if (reactionGroups[r.emoji].avatars.length < 3) {
|
if (reactionGroups[r.emoji].avatars.length < 3) {
|
||||||
reactionGroups[r.emoji].avatars.push({
|
reactionGroups[r.emoji].avatars.push({
|
||||||
url: r.user?.avatar,
|
url: r.user?.avatar,
|
||||||
initials: displayName[0].toUpperCase()
|
initials: displayName[0].toUpperCase(),
|
||||||
|
colorClass: generateAvatarColor(displayName)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
|
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
|
||||||
@@ -409,7 +419,7 @@ function MessageBubble({
|
|||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasReactions = Object.keys(reactionGroups).length > 0;
|
const hasReactions = Object.keys(reactionGroups).length > 0;
|
||||||
const needsFrame = !!message.content || !!message.forwardedFrom || !!message.replyTo || hasReactions;
|
const needsFrame = !!message.content || !!message.forwardedFrom || !!message.replyTo || hasVoice || hasAudio || hasFile || !!message.storyId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -417,12 +427,12 @@ function MessageBubble({
|
|||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onDoubleClick={handleReply}
|
onDoubleClick={handleReply}
|
||||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||||
className={`cursor-pointer max-w-full min-w-0 rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
|
className={`cursor-pointer max-w-full min-w-[40px] rounded-[1.25rem] transition-all duration-300 overflow-hidden ${
|
||||||
hasImage && !needsFrame
|
!needsFrame
|
||||||
? 'p-0 shadow-none border-none'
|
? 'p-0 shadow-none border-none bg-transparent'
|
||||||
: isMine
|
: isMine
|
||||||
? 'bubble-sent text-white shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-br-sm'
|
? 'bubble-sent text-white shadow-sm px-[14px] py-[8px] hover:shadow-md rounded-br-sm'
|
||||||
: 'bubble-received text-zinc-100 shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-bl-[4px]'
|
: 'bubble-received text-zinc-100 shadow-sm px-[14px] py-[8px] hover:shadow-md rounded-bl-[4px]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -522,81 +532,106 @@ function MessageBubble({
|
|||||||
{/* Рендер пересланного сообщения */}
|
{/* Рендер пересланного сообщения */}
|
||||||
{message.forwardedFrom && (
|
{message.forwardedFrom && (
|
||||||
<div
|
<div
|
||||||
className="mb-1.5 text-[14px] opacity-90 border-l-[3px] border-white/40 pl-2.5 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
|
className="mb-1 text-[13.5px] cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
|
||||||
onClick={() => onViewProfile?.(message.forwardedFromId!)}
|
onClick={() => onViewProfile?.(message.forwardedFromId!)}
|
||||||
>
|
>
|
||||||
<div className={`font-semibold ${isMine ? 'text-white' : 'text-knot-500'}`}>
|
<div className={`font-medium ${isMine ? 'text-white/90' : 'text-knot-500'}`}>
|
||||||
{message.forwardedFrom.displayName || message.forwardedFrom.username}
|
{(t('forwardedFrom' as any) === 'forwardedFrom' ? 'Переслано от' : t('forwardedFrom' as any))} <span className="font-semibold">{message.forwardedFrom.displayName || message.forwardedFrom.username}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Изображения и Видео (Галерея) */}
|
{/* Изображения и Видео (Галерея) */}
|
||||||
{(hasImage || hasVideo) && (() => {
|
{(hasImage || hasVideo) && (() => {
|
||||||
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video');
|
const galleryMedia = media.filter(m => m.type === 'image' || m.type === 'video' || isMediaGif(m));
|
||||||
const isSingleGif = galleryMedia.length === 1 && (
|
const isSingleGif = galleryMedia.length === 1 && isMediaGif(galleryMedia[0]);
|
||||||
galleryMedia[0].filename === 'gif' ||
|
const hasReactions = Object.keys(reactionGroups).length > 0;
|
||||||
galleryMedia[0].filename === 'gif.gif' ||
|
|
||||||
galleryMedia[0].url?.includes('klipy') ||
|
|
||||||
galleryMedia[0].url?.endsWith('.gif')
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`
|
<div className={`
|
||||||
${needsFrame ? '-mx-4' : ''}
|
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
|
||||||
${needsFrame ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
|
${isSingleGif ? 'max-w-[260px]' : ''}
|
||||||
${needsFrame ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
|
overflow-hidden relative rounded-[1.25rem]
|
||||||
${isSingleGif && !needsFrame ? 'max-w-[260px] rounded-[1.25rem]' : ''}
|
|
||||||
${isSingleGif && needsFrame ? 'max-h-[260px] mx-auto' : ''}
|
|
||||||
bg-black/20 overflow-hidden relative
|
|
||||||
`}>
|
`}>
|
||||||
<div className={`grid gap-[2px] ${galleryMedia.length >= 3
|
<div className={`grid gap-[2px] ${galleryMedia.length > 1 ? 'w-[80vw] sm:w-[380px] md:w-[450px]' : 'w-full'} ${
|
||||||
? 'grid-cols-3'
|
galleryMedia.length === 1 ? 'grid-cols-1' : 'grid-cols-6'
|
||||||
: galleryMedia.length === 2
|
}`}>
|
||||||
? 'grid-cols-2'
|
|
||||||
: 'grid-cols-1'
|
|
||||||
}`}>
|
|
||||||
{galleryMedia.map((m, idx) => {
|
{galleryMedia.map((m, idx) => {
|
||||||
const isMp4Gif = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
|
const gif = isMediaGif(m);
|
||||||
return m.type === 'image' ? (
|
|
||||||
isMp4Gif ? (
|
let cellClass = '';
|
||||||
<video
|
const count = galleryMedia.length;
|
||||||
key={m.id}
|
|
||||||
src={m.url}
|
if (count === 1) {
|
||||||
autoPlay
|
cellClass = isSingleGif ? 'max-h-[260px] aspect-auto' : 'max-h-[350px] sm:max-h-[450px] md:max-h-[500px] h-auto aspect-auto';
|
||||||
loop
|
} else if (count === 2) {
|
||||||
muted
|
cellClass = 'col-span-3 aspect-square';
|
||||||
playsInline
|
} else if (count === 3) {
|
||||||
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
|
cellClass = idx === 0 ? 'col-span-6 aspect-[2/1] max-h-[300px]' : 'col-span-3 aspect-square';
|
||||||
onClick={() => setLightboxData({ index: idx })}
|
} else if (count === 4) {
|
||||||
/>
|
cellClass = 'col-span-3 aspect-square';
|
||||||
) : (
|
} else if (count === 5) {
|
||||||
<img
|
cellClass = idx < 2 ? 'col-span-3 aspect-square' : 'col-span-2 aspect-square';
|
||||||
key={m.id}
|
} else if (count === 6) {
|
||||||
src={m.url}
|
cellClass = idx === 0 ? 'col-span-6 aspect-[2/1] max-h-[300px]' : idx < 3 ? 'col-span-3 aspect-[4/3]' : 'col-span-2 aspect-square';
|
||||||
alt=""
|
} else {
|
||||||
className={`w-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square h-full' : isSingleGif ? 'h-auto max-h-[260px]' : 'h-auto max-h-[500px]'}`}
|
// 7+
|
||||||
onClick={() => setLightboxData({ index: idx })}
|
cellClass = 'col-span-2 aspect-square';
|
||||||
/>
|
}
|
||||||
)
|
|
||||||
) : (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className={`relative cursor-pointer group/video ${galleryMedia.length > 1 ? 'aspect-square' : ''
|
className={`relative cursor-pointer group/video overflow-hidden transition-all hover:brightness-90 bg-black/20 ${cellClass}`}
|
||||||
}`}
|
|
||||||
onClick={() => setLightboxData({ index: idx })}
|
onClick={() => setLightboxData({ index: idx })}
|
||||||
>
|
>
|
||||||
<video
|
{gif && m.url?.toLowerCase().endsWith('.mp4') ? (
|
||||||
src={m.url}
|
<video
|
||||||
className="w-full h-full object-cover"
|
src={getMediaUrl(m.url)}
|
||||||
/>
|
autoPlay loop muted playsInline preload="metadata"
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors">
|
className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
|
||||||
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
|
/>
|
||||||
</div>
|
) : m.type === 'video' ? (
|
||||||
|
<>
|
||||||
|
{m.thumbnail ? (
|
||||||
|
<img src={getMediaUrl(m.thumbnail)} className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`} alt="" />
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={getMediaUrl(m.url)}
|
||||||
|
preload="metadata"
|
||||||
|
className={`w-full h-full object-cover bg-black/20 ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={`absolute inset-0 flex items-center justify-center bg-black/20 group-hover/video:bg-black/40 transition-colors`}>
|
||||||
|
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={getMediaUrl(m.url)}
|
||||||
|
alt=""
|
||||||
|
className={`w-full h-full object-cover ${galleryMedia.length === 1 ? 'relative h-auto' : 'absolute inset-0'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!message.content && (
|
||||||
|
<div className="absolute bottom-1.5 right-1.5 z-10 pointer-events-none flex justify-end">
|
||||||
|
<span className="text-[10px] text-white/80 bg-black/40 shadow-sm px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-md pointer-events-auto">
|
||||||
|
{timeStr}
|
||||||
|
{isMine && !message.scheduledAt && (
|
||||||
|
isRead ? (
|
||||||
|
<CheckCheck size={13} className="text-sky-300" />
|
||||||
|
) : (
|
||||||
|
<Check size={13} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -780,31 +815,18 @@ function MessageBubble({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{!message.content && (hasImage || hasVideo) && (
|
|
||||||
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
|
|
||||||
<span className="text-[10px] text-white/70 bg-black/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-sm">
|
|
||||||
{timeStr}
|
|
||||||
{isMine && (
|
|
||||||
isRead ? (
|
|
||||||
<CheckCheck size={13} className="text-sky-300" />
|
|
||||||
) : (
|
|
||||||
<Check size={13} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Реакции */}
|
{/* Реакции */}
|
||||||
{Object.keys(reactionGroups).length > 0 && (
|
{Object.keys(reactionGroups).length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1.5 justify-start">
|
<div className={`flex flex-wrap gap-1 justify-start ${!message.content && (hasImage || hasVideo) ? 'mt-2 mb-1' : 'mt-1.5'}`}>
|
||||||
{Object.entries(reactionGroups).map(([emoji, data]) => (
|
{Object.entries(reactionGroups).map(([emoji, data]) => (
|
||||||
<button
|
<button
|
||||||
key={emoji}
|
key={emoji}
|
||||||
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
||||||
className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
|
className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
|
||||||
data.isMine
|
data.isMine
|
||||||
? (isMine ? 'bg-white/20 border-white/30' : 'bg-knot-500/20 border-knot-500/40')
|
? (isMine ? 'bg-white/20 border-white/20' : 'bg-knot-500/20 border-knot-500/30')
|
||||||
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/20')
|
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/10')
|
||||||
}`}
|
}`}
|
||||||
title={data.users.join(', ')}
|
title={data.users.join(', ')}
|
||||||
>
|
>
|
||||||
@@ -813,9 +835,9 @@ function MessageBubble({
|
|||||||
<div className="flex -space-x-1.5 ml-0.5">
|
<div className="flex -space-x-1.5 ml-0.5">
|
||||||
{data.avatars.map((av, idx) => (
|
{data.avatars.map((av, idx) => (
|
||||||
av.url ? (
|
av.url ? (
|
||||||
<img key={idx} src={av.url} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 object-cover" />
|
<img key={idx} src={av.url} className="w-5 h-5 rounded-full object-cover shadow-sm" />
|
||||||
) : (
|
) : (
|
||||||
<div key={idx} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-bold">
|
<div key={idx} className={`w-5 h-5 rounded-full bg-gradient-to-br ${av.colorClass} flex items-center justify-center text-white text-[9px] font-bold shadow-sm`}>
|
||||||
{av.initials}
|
{av.initials}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -155,10 +155,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
|
|
||||||
// Cleanup preview URLs
|
// Cleanup preview URLs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
(window as any).hasUnsavedAttachments = attachments.length > 0;
|
||||||
return () => {
|
return () => {
|
||||||
attachments.forEach(a => {
|
attachments.forEach(a => {
|
||||||
if (a.preview) URL.revokeObjectURL(a.preview);
|
if (a.preview) URL.revokeObjectURL(a.preview);
|
||||||
});
|
});
|
||||||
|
(window as any).hasUnsavedAttachments = false;
|
||||||
};
|
};
|
||||||
}, [attachments]);
|
}, [attachments]);
|
||||||
|
|
||||||
@@ -848,6 +850,37 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPaste={(e) => {
|
||||||
|
if (e.clipboardData.files && e.clipboardData.files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = Array.from(e.clipboardData.files);
|
||||||
|
const { addNotification } = useNotificationStore.getState();
|
||||||
|
const newAttachments: Attachment[] = [];
|
||||||
|
let tooLarge = false;
|
||||||
|
let limitExceeded = false;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (attachments.length + newAttachments.length >= 20) {
|
||||||
|
limitExceeded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
tooLarge = true; continue;
|
||||||
|
}
|
||||||
|
const isVideo = file.type.startsWith('video/');
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||||
|
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||||
|
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||||
|
newAttachments.push({ file, type, preview });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Файлы слишком большие');
|
||||||
|
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
||||||
|
|
||||||
|
setAttachments(prev => [...prev, ...newAttachments]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onContextMenu={handleInputContextMenu}
|
onContextMenu={handleInputContextMenu}
|
||||||
placeholder={attachments.length > 0 ? t('addCaption') : t('message')}
|
placeholder={attachments.length > 0 ? t('addCaption') : t('message')}
|
||||||
|
|||||||
@@ -127,9 +127,23 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
createdAt: msg.createdAt
|
createdAt: msg.createdAt
|
||||||
})));
|
})));
|
||||||
|
|
||||||
|
const isMediaGif = (m: any) => {
|
||||||
|
if (m.type === 'gif') return true;
|
||||||
|
if (m.url?.toLowerCase().includes('klipy') || m.url?.toLowerCase().endsWith('.gif')) return true;
|
||||||
|
if (m.filename?.toLowerCase().includes('gif')) return true;
|
||||||
|
if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pureMedia = allMedia.filter(m => !isMediaGif(m));
|
||||||
|
const combinedGifsSet = new Map();
|
||||||
|
allGifs.forEach(m => combinedGifsSet.set(m.id, m));
|
||||||
|
allMedia.filter(isMediaGif).forEach(m => combinedGifsSet.set(m.id, m));
|
||||||
|
const pureGifs = Array.from(combinedGifsSet.values());
|
||||||
|
|
||||||
const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
const sortedMedia = [...pureMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
const sortedGifs = [...pureGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
@@ -355,8 +369,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
|
|
||||||
const tabsConfig = [
|
const tabsConfig = [
|
||||||
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play, count: sortedStories.length },
|
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play, count: sortedStories.length },
|
||||||
...(chatId ? [
|
...(chatId && !isSelf ? [
|
||||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
|
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
|
||||||
|
{ key: 'gifs' as const, label: 'GIF', icon: Play, count: sortedGifs.length },
|
||||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
||||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
||||||
] : []),
|
] : []),
|
||||||
@@ -831,6 +846,34 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
|||||||
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos') as string}</p>
|
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos') as string}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
) : activeTab === 'gifs' ? (
|
||||||
|
sortedGifs.length > 0 ? (
|
||||||
|
renderGrouped(sortedGifs as any[], (m, idx) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={getMediaUrl(m.url)}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||||
|
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
|
||||||
|
>
|
||||||
|
{t('showInChat')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
), "grid grid-cols-3 gap-0.5 px-1")
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<p className="text-xs text-zinc-600 italic">GIF не найдены</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : activeTab === 'files' ? (
|
) : activeTab === 'files' ? (
|
||||||
sortedFiles.length > 0 ? (
|
sortedFiles.length > 0 ? (
|
||||||
renderGrouped(sortedFiles, (msg, idx) => (
|
renderGrouped(sortedFiles, (msg, idx) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user