Профили, группы, групповые звонки

This commit is contained in:
Халимов Рустам
2026-03-15 00:19:36 +03:00
parent 3b990f8619
commit 924bf87cf4
9 changed files with 268 additions and 200 deletions

View File

@@ -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 _);

View File

@@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -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>

View File

@@ -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 || 'Кто-то'} транслирует экран

View File

@@ -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>

View File

@@ -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>
))

View File

@@ -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',