2 Commits
17 changed files with 610 additions and 304 deletions
@@ -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(
@@ -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
)); ));
} }
@@ -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;
@@ -27,18 +29,25 @@ 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;
}
}
} }
@@ -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);
} }
} }
@@ -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());
+3
View File
@@ -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',
+270 -246
View File
@@ -1,246 +1,270 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
color-scheme: dark; color-scheme: dark;
--primary: #9acbff; --primary: #9acbff;
--primary-container: #3096e5; --primary-container: #3096e5;
--on-primary: #003355; --on-primary: #003355;
--on-primary-container: #e0f2ff; --on-primary-container: #e0f2ff;
--secondary: #c7c6ca; --secondary: #c7c6ca;
--secondary-container: #48494c; --secondary-container: #48494c;
--on-secondary: #2f3033; --on-secondary: #2f3033;
--on-secondary-container: #e3e2e6; --on-secondary-container: #e3e2e6;
--tertiary: #53e16f; --tertiary: #53e16f;
--tertiary-container: #006e25; --tertiary-container: #006e25;
--on-tertiary: #00390a; --on-tertiary: #00390a;
--on-tertiary-container: #affca0; --on-tertiary-container: #affca0;
--error: #ffb4ab; --error: #ffb4ab;
--error-container: #93000a; --error-container: #93000a;
--on-error: #690005; --on-error: #690005;
--on-error-container: #ffdad6; --on-error-container: #ffdad6;
--surface: #131313; --surface: #131313;
--surface-container-lowest: #0b0b0b; --surface-container-lowest: #0b0b0b;
--surface-container-low: #1b1b1b; --surface-container-low: #1b1b1b;
--surface-container: #201f1f; --surface-container: #201f1f;
--surface-container-high: #2a2a2a; --surface-container-high: #2a2a2a;
--surface-container-highest: #353535; --surface-container-highest: #353535;
--on-surface: #f5f5fa; --on-surface: #f5f5fa;
--on-surface-variant: #b4b4c3; --on-surface-variant: #b4b4c3;
--outline: #3c3c4b; --outline: #3c3c4b;
--outline-variant: #2d2d37; --outline-variant: #2d2d37;
--ease-ice: cubic-bezier(0.4, 0, 0.2, 1); --ease-ice: cubic-bezier(0.4, 0, 0.2, 1);
--shadow-ambient: 0 40px 80px -20px rgba(0, 0, 0, 0.5); --shadow-ambient: 0 40px 80px -20px rgba(0, 0, 0, 0.5);
} }
@theme { @theme {
--font-sans: 'Inter', system-ui, -apple-system, sans-serif; --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-headline: 'Inter', sans-serif; --font-headline: 'Inter', sans-serif;
--font-body: 'Inter', sans-serif; --font-body: 'Inter', sans-serif;
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-container: var(--primary-container); --color-primary-container: var(--primary-container);
--color-on-primary: var(--on-primary); --color-on-primary: var(--on-primary);
--color-on-primary-container: var(--on-primary-container); --color-on-primary-container: var(--on-primary-container);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-container: var(--secondary-container); --color-secondary-container: var(--secondary-container);
--color-on-secondary: var(--on-secondary); --color-on-secondary: var(--on-secondary);
--color-on-secondary-container: var(--on-secondary-container); --color-on-secondary-container: var(--on-secondary-container);
--color-tertiary: var(--tertiary); --color-tertiary: var(--tertiary);
--color-tertiary-container: var(--tertiary-container); --color-tertiary-container: var(--tertiary-container);
--color-on-tertiary: var(--on-tertiary); --color-on-tertiary: var(--on-tertiary);
--color-on-tertiary-container: var(--on-tertiary-container); --color-on-tertiary-container: var(--on-tertiary-container);
--color-error: var(--error); --color-error: var(--error);
--color-error-container: var(--error-container); --color-error-container: var(--error-container);
--color-on-error: var(--on-error); --color-on-error: var(--on-error);
--color-on-error-container: var(--on-error-container); --color-on-error-container: var(--on-error-container);
--color-surface: var(--surface); --color-surface: var(--surface);
--color-surface-container-lowest: var(--surface-container-lowest); --color-surface-container-lowest: var(--surface-container-lowest);
--color-surface-container-low: var(--surface-container-low); --color-surface-container-low: var(--surface-container-low);
--color-surface-container: var(--surface-container); --color-surface-container: var(--surface-container);
--color-surface-container-high: var(--surface-container-high); --color-surface-container-high: var(--surface-container-high);
--color-surface-container-highest: var(--surface-container-highest); --color-surface-container-highest: var(--surface-container-highest);
--color-on-surface: var(--on-surface); --color-on-surface: var(--on-surface);
--color-on-surface-variant: var(--on-surface-variant); --color-on-surface-variant: var(--on-surface-variant);
--color-outline: var(--outline); --color-outline: var(--outline);
--color-outline-variant: var(--outline-variant); --color-outline-variant: var(--outline-variant);
--animate-kinetic-in: slideInUp 0.6s var(--ease-ice) forwards; --animate-kinetic-in: slideInUp 0.6s var(--ease-ice) forwards;
} }
/* Global resets and base styles */ /* Global resets and base styles */
html, html,
body, body,
#root { #root {
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
background-color: #131313 !important; background-color: #131313 !important;
/* Force to Obsidian surface */ /* Force to Obsidian surface */
color: #f5f5fa !important; color: #f5f5fa !important;
/* Force to high-contrast white */ /* Force to high-contrast white */
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* Base style for all elements if they inherit color incorrectly */ /* Base style for all elements if they inherit color incorrectly */
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
/* Kinetic Core Components */ /* Kinetic Core Components */
.knot-card { .knot-card {
background-color: var(--surface-container-low) !important; background-color: var(--surface-container-low) !important;
border-radius: 2rem; border-radius: 2rem;
/* 32px */ /* 32px */
transition: all 0.4s var(--ease-ice); transition: all 0.4s var(--ease-ice);
overflow: hidden; overflow: hidden;
} }
.knot-card-active { .knot-card-active {
background-color: var(--surface-container) !important; background-color: var(--surface-container) !important;
} }
.knot-input-group { .knot-input-group {
background-color: rgba(53, 53, 53, 0.4) !important; background-color: rgba(53, 53, 53, 0.4) !important;
border-radius: 1.75rem; border-radius: 1.75rem;
padding: 1.25rem 1.6rem; padding: 1.25rem 1.6rem;
border: 1px solid rgba(154, 203, 255, 0.1); border: 1px solid rgba(154, 203, 255, 0.1);
transition: all 0.4s var(--ease-ice); transition: all 0.4s var(--ease-ice);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
.knot-input-group:focus-within { .knot-input-group:focus-within {
background-color: rgba(53, 53, 53, 0.6) !important; background-color: rgba(53, 53, 53, 0.6) !important;
border-color: #9acbff !important; border-color: #9acbff !important;
box-shadow: 0 0 0 1px #9acbff, 0 0 40px rgba(154, 203, 255, 0.1); box-shadow: 0 0 0 1px #9acbff, 0 0 40px rgba(154, 203, 255, 0.1);
} }
.knot-input-group input { .knot-input-group input {
background: transparent !important; background: transparent !important;
border: none !important; border: none !important;
outline: none !important; outline: none !important;
width: 100%; width: 100%;
color: #f5f5fa !important; color: #f5f5fa !important;
} }
.knot-button-primary { .knot-button-primary {
background: linear-gradient(135deg, #9acbff, #3096e5) !important; background: linear-gradient(135deg, #9acbff, #3096e5) !important;
color: #e0f2ff !important; color: #e0f2ff !important;
border-radius: 1.5rem; border-radius: 1.5rem;
font-weight: 900; font-weight: 900;
letter-spacing: 0.05em; letter-spacing: 0.05em;
transition: all 0.4s var(--ease-ice); transition: all 0.4s var(--ease-ice);
} }
.knot-button-primary:hover { .knot-button-primary:hover {
transform: translateY(-2px); transform: translateY(-2px);
filter: brightness(1.1); filter: brightness(1.1);
box-shadow: 0 24px 32px -12px rgba(48, 150, 229, 0.4); box-shadow: 0 24px 32px -12px rgba(48, 150, 229, 0.4);
} }
/* Utilities */ /* Utilities */
.glass-effect { .glass-effect {
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
background: rgba(19, 19, 19, 0.6); background: rgba(19, 19, 19, 0.6);
} }
.slide-on-ice { .slide-on-ice {
transition: all 0.4s var(--ease-ice); transition: all 0.4s var(--ease-ice);
} }
.animate-kinetic { .animate-kinetic {
animation: var(--animate-kinetic-in); animation: var(--animate-kinetic-in);
} }
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 4px; width: 4px;
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--outline-variant); background: var(--outline-variant);
border-radius: 10px; border-radius: 10px;
} }
/* Overrides for legacy code */ /* Overrides for legacy code */
.bg-surface { .bg-surface {
background-color: var(--surface) !important; background-color: var(--surface) !important;
} }
.bg-surface-container-low { .bg-surface-container-low {
background-color: var(--surface-container-low) !important; background-color: var(--surface-container-low) !important;
} }
.bg-surface-container-lowest { .bg-surface-container-lowest {
background-color: var(--surface-container-lowest) !important; background-color: var(--surface-container-lowest) !important;
} }
.bg-surface-container-high { .bg-surface-container-high {
background-color: var(--surface-container-high) !important; background-color: var(--surface-container-high) !important;
} }
.text-on-surface { .text-on-surface {
color: var(--on-surface) !important; color: var(--on-surface) !important;
} }
.text-on-surface-variant { .text-on-surface-variant {
color: var(--on-surface-variant) !important; color: var(--on-surface-variant) !important;
} }
.text-primary { .text-primary {
color: var(--primary) !important; color: var(--primary) !important;
} }
.text-accent { .text-accent {
color: #9acbff !important; color: #9acbff !important;
} }
/* Bubble overrides */ /* Bubble overrides */
.bubble-sent { .bubble-sent {
background: linear-gradient(135deg, #9acbff, #3096e5) !important; background: linear-gradient(135deg, #9acbff, #3096e5) !important;
color: #e0f2ff !important; color: #e0f2ff !important;
border-radius: 1.5rem; border-radius: 1.5rem;
border-bottom-right-radius: 0.25rem; border-bottom-right-radius: 0.25rem;
} }
.bubble-received { .bubble-received {
background: var(--surface-container-highest) !important; background: var(--surface-container-highest) !important;
color: var(--on-surface) !important; color: var(--on-surface) !important;
border-radius: 1.5rem; border-radius: 1.5rem;
border-bottom-left-radius: 0.25rem; border-bottom-left-radius: 0.25rem;
} }
/* Autofill fix - prevent browser blue background on dark theme */ /* Autofill fix - prevent browser blue background on dark theme */
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
input:-webkit-autofill:focus, input:-webkit-autofill:focus,
input:-webkit-autofill:active { input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px #0b0b0b inset !important; -webkit-box-shadow: 0 0 0 1000px #0b0b0b inset !important;
-webkit-text-fill-color: #f5f5fa !important; -webkit-text-fill-color: #f5f5fa !important;
transition: background-color 5000s ease-in-out 0s; transition: background-color 5000s ease-in-out 0s;
} }
@keyframes highlightFlash { @keyframes highlightFlash {
0% { background-color: rgba(0, 163, 255, 0.3); } 0% { background-color: rgba(0, 163, 255, 0.3); }
100% { background-color: transparent; } 100% { background-color: transparent; }
} }
.highlight-message { @keyframes callWave {
animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important; 0% { transform: scale(1); opacity: 0.5; }
border-radius: 12px; 100% { transform: scale(1.5); opacity: 0; }
position: relative; }
z-index: 10;
} @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 {
animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important;
border-radius: 12px;
position: relative;
z-index: 10;
}
@@ -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) => {