diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs index 9054a82..c84b6c3 100644 --- a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs +++ b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageNotifier.cs @@ -3,4 +3,5 @@ namespace Knot.Contracts.Messaging.Application.Abstractions; public interface IMessageNotifier { Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken); + Task NotifyMessageUpdateAsync(Guid chatId, string updateType, object updatePayload, CancellationToken cancellationToken); } diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs index 6b1b6d1..d032fba 100644 --- a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs +++ b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs @@ -12,6 +12,7 @@ public interface IMessageRepository Task> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken); Task> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken); + Task> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken); Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); Task UpdateAsync(Message message, CancellationToken cancellationToken); diff --git a/backend/src/Contracts/Messaging/Domain/PollMessage.cs b/backend/src/Contracts/Messaging/Domain/PollMessage.cs index 1785984..3b93934 100644 --- a/backend/src/Contracts/Messaging/Domain/PollMessage.cs +++ b/backend/src/Contracts/Messaging/Domain/PollMessage.cs @@ -7,26 +7,32 @@ public class PollMessage : Message { public override string Type => "poll"; public override string? Content { get; protected set; } - public List Options { get; } = new(); - public List Votes { get; } = new(); + public List Options { get; set; } = new(); + public List Votes { get; set; } = new(); public bool IsMultipleChoice { get; set; } + public bool IsAnonymous { get; set; } public DateTime? ExpiresAt { get; set; } public bool IsClosed { get; set; } public PollMessage() : base() { } - public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) + public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) : base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) { Content = question ?? "Poll"; - if (options != null) - { - foreach (var opt in options) - { - Options.Add(new PollOption { Text = opt }); - } - } + Options = options ?? new List(); + IsAnonymous = isAnonymous; IsMultipleChoice = isMultiple; ExpiresAt = expiresAt; } + + public static PollMessage Create(Guid id, Guid chatId, Guid senderId, string? question, List options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId) + { + var poll = new PollMessage(id, chatId, senderId, question, null, isAnonymous, isMultiple, expiresAt, replyToId, forwardedFromId, DateTime.UtcNow, false); + foreach (var opt in options) + { + poll.Options.Add(new PollOption { Text = opt }); + } + return poll; + } } diff --git a/backend/src/Contracts/Messaging/Domain/PollOption.cs b/backend/src/Contracts/Messaging/Domain/PollOption.cs index a7feb75..4514b84 100644 --- a/backend/src/Contracts/Messaging/Domain/PollOption.cs +++ b/backend/src/Contracts/Messaging/Domain/PollOption.cs @@ -2,6 +2,7 @@ namespace Knot.Contracts.Messaging.Domain; public class PollOption { + public Guid Id { get; set; } = Guid.NewGuid(); public string Text { get; set; } = string.Empty; public int VoteCount { get; set; } } diff --git a/backend/src/Contracts/Messaging/Domain/PollVote.cs b/backend/src/Contracts/Messaging/Domain/PollVote.cs index 80f59ed..ebecf3e 100644 --- a/backend/src/Contracts/Messaging/Domain/PollVote.cs +++ b/backend/src/Contracts/Messaging/Domain/PollVote.cs @@ -4,7 +4,7 @@ namespace Knot.Contracts.Messaging.Domain; public class PollVote { - public Guid OptionIndex { get; set; } + public Guid OptionId { get; set; } public Guid UserId { get; set; } public DateTime VotedAt { get; set; } } diff --git a/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs b/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs index c0b5f34..78c7350 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs @@ -27,9 +27,11 @@ public record ChatMessageDto( int? Duration = null, List? PollOptions = null, bool? PollIsMultipleChoice = null, - bool? PollIsClosed = null + bool? PollIsAnonymous = null, + bool? PollIsClosed = null, + List? UserVotedOptionIds = null ); -public record PollOptionDto(string Text, int VoteCount); +public record PollOptionDto(Guid Id, string Text, int VoteCount, List? Voters = null, List? VoterIds = null); diff --git a/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs b/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs index 747be4b..02a0523 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs @@ -27,7 +27,12 @@ public record MessageDetailDto( List Reactions, string? CallType = null, string? CallStatus = null, - int? Duration = null + int? Duration = null, + List? PollOptions = null, + bool? PollIsMultipleChoice = null, + bool? PollIsAnonymous = null, + bool? PollIsClosed = null, + List? UserVotedOptionIds = null ); public record ReplyToMessageDto( diff --git a/backend/src/Modules/Conversations/Application/DTOs/MessageMapper.cs b/backend/src/Modules/Conversations/Application/DTOs/MessageMapper.cs index 8fc90e0..b78ea9a 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/MessageMapper.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/MessageMapper.cs @@ -10,7 +10,8 @@ public static class MessageMapper Message message, IReadOnlyDictionary usersInfo, IEnumerable reactions, - IEnumerable readByUsers) + IEnumerable readByUsers, + Guid? currentUserId = null) { usersInfo.TryGetValue(message.SenderId, out var senderObj); @@ -60,9 +61,24 @@ public static class MessageMapper callMessage?.CallType, callMessage?.CallStatus, callMessage?.Duration, - (message as PollMessage)?.Options.Select(o => new PollOptionDto(o.Text, o.VoteCount)).ToList(), + message is PollMessage pm ? pm.Options.Select(o => { + var voters = pm.IsAnonymous == false + ? pm.Votes + .Where(v => v.OptionId == o.Id) + .Select(v => { + usersInfo.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() : null, (message as PollMessage)?.IsMultipleChoice, - (message as PollMessage)?.IsClosed + (message as PollMessage)?.IsAnonymous, + (message as PollMessage)?.IsClosed, + (message is PollMessage poll && currentUserId.HasValue) ? poll.Votes.Where(v => v.UserId == currentUserId.Value).Select(v => v.OptionId).ToList() : null ); } } diff --git a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs index a590b22..1feb14b 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs @@ -13,7 +13,7 @@ using MediatR; namespace Knot.Modules.Conversations.Application.Messages.GetMessages; -public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor) : IQuery>; +public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery>; internal sealed class GetMessagesQueryHandler : IQueryHandler> { @@ -38,24 +38,34 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler>(ChatErrors.ChatsForbidden); } - DateTime? cursorDate = null; - long? cursorSequenceId = null; + List 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(); - var userIdsToFetch = new HashSet(); var replyMessages = new Dictionary(); @@ -66,6 +76,14 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList() ?? new List(), - senderObj - ); + replyMessages.TryGetValue(message.ReplyToId.Value, out replyMsg); } - var reactionsWithUser = new List(); - var messageReactions = reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr : new List(); - 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(), + 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(), - 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(), + sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), + new List(), // 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(), (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); } } - - - diff --git a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs index 5accf6d..ca1c007 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs @@ -135,8 +135,9 @@ public sealed class SendMessageCommandHandler : ICommandHandler(ChatErrors.PollsDisabled); + if (chat.Type != ChatType.Group) return Result.Failure(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 +{ + 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 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(); + + 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(); + } +} diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs index da00410..ff848ca 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs @@ -15,6 +15,7 @@ using Knot.Contracts.Auth.Domain; using Knot.Contracts.Auth.Application.Abstractions; using Knot.Modules.Conversations.Application.Messages.Pin; using Knot.Modules.Conversations.Application.Messages.Unpin; +using Knot.Modules.Conversations.Application.Messages.Vote; using Knot.Modules.Conversations.Application.DTOs; using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Domain; @@ -283,6 +284,17 @@ public sealed class ChatHub : Hub }); } + [HubMethodName("vote_poll")] + public async Task VotePoll(VotePollRequest request) + { + var command = new VotePollCommand(request.MessageId, request.ChatId, _userContext.UserId, request.OptionId); + var result = await _sender.Send(command); + if (result.IsFailure) + { + throw new HubException(result.Error.Description); + } + } + // ──────────────────────────────────────────────────────────────── // Friend signals (Proxy methods for real-time notification) // ──────────────────────────────────────────────────────────────── @@ -843,6 +855,7 @@ public sealed class ChatHub : Hub public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer); public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer); public record FriendSignalRequest(string FriendId); + public record VotePollRequest(Guid MessageId, Guid ChatId, Guid OptionId); public class CallSession { diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs index c93b5d8..2ae5f75 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/MessageNotifier.cs @@ -5,4 +5,23 @@ using System.Threading; using System.Threading.Tasks; using Knot.Contracts.Messaging.Application.Abstractions; using Microsoft.AspNetCore.SignalR; -public class MessageNotifier : IMessageNotifier { private readonly IHubContext _hubContext; public MessageNotifier(IHubContext hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } } + +public class MessageNotifier : IMessageNotifier +{ + private readonly IHubContext _hubContext; + + public MessageNotifier(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) + { + return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); + } + + public Task NotifyMessageUpdateAsync(Guid chatId, string updateType, object updatePayload, CancellationToken cancellationToken) + { + return _hubContext.Clients.Group(chatId.ToString()).SendAsync(updateType, updatePayload, cancellationToken); + } +} diff --git a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs index f610259..eb19991 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs @@ -90,7 +90,11 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler new { id = o.Id, text = o.Text, voteCount = o.VoteCount }).ToList(), + pollIsMultipleChoice = (message as PollMessage)?.IsMultipleChoice, + pollIsClosed = (message as PollMessage)?.IsClosed, + pollIsAnonymous = (message as PollMessage)?.IsAnonymous }, cancellationToken); } } diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs index 4d55c33..1ca31c7 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs @@ -95,6 +95,36 @@ public sealed class MessageRepository : IMessageRepository .ToListAsync(cancellationToken); } + public async Task> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken) + { + var builder = Builders.Filter; + + // Target message + var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId)); + var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken); + + // Older messages + var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId)); + var older = await _messages.Find(olderFilter) + .SortByDescending(m => m.SequenceId) + .Limit(limit / 2) + .ToListAsync(cancellationToken); + + // Newer messages + var newerFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Gt(m => m.SequenceId, sequenceId)); + var newer = await _messages.Find(newerFilter) + .SortBy(m => m.SequenceId) + .Limit(limit / 2) + .ToListAsync(cancellationToken); + + var result = new List(); + result.AddRange(older); + if (targetMsg != null) result.Add(targetMsg); + result.AddRange(newer); + + return result.OrderBy(m => m.SequenceId).ToList(); + } + public async Task> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken) { // Not ideal for SQL/Mongo combination but keeping the signature diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index 0a00a34..69722ba 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -111,9 +111,11 @@ export interface Message { callType?: 'voice' | 'video' | string | null; callStatus?: 'missed' | 'completed' | 'cancelled' | 'declined' | string | null; duration?: number | null; - pollOptions?: Array<{ Text: string; VoteCount: number }>; + pollOptions?: Array<{ id: string; text: string; voteCount: number; voters?: Array<{ id: string; username: string; displayName: string; avatar?: string | null }>; voterIds?: string[] }>; pollIsMultipleChoice?: boolean; pollIsClosed?: boolean; + pollIsAnonymous?: boolean; + userVotedOptionIds?: string[]; } export interface Chat { diff --git a/client-web/src/core/infrastructure/i18n.ts b/client-web/src/core/infrastructure/i18n.ts index 3b6991f..d6f984e 100644 --- a/client-web/src/core/infrastructure/i18n.ts +++ b/client-web/src/core/infrastructure/i18n.ts @@ -169,6 +169,9 @@ const translations = { pollSettings: 'Настройки', anonymousVoting: 'Анонимное голосование', multipleAnswers: 'Выбор нескольких вариантов', + singleAnswer: 'Одиночный выбор', + anonymous: 'Анонимно', + votes: 'голосов', pollButton: 'Опрос', forwardMessage: 'Переслать сообщение', forward: 'Переслать', @@ -583,6 +586,9 @@ const translations = { pollSettings: 'Settings', anonymousVoting: 'Anonymous Voting', multipleAnswers: 'Multiple Answers', + singleAnswer: 'Single Answer', + anonymous: 'Anonymous', + votes: 'votes', pollButton: 'Poll', groupSettings: 'Group settings', editGroupName: 'Edit name', diff --git a/client-web/src/modules/chats/application/chatStore.ts b/client-web/src/modules/chats/application/chatStore.ts index e76a0ff..f9323bd 100644 --- a/client-web/src/modules/chats/application/chatStore.ts +++ b/client-web/src/modules/chats/application/chatStore.ts @@ -43,6 +43,7 @@ interface ChatState { clearMessages: (chatId: string) => void; setPinnedMessage: (chatId: string, message: Message) => void; removePinnedMessage: (chatId: string, messageId: string, newPinned?: Message[] | null) => void; + jumpToMessage: (chatId: string, sequenceId: number) => Promise; clearStore: () => void; } @@ -188,13 +189,13 @@ export const useChatStore = create((set, get) => ({ updateMessage: (message) => { set((state) => { const chatMessages = state.messages[message.chatId] || []; - const updatedMessages = chatMessages.map((m) => (m.id === message.id ? message : m)); + const updatedMessages = chatMessages.map((m) => (m.id === message.id ? { ...m, ...message } : m)); const updatedChats = state.chats.map((chat) => { if (chat.id === message.chatId) { return { ...chat, - messages: chat.messages?.map((m) => (m.id === message.id ? message : m)), + messages: chat.messages?.map((m) => (m.id === message.id ? { ...m, ...message } : m)), }; } return chat; @@ -545,6 +546,25 @@ export const useChatStore = create((set, get) => ({ }); }, + jumpToMessage: async (chatId, sequenceId) => { + try { + set({ isLoadingMessages: true }); + const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50); + + set((state) => ({ + messages: { ...state.messages, [chatId]: fetched }, + // Since we jumped, we assume there is more history to load above + hasMoreMessages: { ...state.hasMoreMessages, [chatId]: true }, + isLoadingMessages: false, + })); + } catch (error: any) { + console.error('Jump to message error:', error); + set({ isLoadingMessages: false }); + const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState(); + addNotification('error', error.message || 'Failed to jump to message'); + } + }, + clearStore: () => { set({ chats: [], diff --git a/client-web/src/modules/chats/infrastructure/chatApi.ts b/client-web/src/modules/chats/infrastructure/chatApi.ts index b2e943c..e03fc6d 100644 --- a/client-web/src/modules/chats/infrastructure/chatApi.ts +++ b/client-web/src/modules/chats/infrastructure/chatApi.ts @@ -20,9 +20,13 @@ export class ChatApi { }); } - static async getMessages(chatId: string, cursor?: string) { - const params = cursor ? `?cursor=${cursor}` : ''; - return httpClient.request(`/messages/chat/${chatId}${params}`); + static async getMessages(chatId: string, cursor?: string, pivot?: number, limit?: number) { + const params = new URLSearchParams(); + if (cursor) params.append('cursor', cursor); + if (pivot) params.append('pivot', pivot.toString()); + if (limit) params.append('limit', limit.toString()); + const query = params.toString() ? `?${params.toString()}` : ''; + return httpClient.request(`/messages/chat/${chatId}${query}`); } static async uploadFile(file: File) { diff --git a/client-web/src/modules/chats/presentation/ChatPage.tsx b/client-web/src/modules/chats/presentation/ChatPage.tsx index d5c3dc1..dd066f6 100644 --- a/client-web/src/modules/chats/presentation/ChatPage.tsx +++ b/client-web/src/modules/chats/presentation/ChatPage.tsx @@ -210,6 +210,10 @@ export default function ChatPage() { removePinnedMessage(data.chatId, data.messageId); }); + socket.on('poll_updated', (message: Message) => { + updateMessage(message); + }); + socket.on('call_incoming', async (data: CallInfo) => { // Use callerInfo from server if available, otherwise look up from chats let callerInfo: UserBasic | null = data.callerInfo || null; diff --git a/client-web/src/modules/chats/presentation/components/ChatView.tsx b/client-web/src/modules/chats/presentation/components/ChatView.tsx index 4ac7b95..c3d7699 100644 --- a/client-web/src/modules/chats/presentation/components/ChatView.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatView.tsx @@ -79,8 +79,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); - const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => { - cleanup?.(); + const handleJumpToMessage = async (msgId: string, sequenceId?: number) => { const tryScroll = () => { const el = document.getElementById(`msg-${msgId}`); if (el) { @@ -99,34 +98,26 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...'); const chatStore = useChatStore.getState(); - let found = false; - const targetDate = targetCreatedAt ? new Date(targetCreatedAt).getTime() : 0; - - for (let i = 0; i < 100; i++) { - const chatMessages = chatStore.messages[activeChat] || []; - const oldestLoaded = chatMessages.length > 0 ? new Date(chatMessages[0].createdAt).getTime() : Date.now(); - - if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) { + + if (sequenceId !== undefined) { + await chatStore.jumpToMessage(activeChat, sequenceId); + // Wait a bit for React to render + setTimeout(() => { + if (!tryScroll()) { + NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found'); + } + }, 300); + } else { + // Old fallback loop if no sequenceId + let found = false; + for (let i = 0; i < 50; i++) { + await chatStore.loadMessages(activeChat, false, true); + await new Promise(resolve => setTimeout(resolve, 150)); if (tryScroll()) { found = true; break; } } - - if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break; - - await chatStore.loadMessages(activeChat, false, true); - await new Promise(resolve => setTimeout(resolve, 150)); - - if (tryScroll()) { - found = true; - break; + if (!found) { + NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found'); } - - if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) { - if (i > 10) break; - } - } - - if (!found) { - NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found'); } }; const searchInputRef = useRef(null); @@ -455,17 +446,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const st = container.scrollTop; const isScrollingUp = st < lastScrollTopRef.current; - lastScrollTopRef.current = st; + const stChanged = st !== lastScrollTopRef.current; // Sticky Date Header Logic - Telegram style - if (st > 100 && (isScrollingUp || st !== lastScrollTopRef.current)) { - setShowStickyDate(true); - if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current); - stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), isScrollingUp ? 1500 : 1000); + if (st > 100 && stChanged) { + // Показываем плашку только при прокрутке или если она уже активна + // При прокрутке вверх она должна быть видна всегда + if (isScrollingUp) { + setShowStickyDate(true); + if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current); + // Таймер на 2 сек запустится только после остановки скролла + stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 2000); + } else { + // При прокрутке вниз плашка обычно скрывается быстрее + if (stickyDateTimerRef.current) { + clearTimeout(stickyDateTimerRef.current); + stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 800); + } + } } else if (st <= 100) { setShowStickyDate(false); + if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current); } + lastScrollTopRef.current = st; + const containerRect = container.getBoundingClientRect(); const messageElements = container.querySelectorAll('[data-message-id]'); @@ -1172,7 +1177,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal onClick={() => { const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]; if (!currentPin) return; - handleJumpToMessage(currentPin.id, undefined, currentPin.createdAt); + handleJumpToMessage(currentPin.id, currentPin.sequenceId); if (chatPinnedMessages.length > 1) { setPinnedIndex(prev => (prev + 1) % chatPinnedMessages.length); } @@ -1353,7 +1358,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal userId={profileUserId} chatId={activeChat || undefined} onClose={() => setProfileUserId(null)} - onGoToMessage={(msgId: any, createdAt: string) => handleJumpToMessage(msgId, () => setProfileUserId(null), createdAt)} + onGoToMessage={(msgId: any) => { handleJumpToMessage(msgId); setProfileUserId(null); }} isSelf={profileUserId === user?.id} /> )} @@ -1364,7 +1369,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal setShowGroupSettings(false)} - onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setShowGroupSettings(false))} + onGoToMessage={(msgId) => { handleJumpToMessage(msgId); setShowGroupSettings(false); }} /> )} diff --git a/client-web/src/modules/chats/presentation/components/GroupSettings.tsx b/client-web/src/modules/chats/presentation/components/GroupSettings.tsx index 2703bbe..d60ed11 100644 --- a/client-web/src/modules/chats/presentation/components/GroupSettings.tsx +++ b/client-web/src/modules/chats/presentation/components/GroupSettings.tsx @@ -35,7 +35,7 @@ import { getCroppedImg } from '../../../../core/infrastructure/imageCrop'; interface GroupSettingsProps { chat: Chat; onClose: () => void; - onGoToMessage?: (messageId: string) => void; + onGoToMessage?: (messageId: string, sequenceId?: number) => void; } export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSettingsProps) { @@ -256,7 +256,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet url: getMediaUrl(m.url), thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined, messageId: msg.id, - createdAt: msg.createdAt + createdAt: msg.createdAt, + sequenceId: msg.sequenceId }))); const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({ @@ -264,7 +265,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet url: getMediaUrl(m.url), thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined, messageId: msg.id, - createdAt: msg.createdAt + createdAt: msg.createdAt, + sequenceId: msg.sequenceId }))); const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); @@ -669,7 +671,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet /> )} - ); - })} + {(() => { + const hasVoted = (message.userVotedOptionIds && message.userVotedOptionIds.length > 0) || + message.pollOptions?.some(o => o.voterIds?.includes(user?.id || '')); + const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o.voteCount || 0), 0); + + return message.pollOptions.map((opt, idx) => { + const isVotedByMe = (message.userVotedOptionIds?.includes(opt.id)) || + opt.voterIds?.includes(user?.id || ''); + const percent = totalVotes > 0 ? Math.round(((opt.voteCount || 0) / totalVotes) * 100) : 0; + + return ( + + ); + }); + })()} + {(() => { + const hasVoted = (message.userVotedOptionIds && message.userVotedOptionIds.length > 0) || + message.pollOptions?.some(o => o.voterIds?.includes(user?.id || '')); + const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o.voteCount || 0), 0); + if (hasVoted) { + return
ВСЕГО ПРОГОЛОСОВАЛО: {totalVotes}
+ } + return null; + })()} )} {/* Текст */} - {message.content && (() => { + {message.content && message.type !== 'poll' && (() => { const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u; const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15; return ( diff --git a/client-web/src/modules/chats/presentation/components/MessageInput.tsx b/client-web/src/modules/chats/presentation/components/MessageInput.tsx index b7fa075..0ed3584 100644 --- a/client-web/src/modules/chats/presentation/components/MessageInput.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageInput.tsx @@ -802,7 +802,7 @@ export default function MessageInput({ chatId }: MessageInputProps) { {t('file')} - {(config?.messages?.allowPolls ?? true) && ( + {(config?.messages?.allowPolls ?? true) && isGroup && (