Заготовка опросов
This commit is contained in:
@@ -8,6 +8,7 @@ public interface IMessageRepository
|
|||||||
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
||||||
Task<Message?> GetLatestChatMessageAsync(Guid chatId, 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>> 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>> 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 usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||||
|
|
||||||
var members = new List<ChatMemberDto>();
|
var members = new List<ChatMemberDto>();
|
||||||
@@ -88,55 +91,19 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
var messagesList = new List<ChatMessageDto>();
|
var messagesList = new List<ChatMessageDto>();
|
||||||
if (latestMessage != null)
|
if (latestMessage != null)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
messagesList.Add(MessageMapper.MapToDto(
|
||||||
|
latestMessage,
|
||||||
var reactionsWithUser = new List<ReactionDto>();
|
usersInfo,
|
||||||
foreach (var reaction in latestReactions)
|
latestReactions,
|
||||||
{
|
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||||
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
|
var pinnedDtoList = new List<PinnedMessageDto>();
|
||||||
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId)
|
foreach (var pm in pinnedMessages)
|
||||||
.Select(m => new ReadByDto(m.UserId))
|
{
|
||||||
.ToList();
|
pinnedDtoList.Add(new PinnedMessageDto(
|
||||||
|
pm.Id,
|
||||||
var textMessage = latestMessage as TextMessage;
|
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||||
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
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +119,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
chat.CreatedAt,
|
chat.CreatedAt,
|
||||||
members,
|
members,
|
||||||
messagesList,
|
messagesList,
|
||||||
|
pinnedDtoList,
|
||||||
unreadCount
|
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 usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||||
|
|
||||||
var members = new List<ChatMemberDto>();
|
var members = new List<ChatMemberDto>();
|
||||||
@@ -85,53 +88,19 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
|
|
||||||
if (latestMessage != null)
|
if (latestMessage != null)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
messagesList.Add(MessageMapper.MapToDto(
|
||||||
|
latestMessage,
|
||||||
var reactionsWithUser = new List<ReactionDto>();
|
usersInfo,
|
||||||
foreach (var reaction in latestReactions)
|
latestReactions,
|
||||||
{
|
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||||
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 pinnedDtoList = new List<PinnedMessageDto>();
|
||||||
var mediaMessage = latestMessage as MediaMessage;
|
foreach (var pm in pinnedMessages)
|
||||||
var storyMessage = latestMessage as StoryMessage;
|
{
|
||||||
|
pinnedDtoList.Add(new PinnedMessageDto(
|
||||||
messagesList.Add(new ChatMessageDto(
|
pm.Id,
|
||||||
latestMessage.Id,
|
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||||
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
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +116,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
chat.CreatedAt,
|
chat.CreatedAt,
|
||||||
members,
|
members,
|
||||||
messagesList,
|
messagesList,
|
||||||
|
pinnedDtoList,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
chat.IsImporting,
|
chat.IsImporting,
|
||||||
chat.ImportJobId
|
chat.ImportJobId
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public record ChatDto(
|
|||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
List<ChatMemberDto> Members,
|
List<ChatMemberDto> Members,
|
||||||
List<ChatMessageDto> Messages,
|
List<ChatMessageDto> Messages,
|
||||||
|
List<PinnedMessageDto> PinnedMessages,
|
||||||
int UnreadCount,
|
int UnreadCount,
|
||||||
bool IsImporting = false,
|
bool IsImporting = false,
|
||||||
Guid? ImportJobId = null
|
Guid? ImportJobId = null
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public record ChatMessageDto(
|
|||||||
List<ReadByDto> ReadBy,
|
List<ReadByDto> ReadBy,
|
||||||
string? CallType = null,
|
string? CallType = null,
|
||||||
string? CallStatus = 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 Type,
|
||||||
string? Url,
|
string? Url,
|
||||||
string? Filename,
|
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
|
||||||
|
);
|
||||||
+2
-2
@@ -97,7 +97,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
|||||||
|
|
||||||
if (filterType == "gifs") return isGif;
|
if (filterType == "gifs") return isGif;
|
||||||
if (filterType == "media") return (mType == "image" || mType == "video") && !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";
|
if (filterType == "links") return mType == "link";
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -119,7 +119,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
|||||||
storyMessage?.StoryMediaType,
|
storyMessage?.StoryMediaType,
|
||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.Type,
|
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()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
@@ -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;
|
||||||
+58
@@ -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 Microsoft.Extensions.Caching.Memory;
|
||||||
using Knot.Contracts.Auth.Domain;
|
using Knot.Contracts.Auth.Domain;
|
||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
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;
|
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
@@ -39,17 +45,29 @@ public sealed class ChatHub : Hub
|
|||||||
private readonly IUserContext _userContext;
|
private readonly IUserContext _userContext;
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly ILogger<ChatHub> _logger;
|
private readonly ILogger<ChatHub> _logger;
|
||||||
private readonly IMemoryCache _cache;
|
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;
|
_sender = sender;
|
||||||
_userContext = userContext;
|
_userContext = userContext;
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_userRepository = userRepository;
|
_userRepository = userRepository;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_userProvider = userProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task OnConnectedAsync()
|
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();
|
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||||
|
|
||||||
var command = new SendMessageCommand(
|
var command = new SendMessageCommand(
|
||||||
request.ChatId,
|
ChatId: request.ChatId,
|
||||||
_userContext.UserId,
|
SenderId: _userContext.UserId,
|
||||||
request.Content,
|
Content: request.Content,
|
||||||
request.Type,
|
Type: request.Type,
|
||||||
attachments,
|
Attachments: attachments,
|
||||||
request.ReplyToId,
|
ReplyToId: request.ReplyToId,
|
||||||
request.Quote,
|
Quote: request.Quote,
|
||||||
request.ForwardedFromId);
|
ForwardedFromId: request.ForwardedFromId,
|
||||||
|
PollOptions: request.PollOptions,
|
||||||
|
PollIsAnonymous: request.PollIsAnonymous,
|
||||||
|
PollAllowMultipleAnswers: request.PollAllowMultipleAnswers
|
||||||
|
);
|
||||||
|
|
||||||
await _sender.Send(command);
|
await _sender.Send(command);
|
||||||
}
|
}
|
||||||
@@ -227,6 +249,40 @@ public sealed class ChatHub : Hub
|
|||||||
_logger.LogInformation("RemoveReaction completed successfully");
|
_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)
|
// 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
|
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
userId = Context.UserIdentifier,
|
userId = _userContext.UserId.ToString(),
|
||||||
isMuted = request.IsMuted,
|
isMuted = request.IsMuted,
|
||||||
isVideoOff = request.IsVideoOff
|
isVideoOff = request.IsVideoOff
|
||||||
});
|
});
|
||||||
@@ -675,7 +731,7 @@ public sealed class ChatHub : Hub
|
|||||||
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
|
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
|
||||||
{
|
{
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
userId = Context.UserIdentifier,
|
userId = _userContext.UserId.ToString(),
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
isVideoOff = isVideoOff
|
isVideoOff = isVideoOff
|
||||||
});
|
});
|
||||||
@@ -759,7 +815,10 @@ public sealed class ChatHub : Hub
|
|||||||
List<AttachmentHubRequest>? Attachments = null,
|
List<AttachmentHubRequest>? Attachments = null,
|
||||||
Guid? ReplyToId = null,
|
Guid? ReplyToId = null,
|
||||||
string? Quote = 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 ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
|
||||||
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
||||||
public record CallAnswerRequest(string TargetUserId, object Answer);
|
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 CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
|
||||||
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||||
public record RemoveReactionRequest(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 DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
||||||
public record GroupCallJoinRequest(string ChatId, string CallType);
|
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);
|
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);
|
.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)
|
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var builder = Builders<Message>.Filter;
|
var builder = Builders<Message>.Filter;
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
string fileName = "";
|
string fileName = "";
|
||||||
var titleNode = link.QuerySelector(".title") ?? link.QuerySelector(".name") ?? link.QuerySelector(".description");
|
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();
|
fileName = titleNode.TextContent.Trim();
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,8 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
{
|
{
|
||||||
var clone = (IElement)bodyNode.Clone();
|
var clone = (IElement)bodyNode.Clone();
|
||||||
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right")) s.Remove();
|
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
|
// Final desperate attempt: link's own content excluding status tags
|
||||||
@@ -210,7 +211,8 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
{
|
{
|
||||||
var clone = (IElement)link.Clone();
|
var clone = (IElement)link.Clone();
|
||||||
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right, .details_icon")) s.Remove();
|
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;
|
callType?: 'voice' | 'video' | string | null;
|
||||||
callStatus?: 'missed' | 'completed' | 'cancelled' | 'declined' | string | null;
|
callStatus?: 'missed' | 'completed' | 'cancelled' | 'declined' | string | null;
|
||||||
duration?: number | null;
|
duration?: number | null;
|
||||||
|
pollOptions?: Array<{ Text: string; VoteCount: number }>;
|
||||||
|
pollIsMultipleChoice?: boolean;
|
||||||
|
pollIsClosed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
|
|||||||
@@ -158,6 +158,18 @@ const translations = {
|
|||||||
pinMessage: 'Закрепить',
|
pinMessage: 'Закрепить',
|
||||||
unpinMessage: 'Открепить',
|
unpinMessage: 'Открепить',
|
||||||
pinnedMessage: 'Закреплённое сообщение',
|
pinnedMessage: 'Закреплённое сообщение',
|
||||||
|
poll: 'Опрос',
|
||||||
|
pollTab: 'Опросы',
|
||||||
|
createPoll: 'Создать опрос',
|
||||||
|
pollQuestion: 'Вопрос',
|
||||||
|
pollQuestionPlaceholder: 'Задайте вопрос...',
|
||||||
|
pollOptions: 'Варианты ответа',
|
||||||
|
pollOption: 'Вариант',
|
||||||
|
addOption: 'Добавить вариант',
|
||||||
|
pollSettings: 'Настройки',
|
||||||
|
anonymousVoting: 'Анонимное голосование',
|
||||||
|
multipleAnswers: 'Выбор нескольких вариантов',
|
||||||
|
pollButton: 'Опрос',
|
||||||
forwardMessage: 'Переслать сообщение',
|
forwardMessage: 'Переслать сообщение',
|
||||||
forward: 'Переслать',
|
forward: 'Переслать',
|
||||||
forwardedFrom: 'Переслано от',
|
forwardedFrom: 'Переслано от',
|
||||||
@@ -182,7 +194,7 @@ const translations = {
|
|||||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||||
pinChat: 'Закрепить чат',
|
pinChat: 'Закрепить чат',
|
||||||
unpinChat: 'Открепить чат',
|
unpinChat: 'Открепить чат',
|
||||||
chatCleared: 'Чат очищен',
|
chatCleared: 'Очищено',
|
||||||
typeYourStoryPlaceholder: 'Напишите историю...',
|
typeYourStoryPlaceholder: 'Напишите историю...',
|
||||||
uploadMedia: 'Загрузить медиа',
|
uploadMedia: 'Загрузить медиа',
|
||||||
chooseBackground: 'Цвет фона',
|
chooseBackground: 'Цвет фона',
|
||||||
@@ -560,6 +572,18 @@ const translations = {
|
|||||||
pinChat: 'Pin chat',
|
pinChat: 'Pin chat',
|
||||||
unpinChat: 'Unpin chat',
|
unpinChat: 'Unpin chat',
|
||||||
chatCleared: 'Chat cleared',
|
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',
|
groupSettings: 'Group settings',
|
||||||
editGroupName: 'Edit name',
|
editGroupName: 'Edit name',
|
||||||
addMember: 'Add member',
|
addMember: 'Add member',
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ const translations = {
|
|||||||
telegramImport: 'Telegram Import',
|
telegramImport: 'Telegram Import',
|
||||||
serverDesc: 'Server Description',
|
serverDesc: 'Server Description',
|
||||||
successSave: 'Settings saved',
|
successSave: 'Settings saved',
|
||||||
|
successClean: 'Cleanup completed',
|
||||||
errorSave: 'Save failed',
|
errorSave: 'Save failed',
|
||||||
errorInvalidLogin: 'Invalid credentials',
|
errorInvalidLogin: 'Invalid credentials',
|
||||||
errorDelete: 'Delete failed',
|
errorDelete: 'Delete failed',
|
||||||
@@ -414,6 +415,7 @@ const translations = {
|
|||||||
telegramImport: 'Импорт Telegram',
|
telegramImport: 'Импорт Telegram',
|
||||||
serverDesc: 'Описание сервера',
|
serverDesc: 'Описание сервера',
|
||||||
successSave: 'Сохранено',
|
successSave: 'Сохранено',
|
||||||
|
successClean: 'Очищено',
|
||||||
errorSave: 'Ошибка сохранения',
|
errorSave: 'Ошибка сохранения',
|
||||||
errorInvalidLogin: 'Неверный логин или пароль',
|
errorInvalidLogin: 'Неверный логин или пароль',
|
||||||
errorDelete: 'Ошибка удаления',
|
errorDelete: 'Ошибка удаления',
|
||||||
@@ -745,7 +747,7 @@ export default function AdminPage() {
|
|||||||
const handleRunCleanup = async () => {
|
const handleRunCleanup = async () => {
|
||||||
try {
|
try {
|
||||||
await httpClient.request('/admin/clean/run', { method: 'POST' });
|
await httpClient.request('/admin/clean/run', { method: 'POST' });
|
||||||
showToast(t.successSave, 'success');
|
showToast(t.successClean, 'success');
|
||||||
setCleanStats(null);
|
setCleanStats(null);
|
||||||
fetchDashboard();
|
fetchDashboard();
|
||||||
} catch { showToast(t.errorSave, 'error'); }
|
} catch { showToast(t.errorSave, 'error'); }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface ChatState {
|
|||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
activeChat: string | null;
|
activeChat: string | null;
|
||||||
messages: Record<string, Message[]>;
|
messages: Record<string, Message[]>;
|
||||||
pinnedMessages: Record<string, Message>;
|
pinnedMessages: Record<string, Message[]>;
|
||||||
typingUsers: TypingUser[];
|
typingUsers: TypingUser[];
|
||||||
replyTo: Message | null;
|
replyTo: Message | null;
|
||||||
editingMessage: Message | null;
|
editingMessage: Message | null;
|
||||||
@@ -42,7 +42,7 @@ interface ChatState {
|
|||||||
removeChat: (chatId: string) => void;
|
removeChat: (chatId: string) => void;
|
||||||
clearMessages: (chatId: string) => void;
|
clearMessages: (chatId: string) => void;
|
||||||
setPinnedMessage: (chatId: string, message: Message) => 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;
|
clearStore: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +99,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
// Extract pinned messages from chats
|
// Extract pinned messages from chats
|
||||||
const pinnedMessages: Record<string, Message> = {};
|
const pinnedMessages: Record<string, Message[]> = {};
|
||||||
for (const chat of chats) {
|
for (const chat of chats) {
|
||||||
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
|
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 });
|
set({ chats, pinnedMessages, isLoadingChats: false });
|
||||||
@@ -519,18 +519,27 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setPinnedMessage: (chatId, message) => {
|
setPinnedMessage: (chatId, message) => {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
pinnedMessages: { ...state.pinnedMessages, [chatId]: message },
|
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) => {
|
set((state) => {
|
||||||
const updated = { ...state.pinnedMessages };
|
const updated = { ...state.pinnedMessages };
|
||||||
if (newPinned) {
|
if (newPinned) {
|
||||||
updated[chatId] = newPinned;
|
updated[chatId] = newPinned;
|
||||||
} else {
|
} 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 };
|
return { pinnedMessages: updated };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -206,8 +206,8 @@ export default function ChatPage() {
|
|||||||
setPinnedMessage(data.chatId, data.message);
|
setPinnedMessage(data.chatId, data.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('message_unpinned', (data: { chatId: string; messageId: string; newPinnedMessage: Message | null }) => {
|
socket.on('message_unpinned', (data: { chatId: string; messageId: string }) => {
|
||||||
removePinnedMessage(data.chatId, data.messageId, data.newPinnedMessage);
|
removePinnedMessage(data.chatId, data.messageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('call_incoming', async (data: CallInfo) => {
|
socket.on('call_incoming', async (data: CallInfo) => {
|
||||||
|
|||||||
@@ -77,7 +77,58 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
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 searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const topMenuRef = useRef<HTMLDivElement>(null);
|
const topMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const deleteMenuRef = useRef<HTMLDivElement>(null);
|
const deleteMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -87,7 +138,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
const allChatMessages = activeChat ? messages[activeChat] || [] : [];
|
const allChatMessages = activeChat ? messages[activeChat] || [] : [];
|
||||||
// Filter out deleted messages to prevent layout shifts
|
// Filter out deleted messages to prevent layout shifts
|
||||||
const chatMessages = allChatMessages.filter(m => !m.isDeleted);
|
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 [importStatus, setImportStatus] = useState<{ processed: number, total: number, status: string } | null>(null);
|
||||||
const isAtBottomRef = useRef(false);
|
const isAtBottomRef = useRef(false);
|
||||||
@@ -213,8 +265,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
const scrollToBottom = useCallback((smooth = true) => {
|
const scrollToBottom = useCallback((smooth = true) => {
|
||||||
if (messagesEndRef.current) {
|
if (messagesEndRef.current) {
|
||||||
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
messagesEndRef.current.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||||||
} else if (messagesContainerRef.current) {
|
} else if (scrollContainerRef.current) {
|
||||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -225,7 +277,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
|
|
||||||
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (ЯКОРНОЕ ПО MESSAGE ID)
|
// 1. СОХРАНЕНИЕ ПОЗИЦИИ (ЯКОРНОЕ ПО MESSAGE ID)
|
||||||
const saveScrollPosition = useCallback((targetChatId?: string) => {
|
const saveScrollPosition = useCallback((targetChatId?: string) => {
|
||||||
const container = messagesContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
const chatId = targetChatId || activeChat;
|
const chatId = targetChatId || activeChat;
|
||||||
|
|
||||||
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
|
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;
|
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;
|
isAtBottomRef.current = isAtBottomNow;
|
||||||
|
|
||||||
if (isAtBottomNow) {
|
if (isAtBottomNow) {
|
||||||
@@ -268,7 +320,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
|
|
||||||
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
|
// 2. ВОССТАНОВЛЕНИЕ ПОЗИЦИИ
|
||||||
const restoreScrollPosition = useCallback(() => {
|
const restoreScrollPosition = useCallback(() => {
|
||||||
const container = messagesContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (!container || !activeChat) return false;
|
if (!container || !activeChat) return false;
|
||||||
|
|
||||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== 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. ОБЗЕРВЕР И УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ
|
// 3. ОБЗЕРВЕР И УПРАВЛЕНИЕ ЖИЗНЕННЫМ ЦИКЛОМ
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoadingMessages || !messagesContainerRef.current || !activeChat) return;
|
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
|
||||||
const container = messagesContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
if (isInitializingRef.current) return;
|
if (isInitializingRef.current) return;
|
||||||
@@ -361,15 +413,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
isScrollingToBottomRef.current = false;
|
isScrollingToBottomRef.current = false;
|
||||||
|
|
||||||
// Сохраняем позицию старого чата
|
// Сохраняем позицию старого чата
|
||||||
if (prevChatIdRef.current && messagesContainerRef.current && scrollReady) {
|
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
|
||||||
saveScrollPosition(prevChatIdRef.current);
|
saveScrollPosition(prevChatIdRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
setScrollReady(false);
|
setScrollReady(false);
|
||||||
prevChatIdRef.current = activeChat;
|
prevChatIdRef.current = activeChat;
|
||||||
|
|
||||||
if (messagesContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
messagesContainerRef.current.scrollTop = 0;
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -381,7 +433,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}, [activeChat, saveScrollPosition, scrollReady]);
|
}, [activeChat, saveScrollPosition, scrollReady]);
|
||||||
|
|
||||||
const checkScrollPosition = useCallback(() => {
|
const checkScrollPosition = useCallback(() => {
|
||||||
const container = messagesContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (!container || isInitializingRef.current) return;
|
if (!container || isInitializingRef.current) return;
|
||||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 300;
|
||||||
setShowScrollDown(!isNearBottom);
|
setShowScrollDown(!isNearBottom);
|
||||||
@@ -392,10 +444,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
|
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||||
|
|
||||||
checkScrollPosition();
|
checkScrollPosition();
|
||||||
const container = messagesContainerRef.current;
|
const container = scrollContainerRef.current;
|
||||||
if (container && activeChat) {
|
if (container && activeChat) {
|
||||||
// Synchronous "at bottom" check to prevent ResizeObserver from fighting the user
|
// 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;
|
isAtBottomRef.current = isAtBottomNow;
|
||||||
|
|
||||||
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
||||||
@@ -405,12 +457,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
const isScrollingUp = st < lastScrollTopRef.current;
|
const isScrollingUp = st < lastScrollTopRef.current;
|
||||||
lastScrollTopRef.current = st;
|
lastScrollTopRef.current = st;
|
||||||
|
|
||||||
// Sticky Date Header Logic - Telegram style: show when scrolling, especially up
|
// Sticky Date Header Logic - Telegram style
|
||||||
if (st > 100) {
|
if (st > 100 && (isScrollingUp || st !== lastScrollTopRef.current)) {
|
||||||
setShowStickyDate(true);
|
setShowStickyDate(true);
|
||||||
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
|
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
|
||||||
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 2000);
|
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), isScrollingUp ? 1500 : 1000);
|
||||||
} else {
|
} else if (st <= 100) {
|
||||||
setShowStickyDate(false);
|
setShowStickyDate(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,9 +503,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
localStorage.removeItem(`chat_anchor_${activeChat}`);
|
localStorage.removeItem(`chat_anchor_${activeChat}`);
|
||||||
|
|
||||||
isScrollingToBottomRef.current = true;
|
isScrollingToBottomRef.current = true;
|
||||||
if (messagesContainerRef.current) {
|
if (scrollContainerRef.current) {
|
||||||
messagesContainerRef.current.scrollTo({
|
scrollContainerRef.current.scrollTo({
|
||||||
top: messagesContainerRef.current.scrollHeight,
|
top: scrollContainerRef.current.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -514,7 +566,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: messagesContainerRef.current,
|
root: scrollContainerRef.current,
|
||||||
threshold: 0.1,
|
threshold: 0.1,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -522,9 +574,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
observerRef.current = observer;
|
observerRef.current = observer;
|
||||||
|
|
||||||
const observeUnread = () => {
|
const observeUnread = () => {
|
||||||
if (!messagesContainerRef.current) return;
|
if (!scrollContainerRef.current) return;
|
||||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
|
||||||
unreadElements.forEach((el) => {
|
unreadElements.forEach((el: Element) => {
|
||||||
const id = el.getAttribute('data-message-id');
|
const id = el.getAttribute('data-message-id');
|
||||||
if (id && !sentReadIdsRef.current.has(id)) {
|
if (id && !sentReadIdsRef.current.has(id)) {
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
@@ -537,9 +589,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
}, [activeChat, user?.id]);
|
}, [activeChat, user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (observerRef.current && messagesContainerRef.current) {
|
if (observerRef.current && scrollContainerRef.current) {
|
||||||
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
|
const unreadElements = scrollContainerRef.current.querySelectorAll('.unread-detector');
|
||||||
unreadElements.forEach((el) => {
|
unreadElements.forEach((el: Element) => {
|
||||||
const id = el.getAttribute('data-message-id');
|
const id = el.getAttribute('data-message-id');
|
||||||
if (id && !sentReadIdsRef.current.has(id)) {
|
if (id && !sentReadIdsRef.current.has(id)) {
|
||||||
observerRef.current?.observe(el);
|
observerRef.current?.observe(el);
|
||||||
@@ -949,6 +1001,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
try {
|
try {
|
||||||
await ChatApi.clearChat(activeChat);
|
await ChatApi.clearChat(activeChat);
|
||||||
useChatStore.getState().clearMessages(activeChat);
|
useChatStore.getState().clearMessages(activeChat);
|
||||||
|
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
|
||||||
|
NotificationStore.useNotificationStore.getState().addNotification('success', t('chatCleared'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
@@ -1100,37 +1154,62 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pinnedMsg && (
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const el = document.getElementById(`msg-${pinnedMsg.id}`);
|
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
|
||||||
if (el) {
|
if (!currentPin) return;
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
handleJumpToMessage(currentPin.id, undefined, currentPin.createdAt);
|
||||||
el.classList.add('highlight-message');
|
if (chatPinnedMessages.length > 1) {
|
||||||
setTimeout(() => el.classList.remove('highlight-message'), 5000);
|
setPinnedIndex(prev => (prev + 1) % chatPinnedMessages.length);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
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"
|
className={`flex-1 flex items-center gap-3 px-4 py-2 text-left h-full ${chatPinnedMessages.length > 1 ? 'ml-1.5' : ''}`}
|
||||||
>
|
>
|
||||||
<Pin size={16} className="text-primary flex-shrink-0 rotate-45" />
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium text-primary">{t('pinnedMessage')}</p>
|
<p className="text-[11px] font-black text-primary uppercase tracking-wider">
|
||||||
<p className="text-sm text-zinc-300 truncate">
|
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
|
||||||
{pinnedMsg.content || (pinnedMsg.media?.length > 0 ? t('media') : '...')}
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<X
|
</button>
|
||||||
size={16}
|
|
||||||
className="text-zinc-500 hover:text-white flex-shrink-0 transition-colors"
|
<div className="flex items-center px-2">
|
||||||
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (socket && activeChat) {
|
const currentPin = chatPinnedMessages[pinnedIndex % chatPinnedMessages.length];
|
||||||
socket.emit('unpin_message', { messageId: pinnedMsg.id, chatId: activeChat });
|
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>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative flex-1 flex flex-col overflow-hidden">
|
<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 }}
|
exit={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
key="sticky-date"
|
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">
|
<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}
|
{stickyDate}
|
||||||
@@ -1152,7 +1231,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={messagesContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
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' : ''}`}
|
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>
|
</AnimatePresence>
|
||||||
</div>
|
</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} />
|
<MessageInput chatId={activeChat} />
|
||||||
</footer>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AnimatePresence>
|
<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,
|
PhoneMissed,
|
||||||
PhoneIncoming,
|
PhoneIncoming,
|
||||||
PhoneOutgoing,
|
PhoneOutgoing,
|
||||||
|
BarChart2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '../../../auth/application/authStore';
|
import { useAuthStore } from '../../../auth/application/authStore';
|
||||||
import { useChatStore } from '../../application/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
@@ -164,7 +165,7 @@ function MessageBubble({
|
|||||||
? otherMember?.user.displayName || otherMember?.user.userName || otherMember?.user.username || ''
|
? 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 handlePin = () => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
@@ -326,7 +327,8 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end justify-between self-stretch pt-0.5">
|
<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}
|
{timeStr}
|
||||||
</div>
|
</div>
|
||||||
{isMine && (
|
{isMine && (
|
||||||
@@ -687,6 +689,7 @@ function MessageBubble({
|
|||||||
{!message.content && (
|
{!message.content && (
|
||||||
<div className="absolute bottom-1.5 right-1.5 z-10 pointer-events-none flex justify-end">
|
<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">
|
<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}
|
{timeStr}
|
||||||
{isMine && !message.scheduledAt && (
|
{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}` }}>
|
<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 && (() => {
|
{message.content && (() => {
|
||||||
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
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'}`}>
|
<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.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>}
|
{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}
|
{timeStr}
|
||||||
{isMine && !message.scheduledAt && (
|
{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}` }}>
|
<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,
|
ChevronRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
|
BarChart2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useChatStore } from '../../application/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
import { useAuthStore } from '../../../auth/application/authStore';
|
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 { AUDIO_EXTENSIONS, MAX_FILE_SIZE, type ChatMember } from '../../../../core/domain/types';
|
||||||
import { useNotificationStore } from '../../../../core/application/stores/notificationStore';
|
import { useNotificationStore } from '../../../../core/application/stores/notificationStore';
|
||||||
import EmojiPicker from './EmojiPicker';
|
import EmojiPicker from './EmojiPicker';
|
||||||
|
import CreatePollModal from './CreatePollModal';
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -37,7 +39,7 @@ interface MessageInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageInput({ chatId }: MessageInputProps) {
|
export default function MessageInput({ chatId }: MessageInputProps) {
|
||||||
const { user } = useAuthStore();
|
const { user, config } = useAuthStore();
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
const { replyTo, editingMessage, setReplyTo, setEditingMessage, getDraft, setDraft, chats } = useChatStore();
|
const { replyTo, editingMessage, setReplyTo, setEditingMessage, getDraft, setDraft, chats } = useChatStore();
|
||||||
const [text, setText] = useState(() => getDraft(chatId));
|
const [text, setText] = useState(() => getDraft(chatId));
|
||||||
@@ -65,6 +67,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
const [scheduleCalMonth, setScheduleCalMonth] = useState(new Date().getMonth());
|
const [scheduleCalMonth, setScheduleCalMonth] = useState(new Date().getMonth());
|
||||||
const [scheduleCalYear, setScheduleCalYear] = useState(new Date().getFullYear());
|
const [scheduleCalYear, setScheduleCalYear] = useState(new Date().getFullYear());
|
||||||
const [scheduleToast, setScheduleToast] = useState<string | null>(null);
|
const [scheduleToast, setScheduleToast] = useState<string | null>(null);
|
||||||
|
const [showPollModal, setShowPollModal] = useState(false);
|
||||||
|
|
||||||
// Filtered members for @mention
|
// Filtered members for @mention
|
||||||
const filteredMembers = mentionQuery !== null && isGroup
|
const filteredMembers = mentionQuery !== null && isGroup
|
||||||
@@ -256,6 +259,27 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
setDraft(chatId, '');
|
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) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
// Handle @mention navigation
|
// Handle @mention navigation
|
||||||
if (mentionQuery !== null && filteredMembers.length > 0) {
|
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">
|
<div className="relative flex-shrink-0 self-end mb-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAttachMenu(!showAttachMenu)}
|
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} />
|
<Paperclip size={24} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showAttachMenu && (
|
{showAttachMenu && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowAttachMenu(false)} />
|
<div className="fixed inset-0 z-[10000]" onClick={() => setShowAttachMenu(false)} />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 15 }}
|
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
|
<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"
|
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">
|
<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-knot-400" />
|
<ImageIcon size={18} className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
{t('photoVideo')}
|
{t('photoVideo')}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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">
|
<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>
|
</div>
|
||||||
{t('file')}
|
{t('file')}
|
||||||
</button>
|
</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>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1052,6 +1090,12 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<CreatePollModal
|
||||||
|
isOpen={showPollModal}
|
||||||
|
onClose={() => setShowPollModal(false)}
|
||||||
|
onSend={handleSendPoll}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user