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

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>
{/* Медиа / Файлы / Ссылки */}
+4
View File
@@ -186,6 +186,8 @@ const translations = {
noStories: 'Публикаций пока нет',
goToMessage: 'Перейти к сообщению',
story: 'История',
commonGroups: 'Общие группы',
more: 'Ещё',
// Typing
typingText: 'печатает',
// Emoji
@@ -451,6 +453,8 @@ const translations = {
noStories: 'No stories yet',
goToMessage: 'Go to message',
story: 'Story',
commonGroups: 'Common groups',
more: 'More',
typingText: 'typing',
emojiFrequent: 'Frequent',
emojiGestures: 'Gestures',
+124 -2
View File
@@ -4,10 +4,10 @@ import { useChatStore } from '../stores/chatStore';
import { useAuthStore } from '../stores/authStore';
import { getSocket, disconnectSocket } from '../lib/socket';
import { api } from '../lib/api';
import { playNotificationSound, isChatMuted } from '../lib/sounds';
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../lib/sounds';
import { useLang } from '../lib/i18n';
import type { Message, UserBasic, CallInfo } from '../lib/types';
import { Send, Check } from 'lucide-react';
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
import Sidebar from '../components/Sidebar';
import ChatView from '../components/ChatView';
import CallModal from '../components/CallModal';
@@ -51,8 +51,18 @@ export default function ChatPage() {
const [groupCallType, setGroupCallType] = useState<'voice' | 'video'>('voice');
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
const groupCallOpenRef = useRef(false);
const groupCallChatIdRef = useRef('');
const { t } = useLang();
useEffect(() => {
groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId;
}, [groupCallOpen, groupCallChatId]);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
@@ -244,6 +254,45 @@ export default function ChatPage() {
}
});
socket.on('group_call_incoming', (data: { chatId: string; from: string; callerInfo: any; callType: string }) => {
if (data.from === user?.id) return;
if (groupCallOpenRef.current && groupCallChatIdRef.current === data.chatId) return;
const { chats } = useChatStore.getState();
const chat = chats.find(c => c.id === data.chatId);
if (!chat) return;
playCallRingtone();
setIncomingGroupCall({
chatId: data.chatId,
from: data.from,
callerInfo: data.callerInfo,
callType: data.callType,
chatName: chat.name || 'Group',
});
// Auto-dismiss after 15 seconds if ignored
setTimeout(() => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
}, 15000);
});
socket.on('group_call_ended', (data: { chatId: string }) => {
setIncomingGroupCall(prev => {
if (prev?.chatId === data.chatId) {
stopCallRingtone();
return null;
}
return prev;
});
});
return () => {
socket.off('new_message');
socket.off('scheduled_delivered');
@@ -265,6 +314,8 @@ export default function ChatPage() {
socket.off('story_viewed');
socket.off('story_reply');
socket.off('story_reaction');
socket.off('group_call_incoming');
socket.off('group_call_ended');
};
}, [user?.id]);
@@ -276,6 +327,16 @@ export default function ChatPage() {
setCallOpen(true);
};
useEffect(() => {
const handleCustomCallEvent = ((e: CustomEvent) => {
if (e.detail?.targetUser && e.detail?.type) {
handleStartCall(e.detail.targetUser, e.detail.type);
}
}) as EventListener;
window.addEventListener('START_CALL', handleCustomCallEvent);
return () => window.removeEventListener('START_CALL', handleCustomCallEvent);
}, []);
const handleStartGroupCall = (chatId: string, chatName: string, type: 'voice' | 'video') => {
setGroupCallChatId(chatId);
setGroupCallChatName(chatName);
@@ -342,6 +403,67 @@ export default function ChatPage() {
</motion.div>
)}
</AnimatePresence>
{/* Incoming Group Call Overlay */}
<AnimatePresence>
{incomingGroupCall && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
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="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave" />
<div className="relative w-24 h-24 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-4xl font-bold text-white uppercase overflow-hidden">
{incomingGroupCall.callerInfo?.avatar ? (
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
) : (
<>{incomingGroupCall.chatName.charAt(0)}</>
)}
</div>
</div>
<h2 className="text-2xl text-white font-semibold mb-2 text-center break-words w-full max-w-full">
{incomingGroupCall.chatName}
</h2>
<p className="text-emerald-400 font-medium mb-1 truncate w-full text-center">
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('calling' as any) || 'звонит...'}
</p>
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
{t('group' as any) || 'Групповой звонок'}
</p>
<div className="flex items-center gap-8 w-full justify-center">
<button
onClick={() => {
stopCallRingtone();
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors shadow-lg shadow-red-500/20"
>
<PhoneOff size={28} />
</button>
<button
onClick={() => {
stopCallRingtone();
handleStartGroupCall(incomingGroupCall.chatId, incomingGroupCall.chatName, incomingGroupCall.callType as any);
setIncomingGroupCall(null);
}}
className="w-16 h-16 rounded-full bg-emerald-500 hover:bg-emerald-600 flex items-center justify-center text-white transition-colors animate-pulse shadow-lg shadow-emerald-500/20"
>
<Phone size={28} className="animate-wiggle" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
+15 -3
View File
@@ -152,7 +152,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
return {
...chat,
messages: [message],
unreadCount: (chat.id === state.activeChat || message.senderId === userId || message.storyId) ? chat.unreadCount : chat.unreadCount + 1,
unreadCount: (chat.id === state.activeChat || message.senderId === userId) ? chat.unreadCount : chat.unreadCount + 1,
};
}
return chat;
@@ -446,12 +446,24 @@ export const useChatStore = create<ChatState>((set, get) => ({
addChat: (chat) => {
set((state) => {
const existing = state.chats.find((c) => c.id === chat.id);
const messagesFromState = state.messages[chat.id] || [];
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
let unreadCount = chat.unreadCount || 0;
if (!existing && messagesFromState.length > 0) {
const userId = useAuthStore.getState().user?.id;
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
}
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
if (existing) {
return {
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...chat } : c)),
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...updatedChat } : c)),
};
}
return { chats: [chat, ...state.chats] };
return { chats: [updatedChat, ...state.chats] };
});
},