Информация о звонках в чате

This commit is contained in:
Халимов Рустам
2026-04-06 01:51:48 +03:00
parent 90096ce2bc
commit fa185afc73
14 changed files with 288 additions and 11 deletions

View File

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

View File

@@ -128,7 +128,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
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()
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
));
}

View File

@@ -21,6 +21,9 @@ public record ChatMessageDto(
List<MediaDto> Media,
MessageSenderDto Sender,
List<ReactionDto> Reactions,
List<ReadByDto> ReadBy
List<ReadByDto> ReadBy,
string? CallType = null,
string? CallStatus = null,
int? Duration = null
);

View File

@@ -24,7 +24,10 @@ public record MessageDetailDto(
List<MediaDto> Media,
MessageSenderDto? Sender,
List<ReadByDto> ReadBy,
List<MessageReactionDto> Reactions
List<MessageReactionDto> Reactions,
string? CallType = null,
string? CallStatus = null,
int? Duration = null
);
public record ReplyToMessageDto(

View File

@@ -142,7 +142,10 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
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
));
}

View File

@@ -27,7 +27,10 @@ public sealed record SendMessageCommand(
List<string>? PollOptions = null,
bool? PollIsAnonymous = null,
bool? PollAllowMultipleAnswers = null,
DateTime? PollExpiresAt = null) : ICommand<Guid>;
DateTime? PollExpiresAt = null,
string? CallType = null,
string? CallStatus = null,
int? Duration = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
@@ -147,6 +150,20 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
DateTime.UtcNow,
false);
}
else if (request.Type == "call")
{
message = new CallMessage(
Guid.NewGuid(),
request.ChatId,
request.SenderId,
request.CallType ?? "voice",
request.CallStatus ?? "completed",
request.Duration,
request.ReplyToId,
request.ForwardedFromId,
DateTime.UtcNow,
false);
}
else
{
message = new TextMessage(

View File

@@ -29,6 +29,11 @@ public sealed class ChatHub : Hub
public static int OnlineUsersCount => _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<string, CallSession> _activeSessionsByUser = new();
// chatId → (startTime, callType)
private static readonly ConcurrentDictionary<string, (DateTime StartTime, string CallType)> _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<string, ParticipantInfo>());
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
{
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
return new ConcurrentDictionary<string, ParticipantInfo>();
});
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;
}
}
}

View File

@@ -87,7 +87,10 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
readBy = new List<object>(),
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);
}
}

View File

@@ -71,6 +71,13 @@ public static class MongoDbMapConfigurator
cm.SetDiscriminator("PollMessage");
});
BsonClassMap.RegisterClassMap<CallMessage>(cm =>
{
cm.AutoMap();
cm.SetIgnoreExtraElements(true);
cm.SetDiscriminator("CallMessage");
});
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className={`flex ${isMine ? 'justify-end' : 'justify-start'} mb-3 px-4 group scroll-mt-20`} data-message-id={message.id}>
<div
onContextMenu={handleContextMenu}
className={`group/call relative flex items-center gap-3.5 px-4 py-3 rounded-[1.25rem] border backdrop-blur-sm transition-all duration-300 cursor-default select-none
${isMine
? 'bg-primary/10 border-primary/20 hover:bg-primary/20 shadow-[0_4px_12px_rgba(48,150,229,0.08)]'
: 'bg-surface-variant/10 border-white/5 hover:bg-surface-variant/15 shadow-[0_4px_12px_rgba(0,0,0,0.15)]'}`}>
<div className={`w-11 h-11 rounded-2xl flex items-center justify-center shrink-0 shadow-inner group-hover/call:scale-105 transition-transform duration-500
${isMissed ? 'bg-red-500/15 text-red-400' : 'bg-emerald-500/15 text-emerald-400'}`}>
<CallIcon size={22} strokeWidth={2.5} className={isMissed && message.callStatus === 'missed' ? 'animate-wiggle' : ''} />
</div>
<div className="flex-1 min-w-0 pr-4">
<h4 className="text-[15px] font-bold text-white tracking-tight leading-tight mb-0.5 truncate">
{message.callType === 'video' ? t('videoCall') : t('audioCall')}
</h4>
<div className="flex items-center gap-1.5 opacity-80">
<span className={`text-[13px] font-medium ${isMissed ? 'text-red-400' : 'text-zinc-400'}`}>
{statusText} {message.duration && message.duration > 0 ? `${formatDuration(message.duration)}` : ''}
</span>
</div>
</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">
{timeStr}
</div>
{isMine && (
<div className="opacity-60 flex gap-0.5">
{isRead ? <CheckCheck size={12} className="text-primary" /> : <Check size={12} className="text-zinc-500" />}
</div>
)}
</div>
<div className="absolute inset-0 rounded-[1.25rem] bg-white/[0.03] opacity-0 group-hover/call:opacity-100 transition-opacity pointer-events-none" />
</div>
</div>
);
}
const media = message.media || [];
const isMediaGif = (m: MediaItem) => {