Опросы
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public interface IMessageRepository
|
||||
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, 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<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||
|
||||
@@ -7,26 +7,32 @@ public class PollMessage : Message
|
||||
{
|
||||
public override string Type => "poll";
|
||||
public override string? Content { get; protected set; }
|
||||
public List<PollOption> Options { get; } = new();
|
||||
public List<PollVote> Votes { get; } = new();
|
||||
public List<PollOption> Options { get; set; } = new();
|
||||
public List<PollVote> 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<string>? 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<PollOption>? 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<PollOption>();
|
||||
IsAnonymous = isAnonymous;
|
||||
IsMultipleChoice = isMultiple;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public static PollMessage Create(Guid id, Guid chatId, Guid senderId, string? question, List<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ public record ChatMessageDto(
|
||||
int? Duration = null,
|
||||
List<PollOptionDto>? PollOptions = null,
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsClosed = null
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollIsClosed = null,
|
||||
List<Guid>? UserVotedOptionIds = null
|
||||
);
|
||||
|
||||
public record PollOptionDto(string Text, int VoteCount);
|
||||
public record PollOptionDto(Guid Id, string Text, int VoteCount, List<MessageSenderDto>? Voters = null, List<Guid>? VoterIds = null);
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,12 @@ public record MessageDetailDto(
|
||||
List<MessageReactionDto> Reactions,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null
|
||||
int? Duration = null,
|
||||
List<PollOptionDto>? PollOptions = null,
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollIsClosed = null,
|
||||
List<Guid>? UserVotedOptionIds = null
|
||||
);
|
||||
|
||||
public record ReplyToMessageDto(
|
||||
|
||||
@@ -10,7 +10,8 @@ public static class MessageMapper
|
||||
Message message,
|
||||
IReadOnlyDictionary<Guid, UserInfo> usersInfo,
|
||||
IEnumerable<MessageReaction> reactions,
|
||||
IEnumerable<Guid> readByUsers)
|
||||
IEnumerable<Guid> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<ChatHub> _hubContext; public MessageNotifier(IHubContext<ChatHub> 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<ChatHub> _hubContext;
|
||||
|
||||
public MessageNotifier(IHubContext<ChatHub> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,11 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
||||
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
||||
callType = (message as CallMessage)?.CallType,
|
||||
callStatus = (message as CallMessage)?.CallStatus,
|
||||
duration = (message as CallMessage)?.Duration
|
||||
duration = (message as CallMessage)?.Duration,
|
||||
pollOptions = (message as PollMessage)?.Options.Select(o => 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,36 @@ public sealed class MessageRepository : IMessageRepository
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.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<Message>();
|
||||
result.AddRange(older);
|
||||
if (targetMsg != null) result.Add(targetMsg);
|
||||
result.AddRange(newer);
|
||||
|
||||
return result.OrderBy(m => m.SequenceId).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Not ideal for SQL/Mongo combination but keeping the signature
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void>;
|
||||
clearStore: () => void;
|
||||
}
|
||||
|
||||
@@ -188,13 +189,13 @@ export const useChatStore = create<ChatState>((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<ChatState>((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: [],
|
||||
|
||||
@@ -20,9 +20,13 @@ export class ChatApi {
|
||||
});
|
||||
}
|
||||
|
||||
static async getMessages(chatId: string, cursor?: string) {
|
||||
const params = cursor ? `?cursor=${cursor}` : '';
|
||||
return httpClient.request<Message[]>(`/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<Message[]>(`/messages/chat/${chatId}${query}`);
|
||||
}
|
||||
|
||||
static async uploadFile(file: File) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,8 +79,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(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<HTMLInputElement>(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
|
||||
<GroupSettings
|
||||
chat={chat}
|
||||
onClose={() => setShowGroupSettings(false)}
|
||||
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setShowGroupSettings(false))}
|
||||
onGoToMessage={(msgId) => { handleJumpToMessage(msgId); setShowGroupSettings(false); }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId, m.sequenceId); }}
|
||||
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')}
|
||||
@@ -714,7 +716,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId, m.sequenceId); }}
|
||||
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')}
|
||||
@@ -749,7 +751,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
<Download size={14} className="text-zinc-600" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
onClick={() => onGoToMessage?.(msg.id, msg.sequenceId)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-300 text-[11px] font-medium opacity-0 group-hover/file:opacity-100 transition-opacity"
|
||||
>
|
||||
{t('showInChat')}
|
||||
@@ -781,7 +783,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
))}
|
||||
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
onClick={() => onGoToMessage?.(msg.id, msg.sequenceId)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-black/40 hover:bg-knot-500/20 text-zinc-300 hover:text-white text-[11px] font-medium opacity-0 group-hover:opacity-100 transition-all shadow-md z-10"
|
||||
>
|
||||
{t('showInChat')}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '
|
||||
import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types';
|
||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||
import LinkPreview from './LinkPreview';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
@@ -892,56 +893,141 @@ function MessageBubble({
|
||||
{message.type === 'poll' && message.pollOptions && (
|
||||
<div className={`p-1.5 space-y-4 min-w-[260px] max-w-full ${isMine ? 'text-[#0a0a0a]' : 'text-zinc-200'}`}>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[15px] font-bold leading-tight flex items-start gap-2">
|
||||
<h4 className="text-[15px] font-bold leading-tight flex items-start gap-2 whitespace-pre-wrap break-words">
|
||||
<BarChart2 size={18} className="mt-0.5 shrink-0 opacity-60" />
|
||||
{message.content}
|
||||
</h4>
|
||||
<p className="text-[11px] font-medium opacity-50 uppercase tracking-widest pl-7">
|
||||
{message.pollIsMultipleChoice ? t('multipleAnswers') : t('singleAnswer' as any) || 'Выберите один вариант'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between pl-7 pr-2">
|
||||
<p className="text-[11px] font-medium opacity-50 uppercase tracking-widest">
|
||||
{message.pollIsMultipleChoice ? t('multipleAnswers') : t('singleAnswer')}
|
||||
</p>
|
||||
{message.pollIsAnonymous && (
|
||||
<span className="text-[10px] font-black uppercase tracking-tighter bg-black/5 px-2 py-0.5 rounded-sm opacity-40">
|
||||
{t('anonymous')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{message.pollOptions.map((opt, idx) => {
|
||||
const totalVotes = message.pollOptions!.reduce((sum, o) => sum + (o as any).voteCount, 0);
|
||||
const percent = totalVotes > 0 ? Math.round(((opt as any).voteCount / totalVotes) * 100) : 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
className={`w-full group/opt relative rounded-2xl border transition-all duration-300 overflow-hidden text-left p-3 flex flex-col gap-1.5
|
||||
${isMine
|
||||
? 'bg-[#0a0a0a]/5 border-[#0a0a0a]/10 hover:bg-[#0a0a0a]/10'
|
||||
: 'bg-white/5 border-white/5 hover:bg-white/10'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<span className="text-[14px] font-semibold truncate flex-1">{(opt as any).text}</span>
|
||||
<span className="text-[13px] font-black tabular-nums opacity-80">{percent}%</span>
|
||||
</div>
|
||||
|
||||
<div className="relative h-1.5 w-full bg-white/5 rounded-full overflow-hidden z-10">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${percent}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className={`absolute inset-0 rounded-full ${isMine ? 'bg-[#0a0a0a]/40' : 'bg-primary'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<span className="text-[10px] font-bold opacity-40 uppercase tracking-wider">
|
||||
{(opt as any).voteCount} {t('votes' as any) || 'голосов'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
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 (
|
||||
<button
|
||||
key={opt.id || idx}
|
||||
disabled={hasVoted && !message.pollIsMultipleChoice} // If already voted and not multiple choice, disable.
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (hasVoted && !message.pollIsMultipleChoice) return;
|
||||
|
||||
// Optimistic update for better UX
|
||||
const currentVoted = message.userVotedOptionIds || [];
|
||||
if (!currentVoted.includes(opt.id)) {
|
||||
useChatStore.getState().updateMessage({
|
||||
...message,
|
||||
userVotedOptionIds: message.pollIsMultipleChoice ? [...currentVoted, opt.id] : [opt.id]
|
||||
});
|
||||
}
|
||||
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('vote_poll', {
|
||||
messageId: message.id,
|
||||
chatId: message.chatId,
|
||||
optionId: opt.id
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`w-full group/opt relative rounded-2xl border transition-all duration-300 text-left p-3 flex flex-col gap-1.5
|
||||
${isMine
|
||||
? 'bg-[#0a0a0a]/5 border-[#0a0a0a]/10 hover:bg-[#0a0a0a]/10 active:scale-[0.98] hover:z-20'
|
||||
: 'bg-white/5 border-white/5 hover:bg-white/10 active:scale-[0.98] hover:z-20'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<div className="flex items-center gap-2 truncate flex-1">
|
||||
{isVotedByMe && <Check size={14} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />}
|
||||
<span className="text-[14px] font-semibold truncate">{opt.text}</span>
|
||||
</div>
|
||||
{hasVoted && (
|
||||
<span className="text-[13px] font-black tabular-nums opacity-80">{percent}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasVoted && (
|
||||
<>
|
||||
<div className="relative h-1.5 w-full bg-white/5 rounded-full overflow-hidden z-10">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: `${percent}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className={`absolute inset-0 rounded-full ${isMine ? 'bg-[#0a0a0a]/40' : 'bg-primary'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between relative z-10 font-bold uppercase tracking-widest text-[10px]">
|
||||
<span className="opacity-40">{opt.voteCount || 0} {t('votes')}</span>
|
||||
{opt.voters && opt.voters.length > 0 && (
|
||||
<div className="relative group/voters flex -space-x-1.5 hover:space-x-0.5 transition-all duration-300">
|
||||
{opt.voters.slice(0, 5).map((voter) => (
|
||||
<Avatar
|
||||
key={voter.id}
|
||||
src={voter.avatar}
|
||||
name={voter.displayName}
|
||||
size="xs"
|
||||
className="ring-1 ring-white/20 shadow-lg"
|
||||
/>
|
||||
))}
|
||||
{opt.voters.length > 5 && (
|
||||
<div className="w-4 h-4 rounded-lg bg-white/10 flex items-center justify-center text-[7px] font-black tabular-nums ring-1 ring-white/20 text-white/50">
|
||||
+{opt.voters.length - 5}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подробный список при наведении */}
|
||||
<div className="absolute bottom-full right-0 mb-3 opacity-0 group-hover/voters:opacity-100 transition-all duration-300 pointer-events-none scale-95 group-hover/voters:scale-100 origin-bottom-right z-50">
|
||||
<div className="bg-[#1b1b1b] shadow-2xl rounded-2xl p-2 border border-white/10 min-w-[160px] backdrop-blur-xl">
|
||||
<div className="space-y-1">
|
||||
{opt.voters.map(v => (
|
||||
<div key={v.id} className="flex items-center gap-2.5 p-1.5 hover:bg-white/10 rounded-xl transition-colors">
|
||||
<Avatar src={v.avatar} name={v.displayName} size="xs" />
|
||||
<span className="text-[11px] font-bold text-white/90 truncate">{v.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
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 <div className="text-[10px] font-black opacity-30 mt-2 px-2">ВСЕГО ПРОГОЛОСОВАЛО: {totalVotes}</div>
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Текст */}
|
||||
{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 (
|
||||
|
||||
@@ -802,7 +802,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</div>
|
||||
{t('file')}
|
||||
</button>
|
||||
{(config?.messages?.allowPolls ?? true) && (
|
||||
{(config?.messages?.allowPolls ?? true) && isGroup && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPollModal(true);
|
||||
|
||||
@@ -24,7 +24,7 @@ interface UserProfileProps {
|
||||
onMessage?: (userId: string) => void;
|
||||
isSelf?: boolean;
|
||||
chatId?: string;
|
||||
onGoToMessage?: (msgId: any, createdAt: string) => Promise<void>;
|
||||
onGoToMessage?: (msgId: string, sequenceId?: number) => void;
|
||||
}
|
||||
|
||||
type TabType = 'media' | 'gif' | 'files' | 'links';
|
||||
@@ -338,7 +338,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-opacity z-20">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.createdAt); }}
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.sequenceId); }}
|
||||
className="p-2.5 rounded-2xl bg-primary/95 text-white shadow-xl backdrop-blur-md hover:scale-110 active:scale-95 transition-all"
|
||||
title={t('showInChat')}
|
||||
>
|
||||
@@ -380,7 +380,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(item.id, item.createdAt)}
|
||||
onClick={() => onGoToMessage?.(item.id, item.sequenceId)}
|
||||
className="p-3 rounded-2xl bg-white/5 opacity-0 group-hover/row:opacity-100 text-white/40 hover:text-white hover:bg-primary/20 transition-all"
|
||||
title={t('showInChat')}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user