Групповые звонки

This commit is contained in:
Халимов Рустам
2026-03-14 23:27:55 +03:00
parent be0a50b702
commit 3b990f8619
10 changed files with 443 additions and 39 deletions
+29 -21
View File
@@ -268,7 +268,15 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
localStreamRef.current.addTrack(videoTrack);
// Add to all peer connections
for (const [targetUserId, peer] of peersRef.current) {
peer.pc.addTrack(videoTrack, localStreamRef.current);
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
await transceiver.sender.replaceTrack(videoTrack).catch(() => {});
if (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive') {
transceiver.direction = 'sendrecv';
}
} else {
peer.pc.addTrack(videoTrack, localStreamRef.current);
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
@@ -291,13 +299,17 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
// Replace screen track with null on all peers
// Replace screen track with camera track on all peers
for (const [targetUserId, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(null);
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
if (transceiver) transceiver.direction = 'recvonly';
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
const camTrack = localStreamRef.current?.getVideoTracks().find(t => t.enabled);
await transceiver.sender.replaceTrack(camTrack || null).catch(() => {});
if (!camTrack) {
transceiver.direction = 'recvonly';
} else {
transceiver.direction = 'sendrecv';
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
@@ -317,24 +329,19 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
const screenTrack = screenStream.getVideoTracks()[0];
for (const [targetUserId, peer] of peersRef.current) {
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(screenTrack);
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
await transceiver.sender.replaceTrack(screenTrack).catch(() => {});
if (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive') {
transceiver.direction = 'sendrecv';
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
} else {
peer.pc.addTrack(screenTrack, localStreamRef.current || screenStream);
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
const offer = await peer.pc.createOffer();
await peer.pc.setLocalDescription(offer);
const socket = getSocket();
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
}
screenTrack.onended = () => {
@@ -540,6 +547,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
if (data.chatId !== chatId) return;
const newMap = new Map<string, ParticipantInfo>();
for (const p of data.participants) {
if (p.id === useAuthStore.getState().user?.id) continue;
newMap.set(p.id, p);
if (p.isSharingScreen && p.id !== useAuthStore.getState().user?.id) {
setSharingUserId(p.id);
@@ -557,7 +565,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
};
const onUserJoined = (data: { chatId: string; userId: string; userInfo: ParticipantInfo }) => {
if (data.chatId !== chatId) return;
if (data.chatId !== chatId || data.userId === useAuthStore.getState().user?.id) return;
setParticipants(prev => {
const next = new Map(prev);
next.set(data.userId, data.userInfo);
+4 -4
View File
@@ -162,7 +162,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
}, [storyIndex, userIndex]);
useEffect(() => {
if (paused || !currentStory || isVideo) return;
if (paused || showReactions || showReplyInput || showViewers || !currentStory || isVideo) return;
const duration = STORY_DURATION;
const step = (TICK / duration) * 100;
@@ -180,12 +180,12 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [storyIndex, userIndex, paused, goNext, isVideo, currentStory]);
}, [storyIndex, userIndex, paused, showReactions, showReplyInput, showViewers, goNext, isVideo, currentStory]);
// Handle video progress
useEffect(() => {
const video = videoRef.current;
if (!video || !isVideo || paused) return;
if (!video || !isVideo || paused || showReactions || showReplyInput || showViewers) return;
const interval = setInterval(() => {
if (video.duration) {
@@ -195,7 +195,7 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
}, TICK);
return () => clearInterval(interval);
}, [isVideo, paused, storyIndex, userIndex]);
}, [isVideo, paused, showReactions, showReplyInput, showViewers, storyIndex, userIndex]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
+116 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2 } from 'lucide-react';
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock, Search, ChevronLeft, Eye, Users, Video, Camera, Trash2, MessageSquare, Phone, Bell, BellOff, MoreHorizontal } from 'lucide-react';
import Cropper from 'react-easy-crop';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
@@ -14,6 +14,8 @@ import { useStoryStore } from '../stores/useStoryStore';
import { getMediaUrl } from '../lib/utils';
import { getCroppedImg } from '../lib/imageCrop';
import DatePicker from './DatePicker';
import { useChatStore } from '../stores/chatStore';
import { toggleMuteChat, isChatMuted } from '../lib/sounds';
interface UserProfileProps {
userId: string;
@@ -31,6 +33,48 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
const [profile, setProfile] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<MediaTab>('publications');
const { chats, addChat, setActiveChat, loadMessages } = useChatStore();
const personalChat = chats.find(c => c.type === 'personal' && c.members.some(m => m.user.id === userId));
const commonGroups = chats.filter(c => c.type === 'group' && c.members.some(m => m.user.id === userId));
const [isMutedLocally, setIsMutedLocally] = useState(() => personalChat ? isChatMuted(personalChat.id) : false);
useEffect(() => {
if (personalChat) setIsMutedLocally(isChatMuted(personalChat.id));
}, [personalChat?.id]);
const handleOpenChat = async () => {
try {
let targetChatId = personalChat?.id;
if (!targetChatId) {
const chat = await api.createPersonalChat(userId);
addChat(chat);
const socket = getSocket();
if (socket) socket.emit('join_chat', chat.id);
targetChatId = chat.id;
}
setActiveChat(targetChatId);
loadMessages(targetChatId);
onClose();
} catch (e) {
console.error(e);
}
};
const handleToggleMute = () => {
if (!personalChat) return;
const muted = toggleMuteChat(personalChat.id);
setIsMutedLocally(muted);
};
const handleStartCall = (type: 'voice' | 'video') => {
if (profile) {
window.dispatchEvent(new CustomEvent('START_CALL', { detail: { targetUser: profile, type } }));
onClose();
}
};
// Shared media state
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
@@ -507,6 +551,48 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
)}
</div>
)}
{/* Быстрые действия (только для других пользователей) */}
{!isSelf && (
<div className="flex w-full items-center justify-center gap-2 mt-6 px-4">
<button
onClick={handleOpenChat}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<MessageSquare size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('chat')}</span>
</button>
<button
onClick={handleToggleMute}
disabled={!personalChat}
className={`flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl transition-colors border ${!personalChat ? 'opacity-50 cursor-not-allowed bg-white/5 border-white/5' : 'bg-white/5 hover:bg-white/10 border-white/5'}`}
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
{isMutedLocally ? <BellOff size={16} className="text-white" /> : <Bell size={16} className="text-white" />}
</div>
<span className="text-[11px] font-medium text-white shadow-sm">{isMutedLocally ? t('enableSound') : t('disableSound')}</span>
</button>
<button
onClick={() => handleStartCall('voice')}
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 hover:bg-white/10 transition-colors border border-white/5"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<Phone size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('call')}</span>
</button>
<button
className="flex-1 flex flex-col items-center gap-1.5 py-2.5 rounded-2xl bg-white/5 border border-white/5 opacity-50 cursor-not-allowed"
>
<div className="w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<MoreHorizontal size={16} className="text-white" />
</div>
<span className="text-[11px] font-medium text-white">{t('more')}</span>
</button>
</div>
)}
</div>
{/* Информация */}
@@ -585,6 +671,35 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
})}
</p>
</div>
{/* Общие группы */}
{!isSelf && commonGroups.length > 0 && (
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group mt-2 text-left">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-purple-500/20 flex items-center justify-center border border-purple-500/30">
<Users size={12} className="text-purple-400" />
</div>
<label className="text-xs font-semibold text-purple-200/50 uppercase tracking-widest">
{t('commonGroups')}
</label>
</div>
<div className="flex flex-col gap-1.5">
{commonGroups.map(cg => (
<button key={cg.id} onClick={() => { setActiveChat(cg.id); loadMessages(cg.id); onClose(); }} className="flex items-center gap-3 w-full bg-white/5 hover:bg-white/10 p-2 rounded-xl transition-colors border border-white/5">
{cg.avatar ? (
<img src={getMediaUrl(cg.avatar)} className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex flex-col items-center justify-center text-white font-bold">{(cg.name || 'G').charAt(0)}</div>
)}
<div className="flex-1 text-left min-w-0">
<p className="text-sm font-medium text-white truncate">{cg.name || 'Group'}</p>
<p className="text-xs text-zinc-500 truncate">{cg.members.length} {t('participants')}</p>
</div>
</button>
))}
</div>
</div>
)}
</div>
{/* Медиа / Файлы / Ссылки */}