diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Handlers/ChatCreatedDomainEventHandler.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Handlers/ChatCreatedDomainEventHandler.cs index 1eeb68d..0ee7361 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/Handlers/ChatCreatedDomainEventHandler.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Handlers/ChatCreatedDomainEventHandler.cs @@ -60,14 +60,14 @@ public sealed class ChatCreatedDomainEventHandler : INotificationHandler(), - UnreadCount = 0 + id = chat.Id, + type = chat.Type.ToString().ToLowerInvariant(), + name = chat.Type == ChatType.Favorites ? "Избранное" : (chat.Type == ChatType.Personal ? null : chat.Name), + avatar = chat.Avatar, + createdAt = chat.CreatedAt, + members = members, + messages = new List(), + unreadCount = 0 }; if (targetUserId != null) diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs index d722f14..427937b 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs @@ -363,6 +363,7 @@ public sealed class ChatHub : Hub var userInfo = new ParticipantInfo(userId, username, displayName, avatar); var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary()); + var isFirst = participants.IsEmpty; participants.TryAdd(userId, userInfo); // Notify others @@ -380,6 +381,17 @@ public sealed class ChatHub : Hub chatId = chatId, participants = others }); + + if (isFirst) + { + await Clients.Group(chatId).SendAsync("group_call_incoming", new + { + chatId = chatId, + from = userId, + callerInfo = userInfo, + callType = request.CallType + }); + } } [HubMethodName("group_call_leave")] @@ -393,6 +405,7 @@ public sealed class ChatHub : Hub if (participants.IsEmpty) { _groupCallParticipants.TryRemove(chatId, out _); + await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId }); } } diff --git a/apps/server-net/uploads/3dfe695e-fbab-4483-a6eb-13998f128320.svg b/apps/server-net/uploads/3dfe695e-fbab-4483-a6eb-13998f128320.svg new file mode 100644 index 0000000..1e8d62d --- /dev/null +++ b/apps/server-net/uploads/3dfe695e-fbab-4483-a6eb-13998f128320.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/server-net/uploads/73e12c4e-de85-4712-8d2c-8f200d5783a4.png b/apps/server-net/uploads/73e12c4e-de85-4712-8d2c-8f200d5783a4.png new file mode 100644 index 0000000..7dae8b3 Binary files /dev/null and b/apps/server-net/uploads/73e12c4e-de85-4712-8d2c-8f200d5783a4.png differ diff --git a/apps/web/src/components/GroupCallModal.tsx b/apps/web/src/components/GroupCallModal.tsx index ef5ec20..4612afe 100644 --- a/apps/web/src/components/GroupCallModal.tsx +++ b/apps/web/src/components/GroupCallModal.tsx @@ -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(); 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); diff --git a/apps/web/src/components/StoryViewer.tsx b/apps/web/src/components/StoryViewer.tsx index 3159679..0a1e8cf 100644 --- a/apps/web/src/components/StoryViewer.tsx +++ b/apps/web/src/components/StoryViewer.tsx @@ -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) => { diff --git a/apps/web/src/components/UserProfile.tsx b/apps/web/src/components/UserProfile.tsx index f1815fe..2b45630 100644 --- a/apps/web/src/components/UserProfile.tsx +++ b/apps/web/src/components/UserProfile.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); const [activeTab, setActiveTab] = useState('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([]); @@ -507,6 +551,48 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is )} )} + + {/* Быстрые действия (только для других пользователей) */} + {!isSelf && ( +
+ + + + +
+ )} {/* Информация */} @@ -585,6 +671,35 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is })}

+ + {/* Общие группы */} + {!isSelf && commonGroups.length > 0 && ( +
+
+
+ +
+ +
+
+ {commonGroups.map(cg => ( + + ))} +
+
+ )} {/* Медиа / Файлы / Ссылки */} diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index 7ee991e..5bbe386 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -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', diff --git a/apps/web/src/pages/ChatPage.tsx b/apps/web/src/pages/ChatPage.tsx index 3b13b08..118099b 100644 --- a/apps/web/src/pages/ChatPage.tsx +++ b/apps/web/src/pages/ChatPage.tsx @@ -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() { )} + + {/* Incoming Group Call Overlay */} + + {incomingGroupCall && ( + + +
+
+
+ {incomingGroupCall.callerInfo?.avatar ? ( + + ) : ( + <>{incomingGroupCall.chatName.charAt(0)} + )} +
+
+

+ {incomingGroupCall.chatName} +

+

+ {incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('calling' as any) || 'звонит...'} +

+

+ {t('group' as any) || 'Групповой звонок'} +

+ +
+ + +
+ + + )} + ); } diff --git a/apps/web/src/stores/chatStore.ts b/apps/web/src/stores/chatStore.ts index ae44a4e..f674ffd 100644 --- a/apps/web/src/stores/chatStore.ts +++ b/apps/web/src/stores/chatStore.ts @@ -152,7 +152,7 @@ export const useChatStore = create((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((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] }; }); },