diff --git a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs index 22013c6..6b1b6d1 100644 --- a/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs +++ b/backend/src/Contracts/Messaging/Application/Abstractions/IMessageRepository.cs @@ -8,6 +8,7 @@ public interface IMessageRepository Task GetByIdAsync(Guid id, CancellationToken cancellationToken); Task> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken); Task GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken); + Task> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken); Task> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken); Task> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken); diff --git a/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs b/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs index 7251d31..2724c4a 100644 --- a/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs +++ b/backend/src/Modules/Conversations/Application/Chats/GetChatById/GetChatById.cs @@ -63,6 +63,9 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler(); @@ -88,55 +91,19 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler(); 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(); - 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(), - 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(); + foreach (var pm in pinnedMessages) + { + pinnedDtoList.Add(new PinnedMessageDto( + pm.Id, + MessageMapper.MapToDto(pm, usersInfo, new List(), new List()) )); } @@ -152,6 +119,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler(); @@ -85,53 +88,19 @@ internal sealed class GetChatsQueryHandler : IQueryHandler m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId))); + } - var reactionsWithUser = new List(); - 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(), - 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(); + foreach (var pm in pinnedMessages) + { + pinnedDtoList.Add(new PinnedMessageDto( + pm.Id, + MessageMapper.MapToDto(pm, usersInfo, new List(), new List()) )); } @@ -147,6 +116,7 @@ internal sealed class GetChatsQueryHandler : IQueryHandler Members, List Messages, + List PinnedMessages, int UnreadCount, bool IsImporting = false, Guid? ImportJobId = null diff --git a/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs b/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs index 3b773bb..c0b5f34 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs @@ -24,6 +24,12 @@ public record ChatMessageDto( List ReadBy, string? CallType = null, string? CallStatus = null, - int? Duration = null + int? Duration = null, + List? PollOptions = null, + bool? PollIsMultipleChoice = null, + bool? PollIsClosed = null ); +public record PollOptionDto(string Text, int VoteCount); + + diff --git a/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs b/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs index 23da2a0..a5604ed 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/MediaDto.cs @@ -7,6 +7,6 @@ public record MediaDto( string Type, string? Url, string? Filename, - long? Size + long? Size, + string? Duration = null ); - diff --git a/backend/src/Modules/Conversations/Application/DTOs/MessageMapper.cs b/backend/src/Modules/Conversations/Application/DTOs/MessageMapper.cs new file mode 100644 index 0000000..8fc90e0 --- /dev/null +++ b/backend/src/Modules/Conversations/Application/DTOs/MessageMapper.cs @@ -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 usersInfo, + IEnumerable reactions, + IEnumerable readByUsers) + { + usersInfo.TryGetValue(message.SenderId, out var senderObj); + + var reactionsWithUser = new List(); + 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(), + 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 + ); + } +} diff --git a/backend/src/Modules/Conversations/Application/DTOs/PinnedMessageDto.cs b/backend/src/Modules/Conversations/Application/DTOs/PinnedMessageDto.cs new file mode 100644 index 0000000..5c981f8 --- /dev/null +++ b/backend/src/Modules/Conversations/Application/DTOs/PinnedMessageDto.cs @@ -0,0 +1,6 @@ +namespace Knot.Modules.Conversations.Application.DTOs; + +public record PinnedMessageDto( + Guid Id, + ChatMessageDto Message +); diff --git a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs index 0721292..7ff3b09 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs @@ -97,7 +97,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler 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() )); } } diff --git a/backend/src/Modules/Conversations/Application/Messages/Pin/PinMessageCommandHandler.cs b/backend/src/Modules/Conversations/Application/Messages/Pin/PinMessageCommandHandler.cs new file mode 100644 index 0000000..9861e7e --- /dev/null +++ b/backend/src/Modules/Conversations/Application/Messages/Pin/PinMessageCommandHandler.cs @@ -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; + +public sealed class PinMessageCommandHandler : ICommandHandler +{ + 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> Handle(PinMessageCommand request, CancellationToken cancellationToken) + { + var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); + if (chat is null) return Result.Failure(ChatErrors.ChatsNotFound); + + // Security check + if (!chat.Members.Any(m => m.UserId == request.UserId)) + return Result.Failure(ChatErrors.ChatsForbidden); + + var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken); + if (message is null) return Result.Failure(ChatErrors.NotFound); + + if (message.ChatId != request.ChatId) + return Result.Failure(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; diff --git a/backend/src/Modules/Conversations/Application/Messages/Unpin/UnpinMessageCommandHandler.cs b/backend/src/Modules/Conversations/Application/Messages/Unpin/UnpinMessageCommandHandler.cs new file mode 100644 index 0000000..a3d0acd --- /dev/null +++ b/backend/src/Modules/Conversations/Application/Messages/Unpin/UnpinMessageCommandHandler.cs @@ -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; + +public sealed class UnpinMessageCommandHandler : ICommandHandler +{ + 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> Handle(UnpinMessageCommand request, CancellationToken cancellationToken) + { + var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken); + if (chat is null) return Result.Failure(ChatErrors.ChatsNotFound); + + // Security check + if (!chat.Members.Any(m => m.UserId == request.UserId)) + return Result.Failure(ChatErrors.ChatsForbidden); + + var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken); + if (message is null) return Result.Failure(ChatErrors.NotFound); + + if (message.ChatId != request.ChatId) + return Result.Failure(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; diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs index 5ba8259..da00410 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs @@ -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 _logger; private readonly IMemoryCache _cache; + private readonly IUserDisplayNameProvider _userProvider; - public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, IUserRepository userRepository, ILogger logger, IMemoryCache cache) + public ChatHub( + ISender sender, + IUserContext userContext, + IChatRepository chatRepository, + IUserRepository userRepository, + IMessageRepository messageRepository, + ILogger 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(), Enumerable.Empty()); + + 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? Attachments = null, Guid? ReplyToId = null, string? Quote = null, - Guid? ForwardedFromId = null); + Guid? ForwardedFromId = null, + List? 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 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); diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs index 8baab52..4d55c33 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/MessageRepository.cs @@ -62,6 +62,19 @@ public sealed class MessageRepository : IMessageRepository .FirstOrDefaultAsync(cancellationToken); } + public async Task> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken) + { + var builder = Builders.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> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken) { var builder = Builders.Filter; diff --git a/backend/src/Modules/TelegramImport/Infrastructure/Parser/TelegramHtmlParser.cs b/backend/src/Modules/TelegramImport/Infrastructure/Parser/TelegramHtmlParser.cs index 80684d6..b464e0a 100644 --- a/backend/src/Modules/TelegramImport/Infrastructure/Parser/TelegramHtmlParser.cs +++ b/backend/src/Modules/TelegramImport/Infrastructure/Parser/TelegramHtmlParser.cs @@ -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; } } diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index a2e8df5..0a00a34 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -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 { diff --git a/client-web/src/core/infrastructure/i18n.ts b/client-web/src/core/infrastructure/i18n.ts index 0c4a259..3b6991f 100644 --- a/client-web/src/core/infrastructure/i18n.ts +++ b/client-web/src/core/infrastructure/i18n.ts @@ -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', diff --git a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx index 8f5af84..64288bb 100644 --- a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx +++ b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx @@ -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'); } diff --git a/client-web/src/modules/chats/application/chatStore.ts b/client-web/src/modules/chats/application/chatStore.ts index 8f80030..e76a0ff 100644 --- a/client-web/src/modules/chats/application/chatStore.ts +++ b/client-web/src/modules/chats/application/chatStore.ts @@ -7,7 +7,7 @@ interface ChatState { chats: Chat[]; activeChat: string | null; messages: Record; - pinnedMessages: Record; + pinnedMessages: Record; 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((set, get) => ({ } catch { } } // Extract pinned messages from chats - const pinnedMessages: Record = {}; + const pinnedMessages: Record = {}; 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((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 }; }); diff --git a/client-web/src/modules/chats/presentation/ChatPage.tsx b/client-web/src/modules/chats/presentation/ChatPage.tsx index 9f19522..d5c3dc1 100644 --- a/client-web/src/modules/chats/presentation/ChatPage.tsx +++ b/client-web/src/modules/chats/presentation/ChatPage.tsx @@ -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) => { diff --git a/client-web/src/modules/chats/presentation/components/ChatView.tsx b/client-web/src/modules/chats/presentation/components/ChatView.tsx index 7b22494..4ac7b95 100644 --- a/client-web/src/modules/chats/presentation/components/ChatView.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatView.tsx @@ -77,7 +77,58 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState([]); const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); + const scrollContainerRef = useRef(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(null); const topMenuRef = useRef(null); const deleteMenuRef = useRef(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 )} - {pinnedMsg && ( - + className={`flex-1 flex items-center gap-3 px-4 py-2 text-left h-full ${chatPinnedMessages.length > 1 ? 'ml-1.5' : ''}`} + > +
+ +
+
+

+ {t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''} +

+

+ {chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content || + (chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')} +

+
+ + +
+ +
+ )}
@@ -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" > {stickyDate} @@ -1152,7 +1231,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
0 ? 'invisible' : ''}`} > @@ -1253,7 +1332,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
-