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 427937b..0404ce4 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs @@ -382,6 +382,13 @@ public sealed class ChatHub : Hub participants = others }); + // Broadcast active call participants to everyone in the chat + await Clients.Group(chatId).SendAsync("group_call_active", new + { + chatId = chatId, + participants = participants.Keys.ToList() + }); + if (isFirst) { await Clients.Group(chatId).SendAsync("group_call_incoming", new @@ -402,6 +409,14 @@ public sealed class ChatHub : Hub if (_groupCallParticipants.TryGetValue(chatId, out var participants)) { participants.TryRemove(userId, out _); + + // Broadcast updated participants list + await Clients.Group(chatId).SendAsync("group_call_active", new + { + chatId = chatId, + participants = participants.Keys.ToList() + }); + if (participants.IsEmpty) { _groupCallParticipants.TryRemove(chatId, out _); diff --git a/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs b/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs index 3fd19e1..9195e5d 100644 --- a/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs +++ b/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs @@ -254,6 +254,7 @@ public sealed class MessagesController : ControllerBase var sender = await _userRepository.GetByIdAsync(m.SenderId, ct); result.Add(new { + m.Id, m.ReplyToId, m.Quote, m.StoryId, diff --git a/apps/server-net/uploads/7f88e4c3-c74f-4b79-b973-922e29fe583a.jpg b/apps/server-net/uploads/7f88e4c3-c74f-4b79-b973-922e29fe583a.jpg new file mode 100644 index 0000000..71f5eae Binary files /dev/null and b/apps/server-net/uploads/7f88e4c3-c74f-4b79-b973-922e29fe583a.jpg differ diff --git a/apps/server-net/uploads/f05e1946-23bd-48a6-9eac-2677f278ddec.stl b/apps/server-net/uploads/f05e1946-23bd-48a6-9eac-2677f278ddec.stl new file mode 100644 index 0000000..25ec6e3 Binary files /dev/null and b/apps/server-net/uploads/f05e1946-23bd-48a6-9eac-2677f278ddec.stl differ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 83b3d89..f6a92c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -939,6 +939,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal setShowGroupSettings(false)} + onGoToMessage={(msgId) => { + const el = document.getElementById(`msg-${msgId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('bg-vortex-500/20'); + setTimeout(() => el.classList.remove('bg-vortex-500/20'), 2000); + setShowGroupSettings(false); + } + }} /> )} diff --git a/apps/web/src/components/GroupCallModal.tsx b/apps/web/src/components/GroupCallModal.tsx index 4612afe..e797920 100644 --- a/apps/web/src/components/GroupCallModal.tsx +++ b/apps/web/src/components/GroupCallModal.tsx @@ -72,6 +72,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call const [activeMicId, setActiveMicId] = useState(''); const [showMicMenu, setShowMicMenu] = useState(false); const [sharingUserId, setSharingUserId] = useState(null); + const [maximizedUserId, setMaximizedUserId] = useState(null); const localStreamRef = useRef(null); const screenStreamRef = useRef(null); @@ -130,7 +131,18 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call // Add local tracks if (localStreamRef.current) { - localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current!)); + localStreamRef.current.getTracks().forEach(track => { + // Skip local video if screen sharing is active + if (track.kind === 'video' && screenStreamRef.current) return; + pc.addTrack(track, localStreamRef.current!); + }); + } + + // Add screen track if screen sharing is active + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach(track => { + pc.addTrack(track, screenStreamRef.current!); + }); } // Always ensure a video m-line exists for future camera/screen share stability @@ -313,10 +325,12 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call 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 }); + socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription }); + } } - } - setIsScreenSharing(false); + setMaximizedUserId(null); + setIsScreenSharing(false); + setIsVideoOff(!localStreamRef.current?.getVideoTracks().length); const socket = getSocket(); socket?.emit('screen_share_stopped', chatId); } else { @@ -345,14 +359,11 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call } screenTrack.onended = () => { - setIsScreenSharing(false); - if (screenStreamRef.current) { - screenStreamRef.current.getTracks().forEach(t => t.stop()); - screenStreamRef.current = null; - } + toggleScreenShare(); // reuse functionality on OS level stop (e.g. Stop Sharing button natively) }; - + setMaximizedUserId('self'); setIsScreenSharing(true); + setIsVideoOff(true); const socket = getSocket(); socket?.emit('screen_share_started', chatId); } catch { console.error('Screen share failed'); } @@ -640,6 +651,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call const isMe = data.userId === useAuthStore.getState().user?.id; if (!isMe) { setSharingUserId(data.userId); + setMaximizedUserId(data.userId); } setParticipants(prev => { const next = new Map(prev); @@ -659,6 +671,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call // If the person who stopped was the sharing user, clear it setSharingUserId(current => current === data.userId ? null : current); + setMaximizedUserId(current => current === data.userId ? null : current); setParticipants(prev => { const next = new Map(prev); @@ -880,72 +893,80 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call {/* Participant grid */} -
-
- {/* Self */} -
- {hasLocalVideo ? ( -
+
+ {(() => { + const allParticipants = [ + { id: 'self', isSelf: true, displayName: t('you'), isMuted, isVideoOff, hasVideo: hasLocalVideo }, + ...participantList.map(p => ({ ...p, isSelf: false, hasVideo: peersRef.current.get(p.id)?.hasVideo, peer: peersRef.current.get(p.id) })) + ]; - {/* Remote participants */} - {participantList.map(p => { - const peer = peersRef.current.get(p.id); - const hasVid = peer?.hasVideo; - const initials = (p.displayName || p.username).split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); + const mainId = maximizedUserId; + const theMaximized = mainId ? allParticipants.find(p => p.id === mainId) : null; + + const renderParticipantBlock = (p: any, isMaximized = false) => { + const initials = p.isSelf + ? (t('you')?.charAt(0).toUpperCase() || 'Я') + : (p.displayName || p.username).split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase(); return ( -
{ e.preventDefault(); setShowVolumeSlider(true); }}> - {hasVid && !p.isVideoOff ? ( -
+ }; + + if (theMaximized) { + const carouselParticipants = allParticipants.filter(p => p.id !== mainId); + return ( +
+ {renderParticipantBlock(theMaximized, true)} + {carouselParticipants.length > 0 && ( +
+ {carouselParticipants.map(p => renderParticipantBlock(p, false))} +
+ )} +
+ ); + } + + return ( +
+ {allParticipants.map(p => renderParticipantBlock(p, false))} +
+ ); + })()}
{/* Screen share indicator */} {sharingUserId && ( -
+
{participants.get(sharingUserId)?.displayName || 'Кто-то'} транслирует экран diff --git a/apps/web/src/components/GroupSettings.tsx b/apps/web/src/components/GroupSettings.tsx index 57b5324..273f47d 100644 --- a/apps/web/src/components/GroupSettings.tsx +++ b/apps/web/src/components/GroupSettings.tsx @@ -34,9 +34,10 @@ import { getCroppedImg } from '../lib/imageCrop'; interface GroupSettingsProps { chat: Chat; onClose: () => void; + onGoToMessage?: (messageId: string) => void; } -export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { +export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSettingsProps) { const { user } = useAuthStore(); const { updateChat } = useChatStore(); const { t, lang } = useLang(); @@ -55,7 +56,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); - const [activeTab, setActiveTab] = useState<'media' | 'files' | 'links'>('media'); + const [activeTab, setActiveTab] = useState<'members' | 'media' | 'files' | 'links'>('members'); const [tabLoading, setTabLoading] = useState(false); const [sharedMedia, setSharedMedia] = useState([]); const [sharedFiles, setSharedFiles] = useState([]); @@ -293,12 +294,13 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { }; const tabsConfig = [ + { key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length }, { key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length }, { key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length }, { key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length }, ]; - const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0); + const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0); useEffect(() => { if (loadedTabs.size === 3 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) { @@ -333,9 +335,9 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
-
+
{/* Avatar */} -
+
@@ -479,7 +481,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { {/* Media / Files / Links Tabs */} {availableTabs.length > 0 ? ( -
+
{availableTabs.map((tab) => ( ))}
-
+
{tabLoading ? (
- ) : activeTab === 'media' ? ( - sortedMedia.length > 0 ? ( - renderGrouped(sortedMedia, (m, idx) => ( -
setLightboxIndex(idx)} - className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group" - > - {m.type === 'video' ? ( - <> -
setLightboxIndex(idx)} - > - {m.thumbnail ? ( - - ) : ( -
-
- )} -
- -
-
- - ) : ( - setLightboxIndex(idx)} - className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" - /> - )} -
- ), "grid grid-cols-3 gap-0.5 px-1") - ) : ( -
-

{t('sharedPhotos')}

-
- ) - ) : activeTab === 'files' ? ( - sortedFiles.length > 0 ? ( - renderGrouped(sortedFiles, (msg, idx) => ( - - )) - ) : ( -
-

{t('sharedFiles')}

-
- ) - ) : ( - sortedLinks.length > 0 ? ( - renderGrouped(sortedLinks, (msg, idx) => ( -
- {msg.links?.map((link, i) => ( - - - {link} - - ))} - {msg.content &&

{msg.content}

} -
- )) - ) : ( -
-

{t('sharedLinks')}

-
- ) - )} -
-
- ) : loadedTabs.size === 3 ? ( -
- -

{(t('sharedPhotos' as any) || 'Нет вложений') as string}

-
- ) : ( -
- -
- )} - - {/* Members */} -
+ ) : activeTab === 'members' ? ( +
-

+

{t('membersCount')}

{isAdmin && ( @@ -748,6 +640,134 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { ))}
+ ) : activeTab === 'media' ? ( + sortedMedia.length > 0 ? ( + renderGrouped(sortedMedia, (m, idx) => ( +
setLightboxIndex(idx)} + className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group" + > + {m.type === 'video' ? ( + <> +
setLightboxIndex(idx)} + > + {m.thumbnail ? ( + + ) : ( +
+
+ )} +
+ +
+
+ + ) : ( + setLightboxIndex(idx)} + className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" + /> + )} + +
+ ), "grid grid-cols-3 gap-0.5 px-1") + ) : ( +
+

{t('sharedPhotos')}

+
+ ) + ) : activeTab === 'files' ? ( + sortedFiles.length > 0 ? ( + renderGrouped(sortedFiles, (msg, idx) => ( +
+ {(msg.media || []).map((m) => ( +
+ +
+ +
+ + +
+

{m.filename || 'File'}

+

{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}

+
+ +
+ +
+ ))} +
+ )) + ) : ( +
+

{t('sharedFiles')}

+
+ ) + ) : ( + sortedLinks.length > 0 ? ( + renderGrouped(sortedLinks, (msg, idx) => ( +
+ {msg.links?.map((link, i) => ( + + + {link} + + ))} + {msg.content &&

{msg.content}

} + +
+ )) + ) : ( +
+

{t('sharedLinks')}

+
+ ) + )} +
+
+ ) : loadedTabs.size === 3 ? ( +
+ +

{(t('sharedPhotos' as any) || 'Нет вложений') as string}

+
+ ) : ( +
+ +
+ )} +
diff --git a/apps/web/src/components/UserProfile.tsx b/apps/web/src/components/UserProfile.tsx index 2b45630..2b1a11d 100644 --- a/apps/web/src/components/UserProfile.tsx +++ b/apps/web/src/components/UserProfile.tsx @@ -412,10 +412,12 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
) : profile ? ( -
- {/* Аватар */} -
-
+
+ +
+ {/* Аватар */} +
+
{/* Spinning gradient glow ring */} @@ -701,11 +703,12 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
)}
+
{/* Медиа / Файлы / Ссылки */} {availableTabs.length > 0 ? ( -
-
+
+
{availableTabs.map((tab) => (
), "grid grid-cols-3 gap-0.5 px-1") @@ -844,10 +846,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
))} @@ -861,7 +862,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is ) : ( sortedLinks.length > 0 ? ( renderGrouped(sortedLinks, (msg, idx) => ( -
+

{msg.sender?.displayName || msg.sender?.username}

@@ -882,10 +883,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is )}
)) diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index 5bbe386..c7768fc 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -148,6 +148,7 @@ const translations = { screenShare: 'Демонстрация экрана', stopScreenShare: 'Остановить демонстрацию', switchCamera: 'Выбрать камеру', + showInChat: 'Показать в чате', minimize: 'Свернуть', volume: 'Громкость', mute: 'Выключить микрофон', @@ -416,6 +417,7 @@ const translations = { screenShare: 'Screen share', stopScreenShare: 'Stop sharing', switchCamera: 'Switch camera', + showInChat: 'Show in chat', minimize: 'Minimize', volume: 'Volume', mute: 'Mute',