Compare commits
3
Commits
android_v2
...
63fc0e197b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63fc0e197b | ||
|
|
83ed328dd5 | ||
|
|
0f593e52e0 |
@@ -13,6 +13,7 @@ public interface IMessageRepository
|
|||||||
|
|
||||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
|
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
protected List<DeletedMessage> _deletedFor = new();
|
protected List<DeletedMessage> _deletedFor = new();
|
||||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||||
|
|
||||||
|
protected List<Guid> _readByUsers = new();
|
||||||
|
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
||||||
|
|
||||||
protected Message() : base(Guid.Empty) { }
|
protected Message() : base(Guid.Empty) { }
|
||||||
|
|
||||||
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||||
@@ -53,4 +56,14 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
if (!_deletedFor.Exists(x => x.UserId == userId))
|
if (!_deletedFor.Exists(x => x.UserId == userId))
|
||||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void MarkAsRead(Guid userId)
|
||||||
|
{
|
||||||
|
if (!_readByUsers.Contains(userId))
|
||||||
|
{
|
||||||
|
_readByUsers.Add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -1,13 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using System.Linq;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Shared.Kernel.Storage;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
||||||
|
|
||||||
@@ -19,17 +20,21 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
private readonly IMessageRepository _messageRepository;
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly IFileStorageService _fileStorage;
|
private readonly IFileStorageService _fileStorage;
|
||||||
private readonly IChatsUnitOfWork _uow;
|
private readonly IChatsUnitOfWork _uow;
|
||||||
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
|
|
||||||
public LeaveOrDeleteChatCommandHandler(
|
public LeaveOrDeleteChatCommandHandler(
|
||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
|
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
IFileStorageService fileStorage,
|
IFileStorageService fileStorage,
|
||||||
IChatsUnitOfWork uow)
|
IChatsUnitOfWork uow,
|
||||||
|
IHubContext<ChatHub> hubContext)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
|
_hubContext = hubContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
||||||
@@ -58,6 +63,14 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
// DELETE ALL MESSAGES AND FILES FIRST
|
// DELETE ALL MESSAGES AND FILES FIRST
|
||||||
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
|
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
|
||||||
_chatRepository.Remove(chat);
|
_chatRepository.Remove(chat);
|
||||||
|
|
||||||
|
// Notify all remaining members that the chat was deleted
|
||||||
|
|
||||||
|
foreach (var member in chat.Members)
|
||||||
|
{
|
||||||
|
await _hubContext.Clients.User(member.UserId.ToString())
|
||||||
|
.SendAsync("chat_deleted", chat.Id.ToString(), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _uow.SaveChangesAsync(cancellationToken);
|
await _uow.SaveChangesAsync(cancellationToken);
|
||||||
@@ -68,6 +81,7 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
|
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
||||||
{
|
{
|
||||||
// Get all messages directly from Mongo (not paged)
|
// Get all messages directly from Mongo (not paged)
|
||||||
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
|
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
|
||||||
|
|||||||
+8
-3
@@ -13,7 +13,7 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
||||||
|
|
||||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||||
|
|
||||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,12 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
List<Message> messages;
|
List<Message> messages;
|
||||||
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||||
|
|
||||||
if (request.Pivot.HasValue)
|
if (request.AfterSequenceId.HasValue)
|
||||||
|
{
|
||||||
|
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
|
||||||
|
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
|
||||||
|
}
|
||||||
|
else if (request.Pivot.HasValue)
|
||||||
{
|
{
|
||||||
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -154,7 +159,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(message as StoryMessage)?.StoryMediaType,
|
(message as StoryMessage)?.StoryMediaType,
|
||||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
||||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
|
||||||
reactions?.Select(r =>
|
reactions?.Select(r =>
|
||||||
{
|
{
|
||||||
senders.TryGetValue(r.UserId, out var ru);
|
senders.TryGetValue(r.UserId, out var ru);
|
||||||
|
|||||||
+24
-4
@@ -1,7 +1,8 @@
|
|||||||
using MediatR;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
|
||||||
@@ -11,11 +12,13 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
|||||||
{
|
{
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
|
||||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
||||||
@@ -28,6 +31,23 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
|||||||
|
|
||||||
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
||||||
|
|
||||||
|
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
|
||||||
|
var messages = await _messageRepository.GetChatMessagesAfterAsync(
|
||||||
|
request.ChatId,
|
||||||
|
0,
|
||||||
|
1000,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
if (message.SequenceId <= request.LastReadSequenceId &&
|
||||||
|
message.SenderId != request.UserId &&
|
||||||
|
!message.IsReadBy(request.UserId))
|
||||||
|
{
|
||||||
|
message.MarkAsRead(request.UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|||||||
+4
-3
@@ -3,10 +3,10 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -41,7 +41,8 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
||||||
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
var result = messages.Select(message => {
|
var result = messages.Select(message =>
|
||||||
|
{
|
||||||
var textMessage = message as TextMessage;
|
var textMessage = message as TextMessage;
|
||||||
var mediaMessage = message as MediaMessage;
|
var mediaMessage = message as MediaMessage;
|
||||||
var storyMessage = message as StoryMessage;
|
var storyMessage = message as StoryMessage;
|
||||||
@@ -66,7 +67,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
||||||
new List<ReadByDto>()
|
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
|
||||||
);
|
);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,8 +1,8 @@
|
|||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
@@ -192,6 +192,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
||||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||||
senderMember.UpdateDeliveredCursor(message.Id);
|
senderMember.UpdateDeliveredCursor(message.Id);
|
||||||
|
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
|
||||||
|
|
||||||
// 5. ���������
|
// 5. ���������
|
||||||
_messageRepository.Add(message);
|
_messageRepository.Add(message);
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Delete;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Edit;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Pin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.React;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Vote;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Read;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Delete;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.React;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Knot.Contracts.Auth.Domain;
|
using Microsoft.Extensions.Logging;
|
||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Pin;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Vote;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Edit;
|
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Messaging.Domain;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
@@ -158,6 +158,7 @@ public sealed class ChatHub : Hub
|
|||||||
await _sender.Send(command);
|
await _sender.Send(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
|
||||||
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
||||||
{
|
{
|
||||||
ChatId = request.ChatId.ToString(),
|
ChatId = request.ChatId.ToString(),
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ public static class MessagesEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("api/messages").RequireAuthorization();
|
var group = app.MapGroup("api/messages").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapGet("chat/{chatId:guid}", async (
|
||||||
|
[FromRoute] Guid chatId,
|
||||||
|
[FromQuery] string? cursor,
|
||||||
|
[FromQuery] long? afterSequenceId,
|
||||||
|
[FromQuery] long? pivot,
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
ISender sender,
|
||||||
|
IUserContext userContext,
|
||||||
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
|
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
protected List<DeletedMessage> _deletedFor = new();
|
protected List<DeletedMessage> _deletedFor = new();
|
||||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||||
|
|
||||||
|
// ================== Прочитано ==================
|
||||||
|
protected List<Guid> _readByUsers = new();
|
||||||
|
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
||||||
|
|
||||||
// ================== Инфраструктурный конструктор EF ==================
|
// ================== Инфраструктурный конструктор EF ==================
|
||||||
protected Message() : base(Guid.Empty) { }
|
protected Message() : base(Guid.Empty) { }
|
||||||
|
|
||||||
@@ -89,6 +93,16 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void MarkAsRead(Guid userId)
|
||||||
|
{
|
||||||
|
if (!_readByUsers.Contains(userId))
|
||||||
|
{
|
||||||
|
_readByUsers.Add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
|||||||
size = m.Size
|
size = m.Size
|
||||||
}).ToList() ?? (object)Array.Empty<object>(),
|
}).ToList() ?? (object)Array.Empty<object>(),
|
||||||
sender = senderObj,
|
sender = senderObj,
|
||||||
readBy = new List<object>(),
|
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
|
||||||
storyId = (message as StoryMessage)?.StoryId,
|
storyId = (message as StoryMessage)?.StoryId,
|
||||||
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
||||||
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
||||||
|
|||||||
@@ -95,6 +95,20 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var builder = Builders<Message>.Filter;
|
||||||
|
var filter = builder.And(
|
||||||
|
builder.Eq(m => m.ChatId, chatId),
|
||||||
|
builder.Gt(m => m.SequenceId, sequenceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
return await _messages.Find(filter)
|
||||||
|
.SortBy(m => m.SequenceId)
|
||||||
|
.Limit(limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var builder = Builders<Message>.Filter;
|
var builder = Builders<Message>.Filter;
|
||||||
|
|||||||
@@ -401,6 +401,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (m.sequenceId <= lastReadSequenceId) {
|
if (m.sequenceId <= lastReadSequenceId) {
|
||||||
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
||||||
if (alreadyRead) return m;
|
if (alreadyRead) return m;
|
||||||
|
// Увеличиваем счётчик только если текущий пользователь читает чужие сообщения
|
||||||
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
|
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
|
||||||
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
||||||
}
|
}
|
||||||
@@ -412,6 +413,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const updatedChats = state.chats.map((chat) => {
|
const updatedChats = state.chats.map((chat) => {
|
||||||
if (chat.id === chatId) {
|
if (chat.id === chatId) {
|
||||||
const updatedLastMessages = chat.messages?.map(updateMsg);
|
const updatedLastMessages = chat.messages?.map(updateMsg);
|
||||||
|
// Уменьшаем unreadCount только если текущий пользователь прочитал сообщения
|
||||||
if (userId === currentUserId) {
|
if (userId === currentUserId) {
|
||||||
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
|
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,12 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('messages_read', (data: any) => {
|
socket.on('messages_read', (data: any) => {
|
||||||
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
|
const chatId = data.chatId || data.ChatId;
|
||||||
|
const userId = data.userId || data.UserId;
|
||||||
|
const lastReadSequenceId = data.lastReadSequenceId || data.LastReadSequenceId || 0;
|
||||||
|
|
||||||
|
// Обновляем стейт - добавляем userId в readBy для всех сообщений до lastReadSequenceId
|
||||||
|
markRead(chatId, userId, lastReadSequenceId);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
|
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
|
||||||
|
|
||||||
// Галочки прочтения
|
// Галочки прочтения
|
||||||
const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||||
|
// Для чужих сообщений: не показываем галочки
|
||||||
|
const isRead = !chat.isImporting && isMine && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
||||||
|
|
||||||
const timeStr = !chat.isImporting && lastMessage
|
const timeStr = !chat.isImporting && lastMessage
|
||||||
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
||||||
@@ -188,8 +190,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${
|
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
||||||
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Аватар */}
|
{/* Аватар */}
|
||||||
|
|||||||
@@ -79,7 +79,11 @@ function MessageBubble({
|
|||||||
const [quotedText, setQuotedText] = useState<string | null>(null);
|
const [quotedText, setQuotedText] = useState<string | null>(null);
|
||||||
|
|
||||||
// Прочитано
|
// Прочитано
|
||||||
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
|
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||||
|
// Для чужих сообщений: проверено, есть ли в readBy текущий пользователь
|
||||||
|
const isRead = isMine
|
||||||
|
? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал
|
||||||
|
: message.readBy?.some((r) => r.userId === user?.id); // Я прочитал
|
||||||
|
|
||||||
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
|
|||||||
Reference in New Issue
Block a user