Групповые звонки
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* Медиа / Файлы / Ссылки */}
|
||||
|
||||
Reference in New Issue
Block a user