Опросы
This commit is contained in:
+80
-61
@@ -13,7 +13,7 @@ using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
||||
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery<List<MessageDetailDto>>;
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||
|
||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||
{
|
||||
@@ -38,24 +38,34 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
DateTime? cursorDate = null;
|
||||
long? cursorSequenceId = null;
|
||||
List<Message> messages;
|
||||
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Cursor))
|
||||
if (request.Pivot.HasValue)
|
||||
{
|
||||
if (long.TryParse(request.Cursor, out var seqId))
|
||||
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
DateTime? cursorDate = null;
|
||||
long? cursorSequenceId = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Cursor))
|
||||
{
|
||||
cursorSequenceId = seqId;
|
||||
}
|
||||
else if (DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||
{
|
||||
cursorDate = parsed.ToUniversalTime();
|
||||
if (long.TryParse(request.Cursor, out var seqId))
|
||||
{
|
||||
cursorSequenceId = seqId;
|
||||
}
|
||||
else if (DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||
{
|
||||
cursorDate = parsed.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, cursorSequenceId, queryLimit, cancellationToken);
|
||||
}
|
||||
|
||||
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, cursorSequenceId, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
|
||||
var result = new List<MessageDetailDto>();
|
||||
|
||||
var userIdsToFetch = new HashSet<Guid>();
|
||||
var replyMessages = new Dictionary<Guid, Message>();
|
||||
|
||||
@@ -66,6 +76,14 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
{
|
||||
userIdsToFetch.Add(m.SenderId);
|
||||
|
||||
if (m is PollMessage poll && !poll.IsAnonymous)
|
||||
{
|
||||
foreach (var vote in poll.Votes)
|
||||
{
|
||||
userIdsToFetch.Add(vote.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!m.ReplyToId.HasValue)
|
||||
{
|
||||
continue;
|
||||
@@ -94,76 +112,77 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
continue;
|
||||
}
|
||||
|
||||
ReplyToMessageDto? replyToObj = null;
|
||||
if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
|
||||
senders.TryGetValue(message.SenderId, out var sender);
|
||||
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
||||
|
||||
Message? replyMsg = null;
|
||||
if (message.ReplyToId.HasValue)
|
||||
{
|
||||
senders.TryGetValue(replyMsg.SenderId, out var rs);
|
||||
var senderObj = rs != null
|
||||
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
|
||||
: null;
|
||||
|
||||
replyToObj = new ReplyToMessageDto(
|
||||
replyMsg.Id,
|
||||
replyMsg.Content,
|
||||
replyMsg.IsDeleted,
|
||||
(replyMsg as MediaMessage)?.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList() ?? new List<MediaDto>(),
|
||||
senderObj
|
||||
);
|
||||
replyMessages.TryGetValue(message.ReplyToId.Value, out replyMsg);
|
||||
}
|
||||
|
||||
var reactionsWithUser = new List<MessageReactionDto>();
|
||||
var messageReactions = reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr : new List<MessageReaction>();
|
||||
foreach (var reaction in messageReactions)
|
||||
UserInfo? replySender = null;
|
||||
if (replyMsg != null)
|
||||
{
|
||||
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
|
||||
|
||||
reactionsWithUser.Add(new MessageReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
userObj
|
||||
));
|
||||
senders.TryGetValue(replyMsg.SenderId, out replySender);
|
||||
}
|
||||
|
||||
var textMessage = message as TextMessage;
|
||||
var mediaMessage = message as MediaMessage;
|
||||
var storyMessage = message as StoryMessage;
|
||||
|
||||
result.Add(new MessageDetailDto(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.Type,
|
||||
message.Type.ToLower(),
|
||||
message.ReplyToId,
|
||||
replyToObj,
|
||||
textMessage?.Quote,
|
||||
replyMsg != null ? new ReplyToMessageDto(
|
||||
replyMsg.Id,
|
||||
replyMsg.Content,
|
||||
replyMsg.IsDeleted,
|
||||
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
||||
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||
) : null,
|
||||
message is TextMessage tm ? tm.Quote : null,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
message.SequenceId,
|
||||
message.ForwardedFromId,
|
||||
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser)
|
||||
? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar)
|
||||
: null,
|
||||
storyMessage?.StoryId,
|
||||
storyMessage?.StoryMediaUrl,
|
||||
storyMessage?.StoryMediaType,
|
||||
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) : null,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= message.SequenceId && m.UserId != message.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
|
||||
reactionsWithUser,
|
||||
null, // ForwardedFrom details not implemented here yet
|
||||
(message as StoryMessage)?.StoryId,
|
||||
(message as StoryMessage)?.StoryMediaUrl,
|
||||
(message as StoryMessage)?.StoryMediaType,
|
||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||
reactions?.Select(r => {
|
||||
senders.TryGetValue(r.UserId, out var ru);
|
||||
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
||||
}).ToList() ?? new List<MessageReactionDto>(),
|
||||
(message as CallMessage)?.CallType,
|
||||
(message as CallMessage)?.CallStatus,
|
||||
(message as CallMessage)?.Duration
|
||||
));
|
||||
(message as CallMessage)?.Duration,
|
||||
(message as PollMessage)?.Options.Select(o => {
|
||||
var pm = (PollMessage)message;
|
||||
var voters = pm.IsAnonymous == false
|
||||
? pm.Votes
|
||||
.Where(v => v.OptionId == o.Id)
|
||||
.Select(v => {
|
||||
senders.TryGetValue(v.UserId, out var vu);
|
||||
return vu != null
|
||||
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||
: new MessageSenderDto(v.UserId, "unknown", "Unknown", null);
|
||||
})
|
||||
.ToList()
|
||||
: null;
|
||||
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||
}).ToList(),
|
||||
(message as PollMessage)?.IsMultipleChoice,
|
||||
(message as PollMessage)?.IsAnonymous,
|
||||
(message as PollMessage)?.IsClosed,
|
||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
|
||||
));
|
||||
}
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+3
-4
@@ -135,8 +135,9 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
else if (request.Type == "poll")
|
||||
{
|
||||
if (!_messagesSettings.Current.AllowPolls) return Result.Failure<Guid>(ChatErrors.PollsDisabled);
|
||||
if (chat.Type != ChatType.Group) return Result.Failure<Guid>(new Error("Poll.InvalidChat", "Polls are only allowed in groups."));
|
||||
|
||||
message = new PollMessage(
|
||||
message = PollMessage.Create(
|
||||
Guid.NewGuid(),
|
||||
request.ChatId,
|
||||
request.SenderId,
|
||||
@@ -146,9 +147,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
request.PollAllowMultipleAnswers ?? false,
|
||||
request.PollExpiresAt,
|
||||
request.ReplyToId,
|
||||
request.ForwardedFromId,
|
||||
DateTime.UtcNow,
|
||||
false);
|
||||
request.ForwardedFromId);
|
||||
}
|
||||
else if (request.Type == "call")
|
||||
{
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Vote;
|
||||
|
||||
public sealed record VotePollCommand(
|
||||
Guid MessageId,
|
||||
Guid ChatId,
|
||||
Guid UserId,
|
||||
Guid OptionId) : ICommand;
|
||||
|
||||
public sealed class VotePollCommandHandler : ICommandHandler<VotePollCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMessageNotifier _notifier;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
|
||||
public VotePollCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatRepository chatRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IMessageNotifier notifier,
|
||||
IUserDisplayNameProvider userProvider)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_notifier = notifier;
|
||||
_userProvider = userProvider;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(VotePollCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||
if (message is not PollMessage poll) return Result.Failure(new Error("Poll.NotFound", "Poll not found"));
|
||||
|
||||
if (poll.IsClosed) return Result.Failure(new Error("Poll.Closed", "This poll is closed."));
|
||||
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId)) return Result.Failure(ChatErrors.ChatsForbidden);
|
||||
|
||||
var targetOption = poll.Options.FirstOrDefault(o => o.Id == request.OptionId);
|
||||
if (targetOption == null) return Result.Failure(new Error("Poll.InvalidOption", "Invalid option ID."));
|
||||
|
||||
// Prevent duplicate or changed votes
|
||||
var existingVote = poll.Votes.FirstOrDefault(v => v.UserId == request.UserId && v.OptionId == request.OptionId);
|
||||
if (existingVote != null) return Result.Failure(new Error("Poll.AlreadyVoted", "You have already voted for this option."));
|
||||
|
||||
if (!poll.IsMultipleChoice)
|
||||
{
|
||||
var hasVotedInThisPoll = poll.Votes.Any(v => v.UserId == request.UserId);
|
||||
if (hasVotedInThisPoll) return Result.Failure(new Error("Poll.AlreadyVoted", "You have already voted in this poll."));
|
||||
}
|
||||
|
||||
poll.Votes.Add(new PollVote { UserId = request.UserId, OptionId = request.OptionId, VotedAt = DateTime.UtcNow });
|
||||
targetOption.VoteCount++;
|
||||
|
||||
await _messageRepository.UpdateAsync(poll, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify updated poll
|
||||
var voterIds = poll.Votes.Select(v => v.UserId).Distinct().ToList();
|
||||
var votersInfo = poll.IsAnonymous == false
|
||||
? await _userProvider.GetUsersInfoAsync(voterIds, cancellationToken)
|
||||
: new Dictionary<Guid, UserInfo>();
|
||||
|
||||
await _notifier.NotifyMessageUpdateAsync(poll.ChatId, "poll_updated", new
|
||||
{
|
||||
id = poll.Id,
|
||||
chatId = poll.ChatId,
|
||||
senderId = poll.SenderId,
|
||||
createdAt = poll.CreatedAt,
|
||||
type = "poll",
|
||||
content = poll.Content,
|
||||
pollOptions = poll.Options.Select(o => new {
|
||||
id = o.Id,
|
||||
text = o.Text,
|
||||
voteCount = o.VoteCount,
|
||||
voters = poll.IsAnonymous == false
|
||||
? poll.Votes.Where(v => v.OptionId == o.Id)
|
||||
.Select(v => {
|
||||
votersInfo.TryGetValue(v.UserId, out var vu);
|
||||
return vu != null
|
||||
? new { id = vu.Id, username = vu.Username, displayName = vu.DisplayName, avatar = vu.Avatar }
|
||||
: new { id = v.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null };
|
||||
}).ToList()
|
||||
: null,
|
||||
voterIds = poll.IsAnonymous == false
|
||||
? poll.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList()
|
||||
: null
|
||||
}).ToList(),
|
||||
pollIsMultipleChoice = poll.IsMultipleChoice,
|
||||
pollIsClosed = poll.IsClosed,
|
||||
pollIsAnonymous = poll.IsAnonymous
|
||||
}, cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user