Звонки

This commit is contained in:
Халимов Рустам
2026-04-06 01:27:27 +03:00
parent 65d5f5fee9
commit 90096ce2bc
5 changed files with 322 additions and 293 deletions
@@ -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;
@@ -31,14 +33,16 @@ public sealed class ChatHub : Hub
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 +288,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,7 +303,6 @@ 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,
@@ -396,10 +401,12 @@ 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);
+24
View File
@@ -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)
@@ -1614,12 +1610,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 +1651,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 +1796,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 +1911,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}