Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa185afc73 | ||
|
|
90096ce2bc |
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
senderObj.Avatar
|
senderObj.Avatar
|
||||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||||
reactionsWithUser,
|
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
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public record ChatMessageDto(
|
|||||||
List<MediaDto> Media,
|
List<MediaDto> Media,
|
||||||
MessageSenderDto Sender,
|
MessageSenderDto Sender,
|
||||||
List<ReactionDto> Reactions,
|
List<ReactionDto> Reactions,
|
||||||
List<ReadByDto> ReadBy
|
List<ReadByDto> ReadBy,
|
||||||
|
string? CallType = null,
|
||||||
|
string? CallStatus = null,
|
||||||
|
int? Duration = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ public record MessageDetailDto(
|
|||||||
List<MediaDto> Media,
|
List<MediaDto> Media,
|
||||||
MessageSenderDto? Sender,
|
MessageSenderDto? Sender,
|
||||||
List<ReadByDto> ReadBy,
|
List<ReadByDto> ReadBy,
|
||||||
List<MessageReactionDto> Reactions
|
List<MessageReactionDto> Reactions,
|
||||||
|
string? CallType = null,
|
||||||
|
string? CallStatus = null,
|
||||||
|
int? Duration = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ReplyToMessageDto(
|
public record ReplyToMessageDto(
|
||||||
|
|||||||
+4
-1
@@ -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>(),
|
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,
|
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(),
|
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
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -27,7 +27,10 @@ public sealed record SendMessageCommand(
|
|||||||
List<string>? PollOptions = null,
|
List<string>? PollOptions = null,
|
||||||
bool? PollIsAnonymous = null,
|
bool? PollIsAnonymous = null,
|
||||||
bool? PollAllowMultipleAnswers = 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>
|
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||||
{
|
{
|
||||||
@@ -147,6 +150,20 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
false);
|
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
|
else
|
||||||
{
|
{
|
||||||
message = new TextMessage(
|
message = new TextMessage(
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ using Knot.Modules.Conversations.Application.Messages.React;
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Modules.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
@@ -28,17 +30,24 @@ public sealed class ChatHub : Hub
|
|||||||
public static int OnlineUsersCount => _userConnections.Count;
|
public static int OnlineUsersCount => _userConnections.Count;
|
||||||
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
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 ISender _sender;
|
||||||
private readonly IUserContext _userContext;
|
private readonly IUserContext _userContext;
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ILogger<ChatHub> _logger;
|
private readonly ILogger<ChatHub> _logger;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger<ChatHub> logger, IMemoryCache cache)
|
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, IUserRepository userRepository, ILogger<ChatHub> logger, IMemoryCache cache)
|
||||||
{
|
{
|
||||||
_sender = sender;
|
_sender = sender;
|
||||||
_userContext = userContext;
|
_userContext = userContext;
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
}
|
}
|
||||||
@@ -284,10 +293,12 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("call_offer")]
|
[HubMethodName("call_offer")]
|
||||||
public async Task CallOffer(CallOfferRequest request)
|
public async Task CallOffer(CallOfferRequest request)
|
||||||
{
|
{
|
||||||
// Try to get caller info from current user's claims
|
// Fetch fresh user info from repository instead of relying on potentially stale JWT claims
|
||||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
var user = await _userRepository.GetByIdAsync(_userContext.UserId);
|
||||||
var avatar = Context.User?.FindFirstValue("avatar");
|
|
||||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name;
|
var displayName = user?.DisplayName ?? Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||||
|
var avatar = user?.Avatar ?? Context.User?.FindFirstValue("avatar");
|
||||||
|
var username = user?.Username ?? Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name;
|
||||||
|
|
||||||
await SendToUserAsync(request.TargetUserId, "call_incoming", new
|
await SendToUserAsync(request.TargetUserId, "call_incoming", new
|
||||||
{
|
{
|
||||||
@@ -297,18 +308,41 @@ public sealed class ChatHub : Hub
|
|||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
callerInfo = new
|
callerInfo = new
|
||||||
{
|
{
|
||||||
|
|
||||||
id = _userContext.UserId.ToString(),
|
id = _userContext.UserId.ToString(),
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
username = username
|
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")]
|
[HubMethodName("call_answer")]
|
||||||
public async Task CallAnswer(CallAnswerRequest request)
|
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
|
await SendToUserAsync(request.TargetUserId, "call_answered", new
|
||||||
{
|
{
|
||||||
from = _userContext.UserId.ToString(),
|
from = _userContext.UserId.ToString(),
|
||||||
@@ -319,6 +353,19 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("call_decline")]
|
[HubMethodName("call_decline")]
|
||||||
public async Task CallDecline(TargetUserRequest request)
|
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
|
await SendToUserAsync(request.TargetUserId, "call_declined", new
|
||||||
{
|
{
|
||||||
from = _userContext.UserId.ToString(),
|
from = _userContext.UserId.ToString(),
|
||||||
@@ -328,12 +375,53 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("call_end")]
|
[HubMethodName("call_end")]
|
||||||
public async Task CallEnd(TargetUserRequest request)
|
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
|
await SendToUserAsync(request.TargetUserId, "call_ended", new
|
||||||
{
|
{
|
||||||
from = _userContext.UserId.ToString(),
|
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")]
|
[HubMethodName("ice_candidate")]
|
||||||
public async Task IceCandidate(IceCandidateRequest request)
|
public async Task IceCandidate(IceCandidateRequest request)
|
||||||
{
|
{
|
||||||
@@ -396,14 +484,20 @@ public sealed class ChatHub : Hub
|
|||||||
var chatId = request.ChatId;
|
var chatId = request.ChatId;
|
||||||
var userId = _userContext.UserId.ToString();
|
var userId = _userContext.UserId.ToString();
|
||||||
|
|
||||||
|
// Fetch fresh user info from repository
|
||||||
|
var user = await _userRepository.GetByIdAsync(_userContext.UserId);
|
||||||
|
|
||||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
var displayName = user?.DisplayName ?? Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||||
var avatar = Context.User?.FindFirstValue("avatar");
|
var avatar = user?.Avatar ?? Context.User?.FindFirstValue("avatar");
|
||||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name ?? "user";
|
var username = user?.Username ?? Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name ?? "user";
|
||||||
|
|
||||||
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
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;
|
var isFirst = participants.IsEmpty;
|
||||||
participants.TryAdd(userId, userInfo);
|
participants.TryAdd(userId, userInfo);
|
||||||
|
|
||||||
@@ -461,6 +555,11 @@ public sealed class ChatHub : Hub
|
|||||||
if (participants.IsEmpty)
|
if (participants.IsEmpty)
|
||||||
{
|
{
|
||||||
_groupCallParticipants.TryRemove(chatId, out _);
|
_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 });
|
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -684,5 +783,25 @@ public sealed class ChatHub : Hub
|
|||||||
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
||||||
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||||
public record FriendSignalRequest(string FriendId);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -87,7 +87,10 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
|||||||
readBy = new List<object>(),
|
readBy = new List<object>(),
|
||||||
storyId = (message as StoryMessage)?.StoryId,
|
storyId = (message as StoryMessage)?.StoryId,
|
||||||
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
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);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -71,6 +71,13 @@ public static class MongoDbMapConfigurator
|
|||||||
cm.SetDiscriminator("PollMessage");
|
cm.SetDiscriminator("PollMessage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
BsonClassMap.RegisterClassMap<CallMessage>(cm =>
|
||||||
|
{
|
||||||
|
cm.AutoMap();
|
||||||
|
cm.SetIgnoreExtraElements(true);
|
||||||
|
cm.SetDiscriminator("CallMessage");
|
||||||
|
});
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
|
||||||
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export interface Message {
|
|||||||
media: MediaItem[];
|
media: MediaItem[];
|
||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
readBy: Array<{ userId: string }>;
|
readBy: Array<{ userId: string }>;
|
||||||
|
callType?: 'voice' | 'video' | string | null;
|
||||||
|
callStatus?: 'missed' | 'completed' | 'cancelled' | 'declined' | string | null;
|
||||||
|
duration?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ const translations = {
|
|||||||
endCall: 'Завершить',
|
endCall: 'Завершить',
|
||||||
callEnded: 'Звонок завершён',
|
callEnded: 'Звонок завершён',
|
||||||
callDeclined: 'Звонок отклонён',
|
callDeclined: 'Звонок отклонён',
|
||||||
|
audioCall: 'Голосовой звонок',
|
||||||
|
missedCall: 'Пропущенный звонок',
|
||||||
|
declinedCall: 'Отклонённый звонок',
|
||||||
|
cancelledCall: 'Отменённый звонок',
|
||||||
|
completedCall: 'Вызов завершён',
|
||||||
// Photo/video
|
// Photo/video
|
||||||
photoVideo: 'Фото / видео',
|
photoVideo: 'Фото / видео',
|
||||||
fileBtn: 'Файл',
|
fileBtn: 'Файл',
|
||||||
@@ -470,6 +475,11 @@ const translations = {
|
|||||||
endCall: 'End call',
|
endCall: 'End call',
|
||||||
callEnded: 'Call ended',
|
callEnded: 'Call ended',
|
||||||
callDeclined: 'Call declined',
|
callDeclined: 'Call declined',
|
||||||
|
audioCall: 'Audio call',
|
||||||
|
missedCall: 'Missed call',
|
||||||
|
declinedCall: 'Declined call',
|
||||||
|
cancelledCall: 'Cancelled call',
|
||||||
|
completedCall: 'Call completed',
|
||||||
photoVideo: 'Photo / video',
|
photoVideo: 'Photo / video',
|
||||||
fileBtn: 'File',
|
fileBtn: 'File',
|
||||||
sendError: 'Send error',
|
sendError: 'Send error',
|
||||||
|
|||||||
@@ -238,6 +238,30 @@ input:-webkit-autofill:active {
|
|||||||
100% { background-color: transparent; }
|
100% { background-color: transparent; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes callWave {
|
||||||
|
0% { transform: scale(1); opacity: 0.5; }
|
||||||
|
100% { transform: scale(1.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wiggle {
|
||||||
|
0%, 100% { transform: rotate(0); }
|
||||||
|
25% { transform: rotate(-10deg); }
|
||||||
|
75% { transform: rotate(10deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-call-wave {
|
||||||
|
animation: callWave 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-call-wave-delayed {
|
||||||
|
animation: callWave 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-wiggle {
|
||||||
|
animation: wiggle 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.highlight-message {
|
.highlight-message {
|
||||||
animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important;
|
animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
@@ -8,20 +8,16 @@ import { useAuthStore } from '../../../auth/application/authStore';
|
|||||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||||
import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../../../../core/utils/sounds';
|
import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../../../../core/utils/sounds';
|
||||||
import { getMediaUrl } from '../../../../core/utils/utils';
|
import { getMediaUrl } from '../../../../core/utils/utils';
|
||||||
|
import { UserBasic, CallInfo } from '../../../../core/domain/types';
|
||||||
|
|
||||||
type CallState = 'idle' | 'calling' | 'incoming' | 'connected' | 'ended';
|
type CallState = 'idle' | 'calling' | 'incoming' | 'connected' | 'ended';
|
||||||
|
|
||||||
interface CallModalProps {
|
interface CallModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
targetUser: { id: string; displayName?: string; username?: string; avatar?: string | null } | null;
|
targetUser: UserBasic | null;
|
||||||
callType: 'voice' | 'video';
|
callType: 'voice' | 'video';
|
||||||
incoming?: {
|
incoming?: CallInfo | null;
|
||||||
from: string;
|
|
||||||
offer: RTCSessionDescriptionInit;
|
|
||||||
callType: 'voice' | 'video';
|
|
||||||
callerInfo?: { displayName?: string; username?: string; avatar?: string | null } | null;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ICE servers cache (fetched from server)
|
// ICE servers cache (fetched from server)
|
||||||
@@ -1546,15 +1542,32 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
}
|
}
|
||||||
}, [isOpen, incoming, targetUser, callState, startCall]);
|
}, [isOpen, incoming, targetUser, callState, startCall]);
|
||||||
|
|
||||||
// Sync local video ref with stream (only when srcObject actually changes)
|
// Sync local video ref with stream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!localVideoRef.current) return;
|
if (!localVideoRef.current) return;
|
||||||
|
const video = localVideoRef.current as any;
|
||||||
const desired = isScreenSharing && screenStreamRef.current
|
const desired = isScreenSharing && screenStreamRef.current
|
||||||
? screenStreamRef.current
|
? screenStreamRef.current
|
||||||
: localStreamRef.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
|
// Sync remote video/audio ref with remote stream
|
||||||
@@ -1614,12 +1627,12 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const displayName = incoming
|
const displayName = incoming
|
||||||
? incoming.callerInfo?.displayName || incoming.callerInfo?.username || '...'
|
? incoming.callerInfo?.displayName || incoming.callerInfo?.userName || incoming.callerInfo?.username || '...'
|
||||||
: targetUser?.displayName || targetUser?.username || '...';
|
: targetUser?.displayName || targetUser?.userName || targetUser?.username || '...';
|
||||||
|
|
||||||
const displayAvatar = incoming
|
const displayAvatar = incoming
|
||||||
? incoming.callerInfo?.avatar
|
? incoming.callerInfo?.avatarUrl || incoming.callerInfo?.avatar
|
||||||
: targetUser?.avatar;
|
: targetUser?.avatarUrl || targetUser?.avatar;
|
||||||
|
|
||||||
const initials = displayName
|
const initials = displayName
|
||||||
.split(' ')
|
.split(' ')
|
||||||
@@ -1655,16 +1668,15 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-2xl glass-strong shadow-2xl shadow-black/50 border border-white/10 cursor-pointer select-none"
|
className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-2xl glass-strong shadow-2xl shadow-black/50 border border-white/10 cursor-pointer select-none"
|
||||||
onClick={() => setIsMinimized(false)}
|
onClick={() => setIsMinimized(false)}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 rounded-full bg-knot-500/30 animate-call-wave" />
|
{callState === 'connected' && (
|
||||||
{displayAvatar ? (
|
<div className="absolute inset-0 rounded-xl bg-knot-500/30 animate-call-wave" />
|
||||||
<img src={displayAvatar} alt="" className="relative w-10 h-10 rounded-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="relative w-10 h-10 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-sm shadow-inner">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<Avatar
|
||||||
|
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
|
||||||
|
name={displayName || '?'}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-white font-medium truncate max-w-[120px]">{displayName}</p>
|
<p className="text-sm text-white font-medium truncate max-w-[120px]">{displayName}</p>
|
||||||
@@ -1801,7 +1813,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
{(!hasRemoteVideo || remoteIsVideoOffSignal) && !remoteScreenSharing && (
|
{(!hasRemoteVideo || remoteIsVideoOffSignal) && !remoteScreenSharing && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-md z-10">
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-md z-10">
|
||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<div className="absolute inset-0 rounded-full bg-knot-500/10 animate-pulse" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-knot-500/10 animate-pulse" />
|
||||||
<Avatar
|
<Avatar
|
||||||
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
|
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
|
||||||
name={displayName || '?'}
|
name={displayName || '?'}
|
||||||
@@ -1916,17 +1928,17 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
|||||||
<div className="relative mb-8 mt-4">
|
<div className="relative mb-8 mt-4">
|
||||||
{(callState === 'calling' || callState === 'connected') && (
|
{(callState === 'calling' || callState === 'connected') && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 rounded-full bg-knot-500/30 animate-call-wave" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-knot-500/30 animate-call-wave" />
|
||||||
<div className="absolute inset-0 rounded-full bg-knot-500/20 animate-call-wave-delayed" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-knot-500/20 animate-call-wave-delayed" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{callState === 'incoming' && (
|
{callState === 'incoming' && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 rounded-full bg-emerald-500/30 animate-call-wave" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/30 animate-call-wave" />
|
||||||
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave-delayed" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave-delayed" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl cursor-pointer">
|
<div className="relative z-10 p-1.5 rounded-[1.5rem] bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl cursor-pointer">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
|
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
|
||||||
name={displayName || '?'}
|
name={displayName || '?'}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { useLang } from '../../../../core/infrastructure/i18n';
|
|||||||
interface ParticipantInfo {
|
interface ParticipantInfo {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
userName?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
isSharingScreen?: boolean;
|
isSharingScreen?: boolean;
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
isVideoOff?: boolean;
|
isVideoOff?: boolean;
|
||||||
@@ -818,8 +820,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
onClick={() => setIsMinimized(false)}
|
onClick={() => setIsMinimized(false)}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 rounded-full bg-emerald-500/30 animate-call-wave" />
|
<div className="absolute inset-0 rounded-xl bg-emerald-500/30 animate-call-wave" />
|
||||||
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-sm">
|
<div className="relative w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-sm">
|
||||||
{participantList.length + 1}
|
{participantList.length + 1}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -949,7 +951,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={p.avatar ? getMediaUrl(p.avatar) : null}
|
src={(p.avatarUrl || p.avatar) ? getMediaUrl(p.avatarUrl || p.avatar) : null}
|
||||||
name={t('you') || '?'}
|
name={t('you') || '?'}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="shadow-xl"
|
className="shadow-xl"
|
||||||
@@ -968,8 +970,8 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={p.avatar ? getMediaUrl(p.avatar) : null}
|
src={(p.avatarUrl || p.avatar) ? getMediaUrl(p.avatarUrl || p.avatar) : null}
|
||||||
name={p.displayName || p.username || '?'}
|
name={p.displayName || p.userName || p.username || '?'}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="shadow-xl"
|
className="shadow-xl"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
|
|||||||
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
|
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
|
||||||
import SettingsPage from '../../users/presentation/components/SettingsPage';
|
import SettingsPage from '../../users/presentation/components/SettingsPage';
|
||||||
import UserProfile from '../../users/presentation/components/UserProfile';
|
import UserProfile from '../../users/presentation/components/UserProfile';
|
||||||
|
import Avatar from '../../../core/presentation/components/ui/Avatar';
|
||||||
|
import { getMediaUrl } from '../../../core/utils/utils';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const {
|
const {
|
||||||
@@ -476,14 +478,13 @@ export default function ChatPage() {
|
|||||||
className="bg-zinc-900 border border-white/10 p-8 rounded-3xl w-full max-w-sm flex flex-col items-center shadow-2xl"
|
className="bg-zinc-900 border border-white/10 p-8 rounded-3xl w-full max-w-sm flex flex-col items-center shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
|
||||||
<div className="relative w-24 h-24 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-4xl font-black text-on-primary uppercase overflow-hidden shadow-2xl">
|
<Avatar
|
||||||
{incomingGroupCall.callerInfo?.avatar ? (
|
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
||||||
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
|
name={incomingGroupCall.chatName || '?'}
|
||||||
) : (
|
size="2xl"
|
||||||
<>{incomingGroupCall.chatName.charAt(0)}</>
|
className="relative shadow-2xl"
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl text-white font-semibold mb-2 text-center break-words w-full max-w-full">
|
<h2 className="text-2xl text-white font-semibold mb-2 text-center break-words w-full max-w-full">
|
||||||
{incomingGroupCall.chatName}
|
{incomingGroupCall.chatName}
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
: lastMessage.media?.[0]?.type === 'video'
|
: lastMessage.media?.[0]?.type === 'video'
|
||||||
? t('video')
|
? t('video')
|
||||||
: t('file')
|
: t('file')
|
||||||
|
: lastMessage.type === 'call'
|
||||||
|
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
|
||||||
|
lastMessage.callStatus === 'missed' ? 'missedCall' :
|
||||||
|
lastMessage.callStatus === 'declined' ? 'declinedCall' :
|
||||||
|
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
|
||||||
|
'completedCall'
|
||||||
|
)}`
|
||||||
: lastMessage.content || ''
|
: lastMessage.content || ''
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
Pin,
|
Pin,
|
||||||
Clock,
|
Clock,
|
||||||
Forward,
|
Forward,
|
||||||
|
Phone,
|
||||||
|
Video,
|
||||||
|
PhoneMissed,
|
||||||
|
PhoneIncoming,
|
||||||
|
PhoneOutgoing,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '../../../auth/application/authStore';
|
import { useAuthStore } from '../../../auth/application/authStore';
|
||||||
import { useChatStore } from '../../application/chatStore';
|
import { useChatStore } from '../../application/chatStore';
|
||||||
@@ -286,6 +291,57 @@ function MessageBubble({
|
|||||||
return null;
|
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 media = message.media || [];
|
||||||
|
|
||||||
const isMediaGif = (m: MediaItem) => {
|
const isMediaGif = (m: MediaItem) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user