Профили, группы, групповые звонки
This commit is contained in:
@@ -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 _);
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
apps/server-net/uploads/7f88e4c3-c74f-4b79-b973-922e29fe583a.jpg
Normal file
BIN
apps/server-net/uploads/7f88e4c3-c74f-4b79-b973-922e29fe583a.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/server-net/uploads/f05e1946-23bd-48a6-9eac-2677f278ddec.stl
Normal file
BIN
apps/server-net/uploads/f05e1946-23bd-48a6-9eac-2677f278ddec.stl
Normal file
Binary file not shown.
@@ -939,6 +939,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<GroupSettings
|
||||
chat={chat}
|
||||
onClose={() => 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -72,6 +72,7 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
const [activeMicId, setActiveMicId] = useState<string>('');
|
||||
const [showMicMenu, setShowMicMenu] = useState(false);
|
||||
const [sharingUserId, setSharingUserId] = useState<string | null>(null);
|
||||
const [maximizedUserId, setMaximizedUserId] = useState<string | null>(null);
|
||||
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(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
|
||||
</div>
|
||||
|
||||
{/* Participant grid */}
|
||||
<div className="p-4" style={{ minHeight: '300px', maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}
|
||||
>
|
||||
{/* Self */}
|
||||
<div className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5">
|
||||
{hasLocalVideo ? (
|
||||
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
|
||||
{t('you')?.charAt(0).toUpperCase() || 'Я'}
|
||||
</div>
|
||||
{isMuted && <MicOff size={14} className="text-red-400" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white">
|
||||
{t('you')} {isMuted ? '🔇' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4" style={{ minHeight: '300px', maxHeight: '65vh', overflowY: 'auto' }}>
|
||||
{(() => {
|
||||
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 (
|
||||
<div key={p.id} className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5 cursor-pointer" title={t('rightClickVolume')} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}>
|
||||
{hasVid && !p.isVideoOff ? (
|
||||
<video
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
ref={el => {
|
||||
if (el && peer?.remoteStream && el.srcObject !== peer.remoteStream) {
|
||||
el.srcObject = peer.remoteStream;
|
||||
}
|
||||
}}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
<div
|
||||
key={p.id}
|
||||
className={`relative bg-zinc-900 rounded-2xl overflow-hidden flex items-center justify-center border border-white/5 cursor-pointer flex-shrink-0 transition-all ${isMaximized ? 'w-full h-[55vh] aspect-auto' : 'aspect-video'}`}
|
||||
onClick={() => setMaximizedUserId(p.id === maximizedUserId ? null : p.id)}
|
||||
onContextMenu={(e) => { if (!p.isSelf) { e.preventDefault(); setShowVolumeSlider(true); } }}
|
||||
title={p.isSelf ? '' : t('rightClickVolume') as string}
|
||||
style={!isMaximized && theMaximized ? { width: '200px', height: '112px' } : {}}
|
||||
>
|
||||
{p.isSelf ? (
|
||||
p.hasVideo ? <video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" /> : <div className="flex flex-col items-center"><div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
{p.avatar ? (
|
||||
<img src={getMediaUrl(p.avatar)} alt="" className="w-16 h-16 rounded-full object-cover mb-2" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
{p.isMuted && <MicOff size={14} className="text-red-400" />}
|
||||
</div>
|
||||
p.hasVideo && !p.isVideoOff ? <video id={`video-${p.id}`} autoPlay playsInline muted ref={el => { if (el && p.peer?.remoteStream && el.srcObject !== p.peer.remoteStream) el.srcObject = p.peer.remoteStream; }} className="w-full h-full object-contain" /> : <div className="flex flex-col items-center">{p.avatar ? <img src={getMediaUrl(p.avatar)} alt="" className="w-16 h-16 rounded-full object-cover mb-2" /> : <div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>}{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%] flex items-center gap-1">
|
||||
{p.displayName || p.username} {p.isMuted ? <MicOff size={10} className="text-red-400" /> : ''}
|
||||
{p.isSelf ? t('you') : (p.displayName || p.username)} {p.isMuted ? <MicOff size={10} className="text-red-400" /> : ''}
|
||||
</div>
|
||||
{isMaximized && p.hasVideo && (!p.isVideoOff || p.isSelf) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const v = p.isSelf ? localVideoRef.current : document.getElementById(`video-${p.id}`);
|
||||
if (v && v.requestFullscreen) v.requestFullscreen();
|
||||
}}
|
||||
className="absolute top-2 right-2 w-8 h-8 rounded-lg bg-black/60 hover:bg-black/80 flex items-center justify-center text-white/80 hover:text-white transition-colors"
|
||||
title="Развернуть на весь экран"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
};
|
||||
|
||||
if (theMaximized) {
|
||||
const carouselParticipants = allParticipants.filter(p => p.id !== mainId);
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full">
|
||||
{renderParticipantBlock(theMaximized, true)}
|
||||
{carouselParticipants.length > 0 && (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 custom-scrollbar">
|
||||
{carouselParticipants.map(p => renderParticipantBlock(p, false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}>
|
||||
{allParticipants.map(p => renderParticipantBlock(p, false))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Screen share indicator */}
|
||||
{sharingUserId && (
|
||||
<div className="absolute top-6 left-6 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/20 border border-blue-500/30 backdrop-blur-md">
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/20 border border-blue-500/30 backdrop-blur-md">
|
||||
<Monitor size={14} className="text-blue-400 animate-pulse" />
|
||||
<span className="text-xs font-medium text-blue-100">
|
||||
{participants.get(sharingUserId)?.displayName || 'Кто-то'} транслирует экран
|
||||
|
||||
@@ -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<UserPresence[]>([]);
|
||||
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<Message[]>([]);
|
||||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||
@@ -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) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Avatar */}
|
||||
<div className="flex flex-col items-center py-8 px-6">
|
||||
<div className="flex-shrink-0 flex flex-col items-center py-6 px-6 overflow-y-auto max-h-[50%] custom-scrollbar">
|
||||
<div className="relative group">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-vortex-500/20 rounded-full blur-[40px] pointer-events-none" />
|
||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
||||
@@ -479,7 +481,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
|
||||
{/* Media / Files / Links Tabs */}
|
||||
{availableTabs.length > 0 ? (
|
||||
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl">
|
||||
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl flex flex-col flex-1 min-h-0">
|
||||
<div className="flex border-b border-white/5">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
@@ -493,132 +495,22 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<tab.icon size={16} />
|
||||
{loadedTabs.has(tab.key) && <span className="text-xs bg-black/40 px-1.5 rounded-full">{tab.count}</span>}
|
||||
{(loadedTabs.has(tab.key) || tab.key === 'members') && <span className="text-xs bg-black/40 px-1.5 rounded-full">{tab.count}</span>}
|
||||
</div>
|
||||
<span className="truncate w-full px-1">{tab.label as string}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[200px] max-h-[300px] overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'media' ? (
|
||||
sortedMedia.length > 0 ? (
|
||||
renderGrouped(sortedMedia, (m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<div
|
||||
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
>
|
||||
{m.thumbnail ? (
|
||||
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
|
||||
<Video size={32} className="text-white/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
|
||||
<Play size={24} className="text-white fill-white" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={getMediaUrl(m.url)}
|
||||
alt=""
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
sortedFiles.length > 0 ? (
|
||||
renderGrouped(sortedFiles, (msg, idx) => (
|
||||
<div key={msg.id} className="divide-y divide-border border-b border-border">
|
||||
{(msg.media || []).map((m) => (
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={getMediaUrl(m.url)}
|
||||
download={m.filename || 'file'}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-vortex-500/10 flex items-center justify-center flex-shrink-0 text-vortex-400">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-zinc-200 truncate">{m.filename || 'File'}</p>
|
||||
<p className="text-[10px] text-zinc-500">{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}</p>
|
||||
</div>
|
||||
<Download size={14} className="text-zinc-600" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sortedLinks.length > 0 ? (
|
||||
renderGrouped(sortedLinks, (msg, idx) => (
|
||||
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors border-b border-white/5">
|
||||
{msg.links?.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[13px] text-vortex-400 hover:underline truncate mb-1"
|
||||
>
|
||||
<ExternalLink size={12} className="flex-shrink-0" />
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : loadedTabs.size === 3 ? (
|
||||
<div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<ImageIcon size={32} className="text-zinc-600 mb-3" />
|
||||
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mb-6 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
<div className="px-4 pb-4">
|
||||
) : activeTab === 'members' ? (
|
||||
<div className="px-4 py-4 pt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">
|
||||
<h4 className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
||||
{t('membersCount')}
|
||||
</h4>
|
||||
{isAdmin && (
|
||||
@@ -748,6 +640,134 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'media' ? (
|
||||
sortedMedia.length > 0 ? (
|
||||
renderGrouped(sortedMedia, (m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<div
|
||||
className="w-full h-full bg-zinc-800 flex items-center justify-center relative group-hover:scale-105 transition-transform duration-200"
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
>
|
||||
{m.thumbnail ? (
|
||||
<img src={getMediaUrl(m.thumbnail)} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
|
||||
<Video size={32} className="text-white/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/40 transition-colors">
|
||||
<Play size={24} className="text-white fill-white" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={getMediaUrl(m.url)}
|
||||
alt=""
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
|
||||
>
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
sortedFiles.length > 0 ? (
|
||||
renderGrouped(sortedFiles, (msg, idx) => (
|
||||
<div key={msg.id} className="divide-y divide-border border-b border-border">
|
||||
{(msg.media || []).map((m) => (
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={getMediaUrl(m.url)}
|
||||
download={m.filename || 'file'}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-vortex-500/10 flex items-center justify-center flex-shrink-0 text-vortex-400">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] text-zinc-200 truncate">{m.filename || 'File'}</p>
|
||||
<p className="text-[10px] text-zinc-500">{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}</p>
|
||||
</div>
|
||||
<Download size={14} className="text-zinc-600" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-300 text-[11px] font-medium opacity-0 group-hover/file:opacity-100 transition-opacity"
|
||||
>
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sortedLinks.length > 0 ? (
|
||||
renderGrouped(sortedLinks, (msg, idx) => (
|
||||
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors border-b border-white/5 relative group">
|
||||
{msg.links?.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[13px] text-vortex-400 hover:underline truncate mb-1"
|
||||
>
|
||||
<ExternalLink size={12} className="flex-shrink-0" />
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-black/40 hover:bg-vortex-500/20 text-zinc-300 hover:text-white text-[11px] font-medium opacity-0 group-hover:opacity-100 transition-all shadow-md z-10"
|
||||
>
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : loadedTabs.size === 3 ? (
|
||||
<div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<ImageIcon size={32} className="text-zinc-600 mb-3" />
|
||||
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mb-6 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -412,10 +412,12 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<div className="w-8 h-8 border-2 border-vortex-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : profile ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Аватар */}
|
||||
<div className="flex flex-col items-center pt-8 pb-4 px-6 relative overflow-visible">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[240px] h-[240px] bg-vortex-500/10 rounded-full blur-[80px] pointer-events-none" />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex-shrink-0 overflow-y-auto max-h-[50%] custom-scrollbar">
|
||||
{/* Аватар */}
|
||||
<div className="flex flex-col items-center pt-8 pb-4 px-6 relative overflow-visible">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[240px] h-[240px] bg-vortex-500/10 rounded-full blur-[80px] pointer-events-none" />
|
||||
|
||||
<div className="relative group">
|
||||
{/* Spinning gradient glow ring */}
|
||||
@@ -701,11 +703,12 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Медиа / Файлы / Ссылки */}
|
||||
{availableTabs.length > 0 ? (
|
||||
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
||||
<div className="flex border-b border-white/5 h-14">
|
||||
<div className="flex flex-col flex-1 min-h-0 border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
||||
<div className="flex-shrink-0 flex border-b border-white/5 h-14">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -807,10 +810,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
{/* Navigation button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
|
||||
className="absolute bottom-1 right-1 w-6 h-6 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={(t('goToMessage') || 'Go to message') as string}
|
||||
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
@@ -844,10 +846,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-12 top-1/2 -translate-y-1/2 w-8 h-8 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-500 opacity-0 group-hover/file:opacity-100 transition-opacity"
|
||||
title={(t('goToMessage') || 'Go to message') as string}
|
||||
className="absolute right-10 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-300 text-[11px] font-medium opacity-0 group-hover/file:opacity-100 transition-opacity"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -861,7 +862,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
) : (
|
||||
sortedLinks.length > 0 ? (
|
||||
renderGrouped(sortedLinks, (msg, idx) => (
|
||||
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors relative group/link border-b border-white/5">
|
||||
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors relative group border-b border-white/5">
|
||||
<p className="text-xs text-zinc-500 mb-1.5 font-medium pr-8">
|
||||
{msg.sender?.displayName || msg.sender?.username}
|
||||
</p>
|
||||
@@ -882,10 +883,9 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
)}
|
||||
<button
|
||||
onClick={() => onGoToMessage?.(msg.id)}
|
||||
className="absolute right-2 top-2 w-8 h-8 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-500 opacity-0 group-hover/link:opacity-100 transition-opacity"
|
||||
title={(t('goToMessage') || 'Go to message') as string}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-black/40 hover:bg-vortex-500/20 text-zinc-300 hover:text-white text-[11px] font-medium opacity-0 group-hover:opacity-100 transition-all shadow-md z-10"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('showInChat')}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user