Заготовка опросов

This commit is contained in:
Халимов Рустам
2026-04-07 01:01:29 +03:00
parent 02a85fc587
commit c45f4db61c
23 changed files with 824 additions and 249 deletions

View File

@@ -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);

View File

@@ -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
);

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -7,6 +7,6 @@ public record MediaDto(
string Type,
string? Url,
string? Filename,
long? Size
long? Size,
string? Duration = null
);

View File

@@ -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
);
}
}

View File

@@ -0,0 +1,6 @@
namespace Knot.Modules.Conversations.Application.DTOs;
public record PinnedMessageDto(
Guid Id,
ChatMessageDto Message
);

View File

@@ -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()
));
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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',

View File

@@ -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'); }

View File

@@ -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 };
});

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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}` }}>

View File

@@ -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>
);
}