From fa185afc736cbe2bb9d05e009ede1872e13101f3 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: Mon, 6 Apr 2026 01:51:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BE=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B2=20=D1=87=D0=B0=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contracts/Messaging/Domain/CallMessage.cs | 33 +++++ .../Application/Chats/GetChats/GetChats.cs | 5 +- .../Application/DTOs/ChatMessageDto.cs | 5 +- .../Application/DTOs/MessageDetailDto.cs | 5 +- .../Messages/GetMessages/GetMessagesQuery.cs | 5 +- .../Send/SendMessageCommandHandler.cs | 19 ++- .../Infrastructure/SignalR/ChatHub.cs | 114 +++++++++++++++++- .../Handlers/MessageSentDomainEventHandler.cs | 5 +- .../Mongo/MongoDbMapConfigurator.cs | 7 ++ client-web/src/core/domain/types.ts | 3 + client-web/src/core/infrastructure/i18n.ts | 10 ++ .../presentation/components/CallModal.tsx | 25 +++- .../presentation/components/ChatListItem.tsx | 7 ++ .../presentation/components/MessageBubble.tsx | 56 +++++++++ 14 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 backend/src/Contracts/Messaging/Domain/CallMessage.cs diff --git a/backend/src/Contracts/Messaging/Domain/CallMessage.cs b/backend/src/Contracts/Messaging/Domain/CallMessage.cs new file mode 100644 index 0000000..f9591c3 --- /dev/null +++ b/backend/src/Contracts/Messaging/Domain/CallMessage.cs @@ -0,0 +1,33 @@ +using System; + +namespace Knot.Contracts.Messaging.Domain; + +public class CallMessage : Message +{ + public override string Type => "call"; + public override string? Content { get; protected set; } + public string CallType { get; protected set; } + public string CallStatus { get; protected set; } + public int? Duration { get; protected set; } + + public CallMessage() : base() { } + + public CallMessage( + Guid id, + Guid chatId, + Guid senderId, + string callType, + string callStatus, + int? duration, + Guid? replyToId, + Guid? forwardedFromId, + DateTime createdAt, + bool isImported = false) + : base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported) + { + CallType = callType; + CallStatus = callStatus; + Duration = duration; + Content = $"Call {callStatus}"; + } +} diff --git a/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs b/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs index 865e0e3..0d0f8d1 100644 --- a/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs +++ b/backend/src/Modules/Conversations/Application/Chats/GetChats/GetChats.cs @@ -128,7 +128,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList() + 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 )); } diff --git a/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs b/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs index 1e16e6e..3b773bb 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/ChatMessageDto.cs @@ -21,6 +21,9 @@ public record ChatMessageDto( List Media, MessageSenderDto Sender, List Reactions, - List ReadBy + List ReadBy, + string? CallType = null, + string? CallStatus = null, + int? Duration = null ); diff --git a/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs b/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs index 686350f..747be4b 100644 --- a/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs +++ b/backend/src/Modules/Conversations/Application/DTOs/MessageDetailDto.cs @@ -24,7 +24,10 @@ public record MessageDetailDto( List Media, MessageSenderDto? Sender, List ReadBy, - List Reactions + List Reactions, + string? CallType = null, + string? CallStatus = null, + int? Duration = null ); public record ReplyToMessageDto( diff --git a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs index ea55446..c90ab27 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs @@ -142,7 +142,10 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List(), senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null, chat.Members.Where(m => m.LastReadSequenceId >= message.SequenceId && m.UserId != message.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(), - reactionsWithUser + reactionsWithUser, + (message as CallMessage)?.CallType, + (message as CallMessage)?.CallStatus, + (message as CallMessage)?.Duration )); } diff --git a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs index 1672434..35ed35e 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs @@ -27,7 +27,10 @@ public sealed record SendMessageCommand( List? PollOptions = null, bool? PollIsAnonymous = null, bool? PollAllowMultipleAnswers = null, - DateTime? PollExpiresAt = null) : ICommand; + DateTime? PollExpiresAt = null, + string? CallType = null, + string? CallStatus = null, + int? Duration = null) : ICommand; public sealed class SendMessageCommandHandler : ICommandHandler { @@ -147,6 +150,20 @@ public sealed class SendMessageCommandHandler : ICommandHandler _userConnections.Count; public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId); + + // userId → CallSession (one user can be in only one call at a time) + private static readonly ConcurrentDictionary _activeSessionsByUser = new(); + // chatId → (startTime, callType) + private static readonly ConcurrentDictionary _activeGroupCalls = new(); private readonly ISender _sender; private readonly IUserContext _userContext; @@ -309,11 +314,35 @@ public sealed class ChatHub : Hub username = username } }); + + // Track session for history + Guid? chatId = null; + if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId; + + if (!chatId.HasValue) + { + var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); + if (Guid.TryParse(request.TargetUserId, out var targetId)) + { + var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId)); + if (personalChat != null) chatId = personalChat.Id; + } + } + + var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow); + _activeSessionsByUser[_userContext.UserId.ToString()] = session; + _activeSessionsByUser[request.TargetUserId] = session; } [HubMethodName("call_answer")] public async Task CallAnswer(CallAnswerRequest request) { + if (_activeSessionsByUser.TryGetValue(_userContext.UserId.ToString(), out var session)) + { + session.IsAnswered = true; + session.AnswerTime = DateTime.UtcNow; + } + await SendToUserAsync(request.TargetUserId, "call_answered", new { from = _userContext.UserId.ToString(), @@ -324,6 +353,19 @@ public sealed class ChatHub : Hub [HubMethodName("call_decline")] public async Task CallDecline(TargetUserRequest request) { + var currentUserIdStr = _userContext.UserId.ToString(); + if (_activeSessionsByUser.TryRemove(currentUserIdStr, out var session)) + { + _activeSessionsByUser.TryRemove(request.TargetUserId, out _); + if (session.ChatId.HasValue) + { + // If declined by recipient, it's a "declined" call + // If current user is recipient (not the one who started), status is declined + string status = _userContext.UserId == session.FromUserId ? "cancelled" : "declined"; + await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, 0); + } + } + await SendToUserAsync(request.TargetUserId, "call_declined", new { from = _userContext.UserId.ToString(), @@ -333,12 +375,53 @@ public sealed class ChatHub : Hub [HubMethodName("call_end")] public async Task CallEnd(TargetUserRequest request) { + var currentUserIdStr = _userContext.UserId.ToString(); + if (_activeSessionsByUser.TryRemove(currentUserIdStr, out var session)) + { + _activeSessionsByUser.TryRemove(request.TargetUserId, out _); + if (session.ChatId.HasValue) + { + int duration = session.IsAnswered && session.AnswerTime.HasValue + ? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds + : 0; + + string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed"); + await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration); + } + } + await SendToUserAsync(request.TargetUserId, "call_ended", new { from = _userContext.UserId.ToString(), }); } + private async Task CreateCallMessage(Guid chatId, Guid senderId, string callType, string status, int duration) + { + var command = new SendMessageCommand( + chatId, + senderId, + null, + "call", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + callType, + status, + duration + ); + + await _sender.Send(command); + } + [HubMethodName("ice_candidate")] public async Task IceCandidate(IceCandidateRequest request) { @@ -410,7 +493,11 @@ public sealed class ChatHub : Hub var userInfo = new ParticipantInfo(userId, username, displayName, avatar); - var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary()); + var participants = _groupCallParticipants.GetOrAdd(chatId, _ => + { + _activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType); + return new ConcurrentDictionary(); + }); var isFirst = participants.IsEmpty; participants.TryAdd(userId, userInfo); @@ -468,6 +555,11 @@ public sealed class ChatHub : Hub if (participants.IsEmpty) { _groupCallParticipants.TryRemove(chatId, out _); + if (_activeGroupCalls.TryRemove(chatId, out var info)) + { + var duration = (int)(DateTime.UtcNow - info.StartTime).TotalSeconds; + await CreateCallMessage(Guid.Parse(chatId), _userContext.UserId, info.CallType, "completed", duration); + } await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId }); } } @@ -691,5 +783,25 @@ public sealed class ChatHub : Hub public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer); public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer); public record FriendSignalRequest(string FriendId); + + public class CallSession + { + public Guid? ChatId { get; } + public Guid FromUserId { get; } + public Guid ToUserId { get; } + public string CallType { get; } + public DateTime StartTime { get; } + public bool IsAnswered { get; set; } + public DateTime? AnswerTime { get; set; } + + public CallSession(Guid? chatId, Guid fromUserId, Guid toUserId, string callType, DateTime startTime) + { + ChatId = chatId; + FromUserId = fromUserId; + ToUserId = toUserId; + CallType = callType; + StartTime = startTime; + } + } } diff --git a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs index 5f16bb5..f610259 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs @@ -87,7 +87,10 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler(), storyId = (message as StoryMessage)?.StoryId, storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl, - storyMediaType = (message as StoryMessage)?.StoryMediaType + storyMediaType = (message as StoryMessage)?.StoryMediaType, + callType = (message as CallMessage)?.CallType, + callStatus = (message as CallMessage)?.CallStatus, + duration = (message as CallMessage)?.Duration }, cancellationToken); } } diff --git a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs index 7cae70d..3b2c152 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Persistence/Mongo/MongoDbMapConfigurator.cs @@ -71,6 +71,13 @@ public static class MongoDbMapConfigurator cm.SetDiscriminator("PollMessage"); }); + BsonClassMap.RegisterClassMap(cm => + { + cm.AutoMap(); + cm.SetIgnoreExtraElements(true); + cm.SetDiscriminator("CallMessage"); + }); + BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index 185df66..899db30 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -108,6 +108,9 @@ export interface Message { media: MediaItem[]; reactions: Reaction[]; readBy: Array<{ userId: string }>; + callType?: 'voice' | 'video' | string | null; + callStatus?: 'missed' | 'completed' | 'cancelled' | 'declined' | string | null; + duration?: number | null; } export interface Chat { diff --git a/client-web/src/core/infrastructure/i18n.ts b/client-web/src/core/infrastructure/i18n.ts index 9f2d409..8773627 100644 --- a/client-web/src/core/infrastructure/i18n.ts +++ b/client-web/src/core/infrastructure/i18n.ts @@ -109,6 +109,11 @@ const translations = { endCall: 'Завершить', callEnded: 'Звонок завершён', callDeclined: 'Звонок отклонён', + audioCall: 'Голосовой звонок', + missedCall: 'Пропущенный звонок', + declinedCall: 'Отклонённый звонок', + cancelledCall: 'Отменённый звонок', + completedCall: 'Вызов завершён', // Photo/video photoVideo: 'Фото / видео', fileBtn: 'Файл', @@ -470,6 +475,11 @@ const translations = { endCall: 'End call', callEnded: 'Call ended', callDeclined: 'Call declined', + audioCall: 'Audio call', + missedCall: 'Missed call', + declinedCall: 'Declined call', + cancelledCall: 'Cancelled call', + completedCall: 'Call completed', photoVideo: 'Photo / video', fileBtn: 'File', sendError: 'Send error', diff --git a/client-web/src/modules/calls/presentation/components/CallModal.tsx b/client-web/src/modules/calls/presentation/components/CallModal.tsx index b0a3dee..d3c4718 100644 --- a/client-web/src/modules/calls/presentation/components/CallModal.tsx +++ b/client-web/src/modules/calls/presentation/components/CallModal.tsx @@ -1542,15 +1542,32 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi } }, [isOpen, incoming, targetUser, callState, startCall]); - // Sync local video ref with stream (only when srcObject actually changes) + // Sync local video ref with stream useEffect(() => { if (!localVideoRef.current) return; + const video = localVideoRef.current as any; const desired = isScreenSharing && screenStreamRef.current ? screenStreamRef.current : localStreamRef.current; - if (desired && localVideoRef.current.srcObject !== desired) { - localVideoRef.current.srcObject = desired; - } + + const syncLocal = async () => { + if (desired && video.srcObject !== desired) { + console.log('[WebRTC] Syncing local video srcObject'); + video.srcObject = desired; + video.muted = true; + try { + if (video._playPromise) await video._playPromise; + video._playPromise = video.play(); + await video._playPromise; + video._playPromise = null; + } catch (e) { + video._playPromise = null; + console.warn('[WebRTC] Local video play failed:', e); + } + } + }; + + syncLocal(); }); // Sync remote video/audio ref with remote stream diff --git a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx index fd3ac93..3bb4fbd 100644 --- a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx @@ -62,6 +62,13 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { : lastMessage.media?.[0]?.type === 'video' ? t('video') : t('file') + : lastMessage.type === 'call' + ? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t( + lastMessage.callStatus === 'missed' ? 'missedCall' : + lastMessage.callStatus === 'declined' ? 'declinedCall' : + lastMessage.callStatus === 'cancelled' ? 'cancelledCall' : + 'completedCall' + )}` : lastMessage.content || '' : ''; diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 91b00c0..1387158 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -19,6 +19,11 @@ import { Pin, Clock, Forward, + Phone, + Video, + PhoneMissed, + PhoneIncoming, + PhoneOutgoing, } from 'lucide-react'; import { useAuthStore } from '../../../auth/application/authStore'; import { useChatStore } from '../../application/chatStore'; @@ -286,6 +291,57 @@ function MessageBubble({ return null; } + if (message.type === 'call') { + const isMissed = message.callStatus === 'missed' || message.callStatus === 'declined' || message.callStatus === 'cancelled'; + const statusText = isMissed + ? (message.callStatus === 'missed' ? t('missedCall') : message.callStatus === 'declined' ? t('declinedCall') : t('cancelledCall')) + : t('completedCall'); + + const StatusIcon = isMissed ? PhoneMissed : (isMine ? PhoneOutgoing : PhoneIncoming); + const CallIcon = message.callType === 'video' ? Video : StatusIcon; + + return ( +
+
+ +
+ +
+ +
+

+ {message.callType === 'video' ? t('videoCall') : t('audioCall')} +

+
+ + {statusText} {message.duration && message.duration > 0 ? `• ${formatDuration(message.duration)}` : ''} + +
+
+ +
+
+ {timeStr} +
+ {isMine && ( +
+ {isRead ? : } +
+ )} +
+ +
+
+
+ ); + } + const media = message.media || []; const isMediaGif = (m: MediaItem) => {