From 8399d324908201d34ca63cd20a9fa4d34dd456f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Wed, 8 Apr 2026 15:23:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B5=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messages/Edit/EditMessageCommand.cs | 69 +++++++++++++++++++ .../Infrastructure/SignalR/ChatHub.cs | 13 ++++ .../modules/chats/presentation/ChatPage.tsx | 4 +- .../presentation/components/MessageBubble.tsx | 13 ++-- .../presentation/components/MessageInput.tsx | 7 +- 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 backend/src/Modules/Conversations/Application/Messages/Edit/EditMessageCommand.cs diff --git a/backend/src/Modules/Conversations/Application/Messages/Edit/EditMessageCommand.cs b/backend/src/Modules/Conversations/Application/Messages/Edit/EditMessageCommand.cs new file mode 100644 index 0000000..f017343 --- /dev/null +++ b/backend/src/Modules/Conversations/Application/Messages/Edit/EditMessageCommand.cs @@ -0,0 +1,69 @@ +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Contracts.Messaging.Domain; +using Knot.Contracts.Conversations.Application.Abstractions; +using Knot.Contracts.Conversations.Domain; +using Knot.Modules.Conversations.Infrastructure.SignalR; +using Knot.Shared.Kernel; +using MediatR; +using Microsoft.AspNetCore.SignalR; + +namespace Knot.Modules.Conversations.Application.Messages.Edit; + +public sealed record EditMessageCommand( + Guid MessageId, + Guid ChatId, + Guid UserId, + string Content) : ICommand; + +public sealed class EditMessageCommandHandler : ICommandHandler +{ + private readonly IMessageRepository _messageRepository; + private readonly IChatsUnitOfWork _unitOfWork; + private readonly IHubContext _hubContext; + + public EditMessageCommandHandler( + IMessageRepository messageRepository, + IChatsUnitOfWork unitOfWork, + IHubContext hubContext) + { + _messageRepository = messageRepository; + _unitOfWork = unitOfWork; + _hubContext = hubContext; + } + + public async Task Handle(EditMessageCommand request, CancellationToken cancellationToken) + { + var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken); + + if (message is null) + { + return Result.Failure(new Error("Message.NotFound", "Message not found.")); + } + + if (message.SenderId != request.UserId) + { + return Result.Failure(new Error("Message.Forbidden", "You can only edit your own messages.")); + } + + if (message.ChatId != request.ChatId) + { + return Result.Failure(new Error("Message.InvalidChat", "Message does not belong to this chat.")); + } + + message.Edit(request.Content); + + await _messageRepository.UpdateAsync(message, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + // Notify clients + await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("message_edited", new + { + messageId = message.Id, + chatId = message.ChatId, + content = message.Content, + isEdited = true + }); + + return Result.Success(); + } +} diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs index ff848ca..83fb099 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs @@ -16,6 +16,7 @@ using Knot.Contracts.Auth.Application.Abstractions; using Knot.Modules.Conversations.Application.Messages.Pin; using Knot.Modules.Conversations.Application.Messages.Unpin; using Knot.Modules.Conversations.Application.Messages.Vote; +using Knot.Modules.Conversations.Application.Messages.Edit; using Knot.Modules.Conversations.Application.DTOs; using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Domain; @@ -284,6 +285,17 @@ public sealed class ChatHub : Hub }); } + [HubMethodName("edit_message")] + public async Task EditMessage(EditMessageHubRequest request) + { + var command = new EditMessageCommand(request.MessageId, request.ChatId, _userContext.UserId, request.Content); + var result = await _sender.Send(command); + if (result.IsFailure) + { + throw new HubException(result.Error.Description); + } + } + [HubMethodName("vote_poll")] public async Task VotePoll(VotePollRequest request) { @@ -856,6 +868,7 @@ public sealed class ChatHub : Hub public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer); public record FriendSignalRequest(string FriendId); public record VotePollRequest(Guid MessageId, Guid ChatId, Guid OptionId); + public record EditMessageHubRequest(Guid MessageId, Guid ChatId, string Content); public class CallSession { diff --git a/client-web/src/modules/chats/presentation/ChatPage.tsx b/client-web/src/modules/chats/presentation/ChatPage.tsx index 5c848c9..0709008 100644 --- a/client-web/src/modules/chats/presentation/ChatPage.tsx +++ b/client-web/src/modules/chats/presentation/ChatPage.tsx @@ -148,8 +148,8 @@ export default function ChatPage() { } }); - socket.on('message_edited', (message: Message) => { - updateMessage(message); + socket.on('message_edited', (data: { messageId: string; chatId: string; content: string; isEdited: boolean }) => { + updateMessage({ id: data.messageId, chatId: data.chatId, content: data.content, isEdited: data.isEdited } as Message); }); socket.on('new_chat', (chat: any) => { diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 7ba4ba9..242ff5f 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -25,13 +25,14 @@ import { PhoneIncoming, PhoneOutgoing, BarChart2, + Music, } from 'lucide-react'; import { useAuthStore } from '../../../auth/application/authStore'; import { useChatStore } from '../../application/chatStore'; import { getSocket } from '../../../../core/infrastructure/socket'; import { useLang } from '../../../../core/infrastructure/i18n'; import { extractWaveform, getMediaUrl, generateAvatarColor, getInitials } from '../../../../core/utils/utils'; -import type { Message, MediaItem, Reaction, ChatMember } from '../../../../core/domain/types'; +import { AUDIO_EXTENSIONS, type Message, type MediaItem, type Reaction, type ChatMember } from '../../../../core/domain/types'; import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox'; import LinkPreview from './LinkPreview'; import Avatar from '../../../../core/presentation/components/ui/Avatar'; @@ -355,11 +356,13 @@ function MessageBubble({ return false; }; + const isAudioFile = (m: MediaItem) => m.type === 'audio' || AUDIO_EXTENSIONS.some(ext => m.filename?.toLowerCase().endsWith(ext)); + const hasImage = media.some((m) => m.type === 'image' || isMediaGif(m)); const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice'); - const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio')); + const hasAudio = !hasVoice && (message.type === 'audio' || media.some(isAudioFile)); const hasVideo = media.some((m) => m.type === 'video' && !isMediaGif(m)); - const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && !isMediaGif(m)); + const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && !isMediaGif(m)); const reactionGroups: Record = {}; (message.reactions || []).forEach((r) => { @@ -765,7 +768,7 @@ function MessageBubble({ {/* Аудио (mp3 файлы) */} {hasAudio && (() => { - const audioMedia = media.find((m) => m.type === 'audio'); + const audioMedia = media.find(isAudioFile); const formatSize = (bytes?: number | null) => { if (!bytes) return ""; if (bytes < 1024) return bytes + " B"; @@ -855,7 +858,7 @@ function MessageBubble({ {/* Файлы */} {hasFile && media - .filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif') + .filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && !isAudioFile(m) && m.type !== 'gif') .map((m) => { const formatSize = (bytes?: number | null) => { if (!bytes) return ""; diff --git a/client-web/src/modules/chats/presentation/components/MessageInput.tsx b/client-web/src/modules/chats/presentation/components/MessageInput.tsx index 0ed3584..fd7786c 100644 --- a/client-web/src/modules/chats/presentation/components/MessageInput.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageInput.tsx @@ -224,10 +224,15 @@ export default function MessageInput({ chatId }: MessageInputProps) { fileSize: res.size })); + let msgType = 'file'; + if (attachments.every(a => a.type === 'image')) msgType = 'image'; + else if (attachments.every(a => a.type === 'audio')) msgType = 'audio'; + else if (attachments.every(a => a.type === 'video')) msgType = 'video'; + socket.emit('send_message', { chatId, content: trimmed || null, - type: attachments.length > 0 ? (attachments.every(a => a.type === 'image') ? 'image' : 'file') : 'text', + type: msgType, attachments: socketAttachments, replyToId: replyTo?.id || null, quote: replyTo?.quote || null,