Заготовка опросов
This commit is contained in:
@@ -8,6 +8,7 @@ public interface IMessageRepository
|
||||
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
||||
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken);
|
||||
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);
|
||||
|
||||
@@ -63,6 +63,9 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
||||
}
|
||||
}
|
||||
|
||||
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
|
||||
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
@@ -88,55 +91,19 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
||||
var messagesList = new List<ChatMessageDto>();
|
||||
if (latestMessage != null)
|
||||
{
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
messagesList.Add(MessageMapper.MapToDto(
|
||||
latestMessage,
|
||||
usersInfo,
|
||||
latestReactions,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||
}
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestReactions)
|
||||
{
|
||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||
reactionsWithUser.Add(new ReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
reactionUser != null
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||
));
|
||||
}
|
||||
|
||||
var readByList = chat.Members
|
||||
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId)
|
||||
.Select(m => new ReadByDto(m.UserId))
|
||||
.ToList();
|
||||
|
||||
var textMessage = latestMessage as TextMessage;
|
||||
var mediaMessage = latestMessage as MediaMessage;
|
||||
var storyMessage = latestMessage as StoryMessage;
|
||||
|
||||
messagesList.Add(new ChatMessageDto(
|
||||
latestMessage.Id,
|
||||
latestMessage.ChatId,
|
||||
latestMessage.SenderId,
|
||||
latestMessage.Content,
|
||||
latestMessage.Type,
|
||||
latestMessage.ReplyToId,
|
||||
textMessage?.Quote,
|
||||
storyMessage?.StoryId,
|
||||
storyMessage?.StoryMediaUrl,
|
||||
storyMessage?.StoryMediaType,
|
||||
latestMessage.IsEdited,
|
||||
latestMessage.IsDeleted,
|
||||
latestMessage.CreatedAt,
|
||||
latestMessage.SequenceId,
|
||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
readByList
|
||||
var pinnedDtoList = new List<PinnedMessageDto>();
|
||||
foreach (var pm in pinnedMessages)
|
||||
{
|
||||
pinnedDtoList.Add(new PinnedMessageDto(
|
||||
pm.Id,
|
||||
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -152,6 +119,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
||||
chat.CreatedAt,
|
||||
members,
|
||||
messagesList,
|
||||
pinnedDtoList,
|
||||
unreadCount
|
||||
);
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
}
|
||||
}
|
||||
|
||||
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
|
||||
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
|
||||
|
||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||
|
||||
var members = new List<ChatMemberDto>();
|
||||
@@ -85,53 +88,19 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
|
||||
if (latestMessage != null)
|
||||
{
|
||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
||||
messagesList.Add(MessageMapper.MapToDto(
|
||||
latestMessage,
|
||||
usersInfo,
|
||||
latestReactions,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||
}
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in latestReactions)
|
||||
{
|
||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||
reactionsWithUser.Add(new ReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
reactionUser != null
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||
));
|
||||
}
|
||||
|
||||
var textMessage = latestMessage as TextMessage;
|
||||
var mediaMessage = latestMessage as MediaMessage;
|
||||
var storyMessage = latestMessage as StoryMessage;
|
||||
|
||||
messagesList.Add(new ChatMessageDto(
|
||||
latestMessage.Id,
|
||||
latestMessage.ChatId,
|
||||
latestMessage.SenderId,
|
||||
latestMessage.Content,
|
||||
latestMessage.Type,
|
||||
latestMessage.ReplyToId,
|
||||
textMessage?.Quote,
|
||||
storyMessage?.StoryId,
|
||||
storyMessage?.StoryMediaUrl,
|
||||
storyMessage?.StoryMediaType,
|
||||
latestMessage.IsEdited,
|
||||
latestMessage.IsDeleted,
|
||||
latestMessage.CreatedAt,
|
||||
latestMessage.SequenceId,
|
||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
|
||||
(latestMessage as CallMessage)?.CallType,
|
||||
(latestMessage as CallMessage)?.CallStatus,
|
||||
(latestMessage as CallMessage)?.Duration
|
||||
var pinnedDtoList = new List<PinnedMessageDto>();
|
||||
foreach (var pm in pinnedMessages)
|
||||
{
|
||||
pinnedDtoList.Add(new PinnedMessageDto(
|
||||
pm.Id,
|
||||
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -147,6 +116,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
chat.CreatedAt,
|
||||
members,
|
||||
messagesList,
|
||||
pinnedDtoList,
|
||||
unreadCount,
|
||||
chat.IsImporting,
|
||||
chat.ImportJobId
|
||||
|
||||
@@ -12,6 +12,7 @@ public record ChatDto(
|
||||
DateTime CreatedAt,
|
||||
List<ChatMemberDto> Members,
|
||||
List<ChatMessageDto> Messages,
|
||||
List<PinnedMessageDto> PinnedMessages,
|
||||
int UnreadCount,
|
||||
bool IsImporting = false,
|
||||
Guid? ImportJobId = null
|
||||
|
||||
@@ -24,6 +24,12 @@ public record ChatMessageDto(
|
||||
List<ReadByDto> ReadBy,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null
|
||||
int? Duration = null,
|
||||
List<PollOptionDto>? PollOptions = null,
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsClosed = null
|
||||
);
|
||||
|
||||
public record PollOptionDto(string Text, int VoteCount);
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ public record MediaDto(
|
||||
string Type,
|
||||
string? Url,
|
||||
string? Filename,
|
||||
long? Size
|
||||
long? Size,
|
||||
string? Duration = null
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
public static class MessageMapper
|
||||
{
|
||||
public static ChatMessageDto MapToDto(
|
||||
Message message,
|
||||
IReadOnlyDictionary<Guid, UserInfo> usersInfo,
|
||||
IEnumerable<MessageReaction> reactions,
|
||||
IEnumerable<Guid> readByUsers)
|
||||
{
|
||||
usersInfo.TryGetValue(message.SenderId, out var senderObj);
|
||||
|
||||
var reactionsWithUser = new List<ReactionDto>();
|
||||
foreach (var reaction in reactions)
|
||||
{
|
||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||
reactionsWithUser.Add(new ReactionDto(
|
||||
reaction.Id,
|
||||
reaction.Emoji,
|
||||
reaction.UserId,
|
||||
reactionUser != null
|
||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||
));
|
||||
}
|
||||
|
||||
var textMessage = message as TextMessage;
|
||||
var mediaMessage = message as MediaMessage;
|
||||
var storyMessage = message as StoryMessage;
|
||||
var callMessage = message as CallMessage;
|
||||
|
||||
return new ChatMessageDto(
|
||||
message.Id,
|
||||
message.ChatId,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.Type,
|
||||
message.ReplyToId,
|
||||
textMessage?.Quote,
|
||||
storyMessage?.StoryId,
|
||||
storyMessage?.StoryMediaUrl,
|
||||
storyMessage?.StoryMediaType,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
message.SequenceId,
|
||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList() ?? new List<MediaDto>(),
|
||||
senderObj != null ? new MessageSenderDto(
|
||||
senderObj.Id,
|
||||
senderObj.Username,
|
||||
senderObj.DisplayName,
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
readByUsers.Select(id => new ReadByDto(id)).ToList(),
|
||||
callMessage?.CallType,
|
||||
callMessage?.CallStatus,
|
||||
callMessage?.Duration,
|
||||
(message as PollMessage)?.Options.Select(o => new PollOptionDto(o.Text, o.VoteCount)).ToList(),
|
||||
(message as PollMessage)?.IsMultipleChoice,
|
||||
(message as PollMessage)?.IsClosed
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
public record PinnedMessageDto(
|
||||
Guid Id,
|
||||
ChatMessageDto Message
|
||||
);
|
||||
@@ -97,7 +97,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
||||
|
||||
if (filterType == "gifs") return isGif;
|
||||
if (filterType == "media") return (mType == "image" || mType == "video") && !isGif;
|
||||
if (filterType == "files") return mType == "file" || (mType != "image" && mType != "video" && mType != "link" && !isGif);
|
||||
if (filterType == "files") return (mType == "file" || mType == "audio") && !isGif && mType != "image" && mType != "video";
|
||||
if (filterType == "links") return mType == "link";
|
||||
|
||||
return true;
|
||||
@@ -119,7 +119,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
||||
storyMessage?.StoryMediaType,
|
||||
message.IsEdited,
|
||||
message.Type,
|
||||
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
|
||||
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Pin;
|
||||
|
||||
public sealed record PinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||
|
||||
public sealed class PinMessageCommandHandler : ICommandHandler<PinMessageCommand, Guid>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public PinMessageCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatRepository chatRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IMediator mediator)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(PinMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||
|
||||
// Security check
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
if (message.ChatId != request.ChatId)
|
||||
return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
message.AddState(MessageState.IsPinned);
|
||||
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify chat about pinned message change
|
||||
await _mediator.Publish(new MessagePinnedDomainEvent(message.Id, message.ChatId, message.SenderId, message.Content), cancellationToken);
|
||||
|
||||
return Result.Success(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public record MessagePinnedDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : INotification;
|
||||
@@ -0,0 +1,58 @@
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||
|
||||
public sealed record UnpinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||
|
||||
public sealed class UnpinMessageCommandHandler : ICommandHandler<UnpinMessageCommand, Guid>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public UnpinMessageCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
IChatRepository chatRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IMediator mediator)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(UnpinMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||
|
||||
// Security check
|
||||
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
if (message.ChatId != request.ChatId)
|
||||
return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||
|
||||
message.RemoveState(MessageState.IsPinned);
|
||||
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Notify chat about unpinned message change
|
||||
await _mediator.Publish(new MessageUnpinnedDomainEvent(message.Id, message.ChatId), cancellationToken);
|
||||
|
||||
return Result.Success(message.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public record MessageUnpinnedDomainEvent(Guid MessageId, Guid ChatId) : INotification;
|
||||
@@ -13,6 +13,12 @@ using Knot.Shared.Kernel;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
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.DTOs;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
|
||||
@@ -39,17 +45,29 @@ public sealed class ChatHub : Hub
|
||||
private readonly IUserContext _userContext;
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
|
||||
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, IUserRepository userRepository, ILogger<ChatHub> logger, IMemoryCache cache)
|
||||
public ChatHub(
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
IChatRepository chatRepository,
|
||||
IUserRepository userRepository,
|
||||
IMessageRepository messageRepository,
|
||||
ILogger<ChatHub> logger,
|
||||
IMemoryCache cache,
|
||||
IUserDisplayNameProvider userProvider)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
_chatRepository = chatRepository;
|
||||
_userRepository = userRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_userProvider = userProvider;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
@@ -112,14 +130,18 @@ public sealed class ChatHub : Hub
|
||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||
|
||||
var command = new SendMessageCommand(
|
||||
request.ChatId,
|
||||
_userContext.UserId,
|
||||
request.Content,
|
||||
request.Type,
|
||||
attachments,
|
||||
request.ReplyToId,
|
||||
request.Quote,
|
||||
request.ForwardedFromId);
|
||||
ChatId: request.ChatId,
|
||||
SenderId: _userContext.UserId,
|
||||
Content: request.Content,
|
||||
Type: request.Type,
|
||||
Attachments: attachments,
|
||||
ReplyToId: request.ReplyToId,
|
||||
Quote: request.Quote,
|
||||
ForwardedFromId: request.ForwardedFromId,
|
||||
PollOptions: request.PollOptions,
|
||||
PollIsAnonymous: request.PollIsAnonymous,
|
||||
PollAllowMultipleAnswers: request.PollAllowMultipleAnswers
|
||||
);
|
||||
|
||||
await _sender.Send(command);
|
||||
}
|
||||
@@ -227,6 +249,40 @@ public sealed class ChatHub : Hub
|
||||
_logger.LogInformation("RemoveReaction completed successfully");
|
||||
}
|
||||
|
||||
[HubMethodName("pin_message")]
|
||||
public async Task PinMessage(PinMessageRequest request)
|
||||
{
|
||||
var command = new PinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
||||
var result = await _sender.Send(command);
|
||||
var message = await _messageRepository.GetByIdAsync(request.MessageId, Context.ConnectionAborted);
|
||||
if (message != null)
|
||||
{
|
||||
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
|
||||
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
|
||||
|
||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
message = dto,
|
||||
userId = _userContext.UserId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HubMethodName("unpin_message")]
|
||||
public async Task UnpinMessage(PinMessageRequest request)
|
||||
{
|
||||
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
||||
var result = await _sender.Send(command);
|
||||
|
||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageId = request.MessageId,
|
||||
userId = _userContext.UserId
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Friend signals (Proxy methods for real-time notification)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -648,7 +704,7 @@ public sealed class ChatHub : Hub
|
||||
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
userId = Context.UserIdentifier,
|
||||
userId = _userContext.UserId.ToString(),
|
||||
isMuted = request.IsMuted,
|
||||
isVideoOff = request.IsVideoOff
|
||||
});
|
||||
@@ -675,7 +731,7 @@ public sealed class ChatHub : Hub
|
||||
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
|
||||
{
|
||||
chatId = chatId,
|
||||
userId = Context.UserIdentifier,
|
||||
userId = _userContext.UserId.ToString(),
|
||||
isMuted = isMuted,
|
||||
isVideoOff = isVideoOff
|
||||
});
|
||||
@@ -759,7 +815,10 @@ public sealed class ChatHub : Hub
|
||||
List<AttachmentHubRequest>? Attachments = null,
|
||||
Guid? ReplyToId = null,
|
||||
string? Quote = null,
|
||||
Guid? ForwardedFromId = null);
|
||||
Guid? ForwardedFromId = null,
|
||||
List<string>? PollOptions = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollAllowMultipleAnswers = null);
|
||||
public record ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
|
||||
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
||||
public record CallAnswerRequest(string TargetUserId, object Answer);
|
||||
@@ -771,6 +830,7 @@ public sealed class ChatHub : Hub
|
||||
public record CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
|
||||
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||
public record PinMessageRequest(Guid MessageId, Guid ChatId);
|
||||
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
||||
public record GroupCallJoinRequest(string ChatId, string CallType);
|
||||
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
|
||||
|
||||
@@ -62,6 +62,19 @@ public sealed class MessageRepository : IMessageRepository
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.Filter;
|
||||
var filter = builder.And(
|
||||
builder.Eq(m => m.ChatId, chatId),
|
||||
builder.BitsAnySet(m => m.State, (long)MessageState.IsPinned)
|
||||
);
|
||||
|
||||
return await _messages.Find(filter)
|
||||
.SortByDescending(m => m.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.Filter;
|
||||
|
||||
@@ -190,7 +190,7 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
||||
string fileName = "";
|
||||
var titleNode = link.QuerySelector(".title") ?? link.QuerySelector(".name") ?? link.QuerySelector(".description");
|
||||
|
||||
if (titleNode != null)
|
||||
if (titleNode != null && !titleNode.TextContent.Contains(":") && titleNode.TextContent.Length < 100)
|
||||
{
|
||||
fileName = titleNode.TextContent.Trim();
|
||||
}
|
||||
@@ -202,7 +202,8 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
||||
{
|
||||
var clone = (IElement)bodyNode.Clone();
|
||||
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right")) s.Remove();
|
||||
fileName = clone.TextContent.Trim();
|
||||
var candidate = clone.TextContent.Trim();
|
||||
if (candidate.Length > 0 && candidate.Length < 100 && !candidate.Contains(":")) fileName = candidate;
|
||||
}
|
||||
|
||||
// Final desperate attempt: link's own content excluding status tags
|
||||
@@ -210,7 +211,8 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
||||
{
|
||||
var clone = (IElement)link.Clone();
|
||||
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right, .details_icon")) s.Remove();
|
||||
fileName = clone.TextContent.Trim();
|
||||
var candidate = clone.TextContent.Trim();
|
||||
if (candidate.Length > 0 && candidate.Length < 100 && !candidate.Contains(":")) fileName = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,9 @@ export interface Message {
|
||||
callType?: 'voice' | 'video' | string | null;
|
||||
callStatus?: 'missed' | 'completed' | 'cancelled' | 'declined' | string | null;
|
||||
duration?: number | null;
|
||||
pollOptions?: Array<{ Text: string; VoteCount: number }>;
|
||||
pollIsMultipleChoice?: boolean;
|
||||
pollIsClosed?: boolean;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
|
||||
@@ -158,6 +158,18 @@ const translations = {
|
||||
pinMessage: 'Закрепить',
|
||||
unpinMessage: 'Открепить',
|
||||
pinnedMessage: 'Закреплённое сообщение',
|
||||
poll: 'Опрос',
|
||||
pollTab: 'Опросы',
|
||||
createPoll: 'Создать опрос',
|
||||
pollQuestion: 'Вопрос',
|
||||
pollQuestionPlaceholder: 'Задайте вопрос...',
|
||||
pollOptions: 'Варианты ответа',
|
||||
pollOption: 'Вариант',
|
||||
addOption: 'Добавить вариант',
|
||||
pollSettings: 'Настройки',
|
||||
anonymousVoting: 'Анонимное голосование',
|
||||
multipleAnswers: 'Выбор нескольких вариантов',
|
||||
pollButton: 'Опрос',
|
||||
forwardMessage: 'Переслать сообщение',
|
||||
forward: 'Переслать',
|
||||
forwardedFrom: 'Переслано от',
|
||||
@@ -182,7 +194,7 @@ const translations = {
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||
pinChat: 'Закрепить чат',
|
||||
unpinChat: 'Открепить чат',
|
||||
chatCleared: 'Чат очищен',
|
||||
chatCleared: 'Очищено',
|
||||
typeYourStoryPlaceholder: 'Напишите историю...',
|
||||
uploadMedia: 'Загрузить медиа',
|
||||
chooseBackground: 'Цвет фона',
|
||||
@@ -560,6 +572,18 @@ const translations = {
|
||||
pinChat: 'Pin chat',
|
||||
unpinChat: 'Unpin chat',
|
||||
chatCleared: 'Chat cleared',
|
||||
poll: 'Poll',
|
||||
pollTab: 'Polls',
|
||||
createPoll: 'Create Poll',
|
||||
pollQuestion: 'Question',
|
||||
pollQuestionPlaceholder: 'Ask a question...',
|
||||
pollOptions: 'Options',
|
||||
pollOption: 'Option',
|
||||
addOption: 'Add Option',
|
||||
pollSettings: 'Settings',
|
||||
anonymousVoting: 'Anonymous Voting',
|
||||
multipleAnswers: 'Multiple Answers',
|
||||
pollButton: 'Poll',
|
||||
groupSettings: 'Group settings',
|
||||
editGroupName: 'Edit name',
|
||||
addMember: 'Add member',
|
||||
|
||||
@@ -244,6 +244,7 @@ const translations = {
|
||||
telegramImport: 'Telegram Import',
|
||||
serverDesc: 'Server Description',
|
||||
successSave: 'Settings saved',
|
||||
successClean: 'Cleanup completed',
|
||||
errorSave: 'Save failed',
|
||||
errorInvalidLogin: 'Invalid credentials',
|
||||
errorDelete: 'Delete failed',
|
||||
@@ -414,6 +415,7 @@ const translations = {
|
||||
telegramImport: 'Импорт Telegram',
|
||||
serverDesc: 'Описание сервера',
|
||||
successSave: 'Сохранено',
|
||||
successClean: 'Очищено',
|
||||
errorSave: 'Ошибка сохранения',
|
||||
errorInvalidLogin: 'Неверный логин или пароль',
|
||||
errorDelete: 'Ошибка удаления',
|
||||
@@ -745,7 +747,7 @@ export default function AdminPage() {
|
||||
const handleRunCleanup = async () => {
|
||||
try {
|
||||
await httpClient.request('/admin/clean/run', { method: 'POST' });
|
||||
showToast(t.successSave, 'success');
|
||||
showToast(t.successClean, 'success');
|
||||
setCleanStats(null);
|
||||
fetchDashboard();
|
||||
} catch { showToast(t.errorSave, 'error'); }
|
||||
|
||||
@@ -7,7 +7,7 @@ interface ChatState {
|
||||
chats: Chat[];
|
||||
activeChat: string | null;
|
||||
messages: Record<string, Message[]>;
|
||||
pinnedMessages: Record<string, Message>;
|
||||
pinnedMessages: Record<string, Message[]>;
|
||||
typingUsers: TypingUser[];
|
||||
replyTo: Message | null;
|
||||
editingMessage: Message | null;
|
||||
@@ -42,7 +42,7 @@ interface ChatState {
|
||||
removeChat: (chatId: string) => void;
|
||||
clearMessages: (chatId: string) => void;
|
||||
setPinnedMessage: (chatId: string, message: Message) => void;
|
||||
removePinnedMessage: (chatId: string, messageId: string, newPinned: Message | null) => void;
|
||||
removePinnedMessage: (chatId: string, messageId: string, newPinned?: Message[] | null) => void;
|
||||
clearStore: () => void;
|
||||
}
|
||||
|
||||
@@ -99,10 +99,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
} catch { }
|
||||
}
|
||||
// Extract pinned messages from chats
|
||||
const pinnedMessages: Record<string, Message> = {};
|
||||
const pinnedMessages: Record<string, Message[]> = {};
|
||||
for (const chat of chats) {
|
||||
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
|
||||
pinnedMessages[chat.id] = chat.pinnedMessages[0].message;
|
||||
pinnedMessages[chat.id] = chat.pinnedMessages.map((pm: any) => pm.message);
|
||||
}
|
||||
}
|
||||
set({ chats, pinnedMessages, isLoadingChats: false });
|
||||
@@ -519,18 +519,27 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
},
|
||||
|
||||
setPinnedMessage: (chatId, message) => {
|
||||
set((state) => ({
|
||||
pinnedMessages: { ...state.pinnedMessages, [chatId]: message },
|
||||
}));
|
||||
set((state) => {
|
||||
const existing = state.pinnedMessages[chatId] || [];
|
||||
if (existing.some(m => m.id === message.id)) return state;
|
||||
return {
|
||||
pinnedMessages: {
|
||||
...state.pinnedMessages,
|
||||
[chatId]: [...existing, message]
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removePinnedMessage: (chatId, _messageId, newPinned) => {
|
||||
removePinnedMessage: (chatId, messageId, newPinned?) => {
|
||||
set((state) => {
|
||||
const updated = { ...state.pinnedMessages };
|
||||
if (newPinned) {
|
||||
updated[chatId] = newPinned;
|
||||
} else {
|
||||
delete updated[chatId];
|
||||
const filtered = (updated[chatId] || []).filter(m => m.id !== messageId);
|
||||
if (filtered.length === 0) delete updated[chatId];
|
||||
else updated[chatId] = filtered;
|
||||
}
|
||||
return { pinnedMessages: updated };
|
||||
});
|
||||
|
||||
@@ -206,8 +206,8 @@ export default function ChatPage() {
|
||||
setPinnedMessage(data.chatId, data.message);
|
||||
});
|
||||
|
||||
socket.on('message_unpinned', (data: { chatId: string; messageId: string; newPinnedMessage: Message | null }) => {
|
||||
removePinnedMessage(data.chatId, data.messageId, data.newPinnedMessage);
|
||||
socket.on('message_unpinned', (data: { chatId: string; messageId: string }) => {
|
||||
removePinnedMessage(data.chatId, data.messageId);
|
||||
});
|
||||
|
||||
socket.on('call_incoming', async (data: CallInfo) => {
|
||||
|
||||
@@ -77,7 +77,58 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
|
||||
cleanup?.();
|
||||
const tryScroll = () => {
|
||||
const el = document.getElementById(`msg-${msgId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('highlight-message');
|
||||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (tryScroll()) return;
|
||||
if (!activeChat) return;
|
||||
|
||||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||
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 (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 (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);
|
||||
const topMenuRef = useRef<HTMLDivElement>(null);
|
||||
const deleteMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -87,7 +138,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const allChatMessages = activeChat ? messages[activeChat] || [] : [];
|
||||
// Filter out deleted messages to prevent layout shifts
|
||||
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
|
||||
const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null;
|
||||
const chatPinnedMessages = activeChat ? pinnedMessages[activeChat] || [] : [];
|
||||
const [pinnedIndex, setPinnedIndex] = useState(0);
|
||||
|
||||
const [importStatus, setImportStatus] = useState<{ processed: number, total: number, status: string } | null>(null);
|
||||
const isAtBottomRef = useRef(false);
|
||||
@@ -213,8 +265,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const scrollToBottom = useCallback((smooth = true) => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||||
} else if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||
} else if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -225,7 +277,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (ЯКОРНОЕ ПО MESSAGE ID)
|
||||
const saveScrollPosition = useCallback((targetChatId?: string) => {
|
||||
const container = messagesContainerRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
const chatId = targetChatId || activeChat;
|
||||
|
||||
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||
@@ -233,7 +285,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
// Проверка: сообщения в стейте должны быть от целевого чата
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
|
||||
|
||||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||||
isAtBottomRef.current = isAtBottomNow;
|
||||
|
||||
if (isAtBottomNow) {
|
||||
@@ -268,7 +320,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || !activeChat) return false;
|
||||
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
|
||||
@@ -323,8 +375,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
// 3. ОБЗЕРВЕР И УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ
|
||||
useEffect(() => {
|
||||
if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return;
|
||||
const container = messagesContainerRef.current;
|
||||
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
|
||||
const container = scrollContainerRef.current;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isInitializingRef.current) return;
|
||||
@@ -361,15 +413,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
isScrollingToBottomRef.current = false;
|
||||
|
||||
// Сохраняем позицию старого чата
|
||||
if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) {
|
||||
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
|
||||
saveScrollPosition(prevChatIdRef.current);
|
||||
}
|
||||
|
||||
setScrollReady(false);
|
||||
prevChatIdRef.current = activeChat;
|
||||
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = 0;
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
@@ -381,7 +433,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
}, [activeChat, saveScrollPosition, scrollReady]);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || isInitializingRef.current) return;
|
||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||
setShowScrollDown(!isNearBottom);
|
||||
@@ -392,10 +444,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||
|
||||
checkScrollPosition();
|
||||
const container = messagesContainerRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (container && activeChat) {
|
||||
// Synchronous "at bottom" check to prevent ResizeObserver from fighting the user
|
||||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||||
isAtBottomRef.current = isAtBottomNow;
|
||||
|
||||
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
||||
@@ -405,12 +457,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const isScrollingUp = st < lastScrollTopRef.current;
|
||||
lastScrollTopRef.current = st;
|
||||
|
||||
// Sticky Date Header Logic - Telegram style: show when scrolling, especially up
|
||||
if (st > 100) {
|
||||
// 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), 2000);
|
||||
} else {
|
||||
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), isScrollingUp ? 1500 : 1000);
|
||||
} else if (st <= 100) {
|
||||
setShowStickyDate(false);
|
||||
}
|
||||
|
||||
@@ -451,9 +503,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
localStorage.removeItem(`chat_anchor_${activeChat}`);
|
||||
|
||||
isScrollingToBottomRef.current = true;
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTo({
|
||||
top: messagesContainerRef.current.scrollHeight,
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
@@ -514,7 +566,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
}
|
||||
},
|
||||
{
|
||||
root: messagesContainerRef.current,
|
||||
root: scrollContainerRef.current,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
@@ -522,9 +574,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
observerRef.current = observer;
|
||||
|
||||
const observeUnread = () => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||
unreadElements.forEach((el) => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
|
||||
unreadElements.forEach((el: Element) => {
|
||||
const id = el.getAttribute('data-message-id');
|
||||
if (id && !sentReadIdsRef.current.has(id)) {
|
||||
observer.observe(el);
|
||||
@@ -537,9 +589,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
}, [activeChat, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (observerRef.current && messagesContainerRef.current) {
|
||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
||||
unreadElements.forEach((el) => {
|
||||
if (observerRef.current && scrollContainerRef.current) {
|
||||
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
|
||||
unreadElements.forEach((el: Element) => {
|
||||
const id = el.getAttribute('data-message-id');
|
||||
if (id && !sentReadIdsRef.current.has(id)) {
|
||||
observerRef.current?.observe(el);
|
||||
@@ -949,6 +1001,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
try {
|
||||
await ChatApi.clearChat(activeChat);
|
||||
useChatStore.getState().clearMessages(activeChat);
|
||||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('success', t('chatCleared'));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -1100,37 +1154,62 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pinnedMsg && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.getElementById(`msg-${pinnedMsg.id}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('highlight-message');
|
||||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-2 border-b border-outline/10 bg-surface-container-high/60 hover:bg-surface-container-highest transition-colors text-left w-full flex-shrink-0"
|
||||
>
|
||||
<Pin size={16} className="text-primary flex-shrink-0 rotate-45" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-primary">{t('pinnedMessage')}</p>
|
||||
<p className="text-sm text-zinc-300 truncate">
|
||||
{pinnedMsg.content || (pinnedMsg.media?.length > 0 ? t('media') : '...')}
|
||||
</p>
|
||||
</div>
|
||||
<X
|
||||
size={16}
|
||||
className="text-zinc-500 hover:text-white flex-shrink-0 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const socket = getSocket();
|
||||
if (socket && activeChat) {
|
||||
socket.emit('unpin_message', { messageId: pinnedMsg.id, chatId: activeChat });
|
||||
{chatPinnedMessages.length > 0 && (
|
||||
<div className="flex-shrink-0 flex items-center gap-0 border-b border-outline/10 bg-surface-container-high/60 hover:bg-surface-container-high transition-colors overflow-hidden h-[54px] relative">
|
||||
{/* Cycling progress indicator for multiple pins */}
|
||||
{chatPinnedMessages.length > 1 && (
|
||||
<div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden">
|
||||
{chatPinnedMessages.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
|
||||
if (!currentPin) return;
|
||||
handleJumpToMessage(currentPin.id, undefined, currentPin.createdAt);
|
||||
if (chatPinnedMessages.length > 1) {
|
||||
setPinnedIndex(prev => (prev + 1) % chatPinnedMessages.length);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
className={`flex-1 flex items-center gap-3 px-4 py-2 text-left h-full ${chatPinnedMessages.length > 1 ? 'ml-1.5' : ''}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Pin size={14} className="text-primary rotate-45" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-black text-primary uppercase tracking-wider">
|
||||
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-300 truncate font-medium">
|
||||
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
|
||||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center px-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const socket = getSocket();
|
||||
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
|
||||
if (socket && activeChat && currentPin) {
|
||||
socket.emit('unpin_message', { messageId: currentPin.id, chatId: activeChat });
|
||||
}
|
||||
}}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-zinc-500 hover:text-white hover:bg-white/5 transition-all"
|
||||
title={t('unpin' as any) || 'Открепить'}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
||||
@@ -1142,7 +1221,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
exit={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
key="sticky-date"
|
||||
className="absolute top-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-none"
|
||||
className="absolute top-6 left-1/2 -translate-x-1/2 z-[999] pointer-events-none"
|
||||
>
|
||||
<span className="px-4 py-1.5 rounded-full text-[11px] font-black uppercase tracking-widest text-white bg-black/60 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)] border border-white/10 ring-2 ring-black/20 whitespace-nowrap">
|
||||
{stickyDate}
|
||||
@@ -1152,7 +1231,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 scroll-smooth-container ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
|
||||
>
|
||||
@@ -1253,7 +1332,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe">
|
||||
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe relative z-50">
|
||||
<MessageInput chatId={activeChat} />
|
||||
</footer>
|
||||
</>
|
||||
@@ -1266,57 +1345,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
|
||||
cleanup?.();
|
||||
const tryScroll = () => {
|
||||
const el = document.getElementById(`msg-${msgId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('highlight-message');
|
||||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (tryScroll()) return;
|
||||
if (!activeChat) return;
|
||||
|
||||
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||
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 (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 (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
|
||||
if (i > 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Plus, Trash2, BarChart2 } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
|
||||
interface CreatePollModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSend: (data: {
|
||||
question: string;
|
||||
options: string[];
|
||||
isAnonymous: boolean;
|
||||
allowMultiple: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export default function CreatePollModal({ isOpen, onClose, onSend }: CreatePollModalProps) {
|
||||
const { t } = useLang();
|
||||
const [question, setQuestion] = useState('');
|
||||
const [options, setOptions] = useState(['', '']);
|
||||
const [isAnonymous, setIsAnonymous] = useState(true);
|
||||
const [allowMultiple, setAllowMultiple] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddOption = () => {
|
||||
if (options.length < 10) {
|
||||
setOptions([...options, '']);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
if (options.length > 2) {
|
||||
const newOptions = [...options];
|
||||
newOptions.splice(index, 1);
|
||||
setOptions(newOptions);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionChange = (index: number, value: string) => {
|
||||
const newOptions = [...options];
|
||||
newOptions[index] = value;
|
||||
setOptions(newOptions);
|
||||
};
|
||||
|
||||
const isValid = question.trim() && options.filter(o => o.trim()).length >= 2;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid) return;
|
||||
onSend({
|
||||
question: question.trim(),
|
||||
options: options.filter(o => o.trim()),
|
||||
isAnonymous,
|
||||
allowMultiple
|
||||
});
|
||||
// Reset and close
|
||||
setQuestion('');
|
||||
setOptions(['', '']);
|
||||
setIsAnonymous(true);
|
||||
setAllowMultiple(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[100000] flex items-center justify-center p-4 isolate">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 30 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 30 }}
|
||||
className="relative w-full max-w-md max-h-[85vh] bg-[#1a1a1a] rounded-[2.5rem] shadow-2xl flex flex-col overflow-hidden border border-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
<BarChart2 className="text-primary" size={22} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">
|
||||
{t('createPoll')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-zinc-400 hover:bg-white/5 hover:text-white transition-all"
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-6 space-y-7 overflow-y-auto custom-scrollbar">
|
||||
{/* Question */}
|
||||
<div className="space-y-2.5">
|
||||
<label className="text-[11px] font-black uppercase tracking-[0.1em] text-primary/80 px-1">
|
||||
{t('pollQuestion')}
|
||||
</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
placeholder={t('pollQuestionPlaceholder')}
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/5 rounded-2xl p-4 text-sm text-white placeholder:text-white/20 focus:outline-none focus:border-primary/30 focus:bg-white/[0.06] transition-all resize-none min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[11px] font-black uppercase tracking-[0.1em] text-primary/80 px-1">
|
||||
{t('pollOptions')}
|
||||
</label>
|
||||
<div className="space-y-2.5">
|
||||
{options.map((option, idx) => (
|
||||
<div key={idx} className="flex gap-2.5 group">
|
||||
<input
|
||||
placeholder={`${t('pollOption')} ${idx + 1}`}
|
||||
value={option}
|
||||
onChange={(e) => handleOptionChange(idx, e.target.value)}
|
||||
className="flex-1 bg-white/[0.03] border border-white/5 rounded-2xl px-5 py-3.5 text-sm text-white placeholder:text-white/20 focus:outline-none focus:border-primary/30 focus:bg-white/[0.06] transition-all"
|
||||
/>
|
||||
{options.length > 2 && (
|
||||
<button
|
||||
onClick={() => handleRemoveOption(idx)}
|
||||
className="w-12 h-12 rounded-2xl bg-red-500/10 text-red-400 flex items-center justify-center hover:bg-red-500/20 transition-all opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{options.length < 10 && (
|
||||
<button
|
||||
onClick={handleAddOption}
|
||||
className="flex items-center gap-2.5 text-sm font-bold text-primary hover:text-primary/80 transition-colors px-1 h-10"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>{t('addOption')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="pt-2 space-y-4">
|
||||
<label className="text-[11px] font-black uppercase tracking-[0.1em] text-zinc-500 px-1">
|
||||
{t('pollSettings')}
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between p-4.5 rounded-[1.25rem] bg-white/[0.02] border border-white/5 cursor-pointer hover:bg-white/[0.05] transition-all group">
|
||||
<span className="text-[15px] font-medium text-zinc-300 group-hover:text-white transition-colors">{t('anonymousVoting')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAnonymous}
|
||||
onChange={(e) => setIsAnonymous(e.target.checked)}
|
||||
className="w-5 h-5 rounded-md accent-primary"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between p-4.5 rounded-[1.25rem] bg-white/[0.02] border border-white/5 cursor-pointer hover:bg-white/[0.05] transition-all group">
|
||||
<span className="text-[15px] font-medium text-zinc-300 group-hover:text-white transition-colors">{t('multipleAnswers')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowMultiple}
|
||||
onChange={(e) => setAllowMultiple(e.target.checked)}
|
||||
className="w-5 h-5 rounded-md accent-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 pt-3">
|
||||
<button
|
||||
disabled={!isValid}
|
||||
onClick={handleSubmit}
|
||||
className="w-full h-14 rounded-2xl bg-primary text-white text-[16px] font-black shadow-2xl shadow-primary/20 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-30 disabled:grayscale disabled:scale-100 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<BarChart2 size={22} className="fill-white/20" />
|
||||
{t('createPoll')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
PhoneMissed,
|
||||
PhoneIncoming,
|
||||
PhoneOutgoing,
|
||||
BarChart2,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
@@ -164,7 +165,7 @@ function MessageBubble({
|
||||
? otherMember?.user.displayName || otherMember?.user.userName || otherMember?.user.username || ''
|
||||
: '';
|
||||
|
||||
const isPinned = pinnedMessages[message.chatId]?.id === message.id;
|
||||
const isPinned = (pinnedMessages[message.chatId] || []).some(m => m.id === message.id);
|
||||
|
||||
const handlePin = () => {
|
||||
const socket = getSocket();
|
||||
@@ -326,7 +327,8 @@ function MessageBubble({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end justify-between self-stretch pt-0.5">
|
||||
<div className="text-[10px] font-bold tracking-tight text-white/30 tabular-nums uppercase">
|
||||
<div className="text-[10px] font-bold tracking-tight text-white/30 tabular-nums uppercase flex items-center gap-1">
|
||||
{isPinned && <Pin size={10} className="rotate-45 text-primary fill-primary/20" />}
|
||||
{timeStr}
|
||||
</div>
|
||||
{isMine && (
|
||||
@@ -687,6 +689,7 @@ function MessageBubble({
|
||||
{!message.content && (
|
||||
<div className="absolute bottom-1.5 right-1.5 z-10 pointer-events-none flex justify-end">
|
||||
<span className="text-[10px] font-bold text-on-surface-variant/40 bg-surface-container-highest/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-md pointer-events-auto">
|
||||
{isPinned && <Pin size={10} className="rotate-45 text-primary fill-primary/40" />}
|
||||
{timeStr}
|
||||
{isMine && !message.scheduledAt && (
|
||||
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-primary fill-1' : 'text-on-surface-variant/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
|
||||
@@ -885,6 +888,58 @@ 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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Текст */}
|
||||
{message.content && (() => {
|
||||
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
||||
@@ -904,6 +959,7 @@ function MessageBubble({
|
||||
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-[#0a0a0a]/50' : 'text-on-surface-variant/40'}`}>
|
||||
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
|
||||
{message.scheduledAt && <span className="material-symbols-outlined text-[12px] text-amber-400 mr-0.5">schedule</span>}
|
||||
{isPinned && <Pin size={10} className={`rotate-45 ${isMine ? 'text-[#0a0a0a]/60 fill-[#0a0a0a]/20' : 'text-primary fill-primary/20'} mr-0.5`} />}
|
||||
{timeStr}
|
||||
{isMine && !message.scheduledAt && (
|
||||
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-[#0a0a0a]/80 fill-1' : 'text-[#0a0a0a]/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
Check,
|
||||
BarChart2,
|
||||
} from 'lucide-react';
|
||||
import { useChatStore } from '../../application/chatStore';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
@@ -25,6 +26,7 @@ import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { AUDIO_EXTENSIONS, MAX_FILE_SIZE, type ChatMember } from '../../../../core/domain/types';
|
||||
import { useNotificationStore } from '../../../../core/application/stores/notificationStore';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
import CreatePollModal from './CreatePollModal';
|
||||
|
||||
interface Attachment {
|
||||
file: File;
|
||||
@@ -37,7 +39,7 @@ interface MessageInputProps {
|
||||
}
|
||||
|
||||
export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { user, config } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const { replyTo, editingMessage, setReplyTo, setEditingMessage, getDraft, setDraft, chats } = useChatStore();
|
||||
const [text, setText] = useState(() => getDraft(chatId));
|
||||
@@ -65,6 +67,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
const [scheduleCalMonth, setScheduleCalMonth] = useState(new Date().getMonth());
|
||||
const [scheduleCalYear, setScheduleCalYear] = useState(new Date().getFullYear());
|
||||
const [scheduleToast, setScheduleToast] = useState<string | null>(null);
|
||||
const [showPollModal, setShowPollModal] = useState(false);
|
||||
|
||||
// Filtered members for @mention
|
||||
const filteredMembers = mentionQuery !== null && isGroup
|
||||
@@ -256,6 +259,27 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
setDraft(chatId, '');
|
||||
};
|
||||
|
||||
const handleSendPoll = (poll: {
|
||||
question: string;
|
||||
options: string[];
|
||||
isAnonymous: boolean;
|
||||
allowMultiple: boolean;
|
||||
}) => {
|
||||
const socket = getSocket();
|
||||
if (!socket) return;
|
||||
|
||||
socket.emit('send_message', {
|
||||
chatId,
|
||||
content: poll.question,
|
||||
type: 'poll',
|
||||
pollOptions: poll.options,
|
||||
pollIsAnonymous: poll.isAnonymous,
|
||||
pollAllowMultipleAnswers: poll.allowMultiple,
|
||||
replyToId: replyTo?.id || null,
|
||||
});
|
||||
setReplyTo(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Handle @mention navigation
|
||||
if (mentionQuery !== null && filteredMembers.length > 0) {
|
||||
@@ -746,31 +770,31 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
<div className="relative flex-shrink-0 self-end mb-1">
|
||||
<button
|
||||
onClick={() => setShowAttachMenu(!showAttachMenu)}
|
||||
className="w-10 h-10 rounded-full text-on-surface-variant/60 hover:text-primary transition-all flex items-center justify-center p-0"
|
||||
className="w-10 h-10 rounded-full text-zinc-400 hover:text-primary transition-all flex items-center justify-center p-0"
|
||||
>
|
||||
<Paperclip size={24} strokeWidth={1.5} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showAttachMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAttachMenu(false)} />
|
||||
<div className="fixed inset-0 z-[10000]" onClick={() => setShowAttachMenu(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||
className="absolute bottom-[calc(100%+12px)] left-0 w-52 rounded-[1.5rem] glass-strong shadow-2xl z-50 p-2 border border-white/10 backdrop-blur-3xl"
|
||||
className="absolute bottom-[calc(100%+12px)] left-0 w-52 rounded-[2.5rem] bg-[#1e1e1e]/95 shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-[10001] p-2 border border-white/10 backdrop-blur-3xl"
|
||||
>
|
||||
<button
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
onClick={() => { imageInputRef.current?.click(); setShowAttachMenu(false); }}
|
||||
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-linear-to-br from-primary/20 to-primary-container/20 flex items-center justify-center ring-1 ring-primary/30 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<ImageIcon size={18} className="text-knot-400" />
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary/20 to-primary-container/20 flex items-center justify-center ring-1 ring-primary/30 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<ImageIcon size={18} className="text-primary" />
|
||||
</div>
|
||||
{t('photoVideo')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false); }}
|
||||
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group mt-1"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400/20 to-teal-500/20 flex items-center justify-center ring-1 ring-emerald-400/30 group-hover:scale-110 transition-transform shadow-inner">
|
||||
@@ -778,6 +802,20 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</div>
|
||||
{t('file')}
|
||||
</button>
|
||||
{(config?.messages?.allowPolls ?? true) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPollModal(true);
|
||||
setShowAttachMenu(false);
|
||||
}}
|
||||
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group mt-1"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-400/20 to-indigo-500/20 flex items-center justify-center ring-1 ring-purple-400/30 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<BarChart2 size={18} className="text-purple-400" />
|
||||
</div>
|
||||
{t('poll')}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
@@ -1052,6 +1090,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<CreatePollModal
|
||||
isOpen={showPollModal}
|
||||
onClose={() => setShowPollModal(false)}
|
||||
onSend={handleSendPoll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user