Починка импорта, плеер для аудио

This commit is contained in:
Халимов Рустам
2026-04-06 23:35:45 +03:00
parent d96e4ec7d4
commit 32c9bc43cf
10 changed files with 375 additions and 196 deletions

View File

@@ -63,6 +63,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const [showGroupSettings, setShowGroupSettings] = useState(false);
const [showScrollDown, setShowScrollDown] = useState(false);
const [stickyDate, setStickyDate] = useState<string | null>(null);
const [showStickyDate, setShowStickyDate] = useState(false);
const stickyDateTimerRef = useRef<any>(null);
const [muted, setMuted] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
@@ -380,6 +383,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
setShowScrollDown(!isNearBottom);
}, []);
const lastScrollTopRef = useRef(0);
const handleScroll = () => {
if (isInitializingRef.current || isScrollingToBottomRef.current) return;
@@ -389,6 +393,42 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150);
const st = container.scrollTop;
const isScrollingUp = st < lastScrollTopRef.current;
lastScrollTopRef.current = st;
// Sticky Date Header Logic - Telegram style: show when scrolling, especially up
if (st > 100) {
setShowStickyDate(true);
if (stickyDateTimerRef.current) clearTimeout(stickyDateTimerRef.current);
stickyDateTimerRef.current = setTimeout(() => setShowStickyDate(false), 2000);
} else {
setShowStickyDate(false);
}
const containerRect = container.getBoundingClientRect();
const messageElements = container.querySelectorAll('[data-message-id]');
let currentTopMsgId = null;
for (const el of messageElements) {
const rect = el.getBoundingClientRect();
if (rect.top >= containerRect.top) {
currentTopMsgId = el.getAttribute('data-message-id');
break;
}
}
if (currentTopMsgId) {
const msg = chatMessages.find(m => m.id === currentTopMsgId);
if (msg) {
const dateStr = new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en', {
day: 'numeric',
month: 'long',
});
if (dateStr !== stickyDate) setStickyDate(dateStr);
}
}
if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) {
useChatStore.getState().loadMessages(activeChat, false, true);
}
@@ -1085,106 +1125,125 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</button>
)}
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
>
{isLoadingMessages && chatMessages.length === 0 ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : chatMessages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4 opacity-30 select-none">
<MessagesSquare size={64} className="text-on-surface-variant" />
<p className="text-sm font-bold uppercase tracking-widest text-on-surface-variant">{t('noMessages')}</p>
</div>
</div>
) : (
<div className="space-y-1 max-w-3xl mx-auto">
{isLoadingMessages && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{chatMessages.map((msg, i) => {
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
const showDate =
!prevMsg ||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
const isFirstUnread = firstUnreadId === msg.id;
return (
<div
key={msg.id}
data-message-id={msg.id}
data-sequence-id={msg.sequenceId}
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
>
{isFirstUnread && (
<div id="unread-divider" className="flex items-center justify-center my-4 opacity-80 select-none">
<div className="flex-1 h-px bg-outline/20"></div>
<span className="px-4 text-[11px] font-semibold tracking-wider uppercase text-zinc-400">
{t('unreadMessages')}
</span>
<div className="flex-1 h-px bg-outline/20"></div>
</div>
)}
{showDate && (
<div className="flex justify-center my-4">
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass-effect">
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en', {
day: 'numeric',
month: 'long',
})}
</span>
</div>
)}
<MessageBubble
message={msg}
isMine={msg.senderId === user?.id}
showAvatar={showAvatar}
onViewProfile={(userId) => setProfileUserId(userId)}
selectionMode={selectionMode}
isSelected={selectedMessages.has(msg.id)}
onToggleSelect={handleToggleSelect}
onStartSelectionMode={handleStartSelection}
onForward={(id) => {
setSelectedMessages(new Set([id]));
setShowForwardModal(true);
}}
/>
</div>
);
})}
<div ref={messagesEndRef} className="h-4" />
</div>
)}
</div>
<AnimatePresence>
{showScrollDown && (
<motion.button
initial={{ scale: 0.5, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.5, opacity: 0, y: 20 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => scrollToBottom(true)}
className="absolute bottom-24 right-8 w-14 h-14 rounded-2xl bg-primary text-on-primary shadow-2xl flex items-center justify-center z-20 hover:shadow-primary/20 transition-all"
>
<ArrowDown size={28} />
{unreadCount > 0 && (
<span className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest">
{unreadCount > 99 ? '99+' : unreadCount}
<div className="relative flex-1 flex flex-col overflow-hidden">
<AnimatePresence mode="wait">
{showStickyDate && stickyDate && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
key="sticky-date"
className="absolute top-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-none"
>
<span className="px-4 py-1.5 rounded-full text-[11px] font-black uppercase tracking-widest text-white bg-black/60 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.5)] border border-white/10 ring-2 ring-black/20 whitespace-nowrap">
{stickyDate}
</span>
)}
</motion.button>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 scroll-smooth-container ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
>
{isLoadingMessages && chatMessages.length === 0 ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : chatMessages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4 opacity-30 select-none">
<MessagesSquare size={64} className="text-on-surface-variant" />
<p className="text-sm font-bold uppercase tracking-widest text-on-surface-variant">{t('noMessages')}</p>
</div>
</div>
) : (
<div className="space-y-1 max-w-3xl mx-auto">
{isLoadingMessages && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{chatMessages.map((msg, i) => {
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
const showDate =
!prevMsg ||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
const isFirstUnread = firstUnreadId === msg.id;
return (
<div
key={msg.id}
data-message-id={msg.id}
data-sequence-id={msg.sequenceId}
className={`transition-colors duration-500 ${msg.senderId !== user?.id && !msg.readBy?.some(r => r.userId === user?.id) ? 'unread-detector' : ''}`}
>
{isFirstUnread && (
<div id="unread-divider" className="flex items-center justify-center my-4 opacity-80 select-none">
<div className="flex-1 h-px bg-outline/20"></div>
<span className="px-4 text-[11px] font-semibold tracking-wider uppercase text-zinc-400">
{t('unreadMessages')}
</span>
<div className="flex-1 h-px bg-outline/20"></div>
</div>
)}
{showDate && (
<div className="flex justify-center my-4">
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass-effect">
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en', {
day: 'numeric',
month: 'long',
})}
</span>
</div>
)}
<MessageBubble
message={msg}
isMine={msg.senderId === user?.id}
showAvatar={showAvatar}
onViewProfile={(userId) => setProfileUserId(userId)}
selectionMode={selectionMode}
isSelected={selectedMessages.has(msg.id)}
onToggleSelect={handleToggleSelect}
onStartSelectionMode={handleStartSelection}
onForward={(id) => {
setSelectedMessages(new Set([id]));
setShowForwardModal(true);
}}
/>
</div>
);
})}
<div ref={messagesEndRef} className="h-4" />
</div>
)}
</div>
<AnimatePresence>
{showScrollDown && (
<motion.button
initial={{ scale: 0.5, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.5, opacity: 0, y: 20 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => scrollToBottom(true)}
className="absolute bottom-8 right-8 w-14 h-14 rounded-2xl bg-primary text-on-primary shadow-2xl flex items-center justify-center z-20 hover:shadow-primary/20 transition-all"
>
<ArrowDown size={28} />
{unreadCount > 0 && (
<span className="absolute -top-2 -right-2 min-w-[24px] h-6 px-1.5 rounded-full bg-error text-on-error text-[12px] font-black flex items-center justify-center shadow-lg border-2 border-surface-container-lowest">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</motion.button>
)}
</AnimatePresence>
</div>
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe">
<MessageInput chatId={activeChat} />

View File

@@ -336,7 +336,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 50, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed right-3 top-3 bottom-3 w-[720px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
className="fixed right-3 top-3 bottom-3 w-[900px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border/40">
@@ -496,10 +496,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex flex-col items-center justify-center gap-1.5 py-3 text-[10px] font-black uppercase tracking-wider transition-all border-r last:border-r-0 border-white/5 ${
className={`flex-1 flex flex-col items-center justify-center gap-2 py-4 text-[10px] font-black uppercase tracking-wider transition-all ${
activeTab === tab.key
? 'bg-white/5 text-knot-400'
: 'text-zinc-500 hover:text-zinc-300'
? 'bg-white/10 text-knot-400 shadow-[inset_0_-2px_0_0_#A855F7]'
: 'text-zinc-500 hover:text-zinc-400'
}`}
>
<div className="flex items-center gap-1.5 mb-0.5">
@@ -649,10 +649,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
renderGrouped(sortedGifs, (m, idx) => (
<div
key={m.id}
onClick={() => {
const eContext = { stopPropagation: () => {} } as any;
onGoToMessage?.(m.messageId);
}}
onClick={() => setLightboxIndex(idx)}
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
>
{(m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? (
@@ -823,7 +820,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
<AnimatePresence>
{lightboxIndex !== null && (
<ImageLightbox
images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
images={(activeTab === 'gifs' ? sortedGifs : sortedMedia).map((m) => ({
url: m.url, // Already contains getMediaUrl in calculated allGifs/allMedia
type: activeTab === 'gifs' ? 'gif' : m.type
}))}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>

View File

@@ -762,34 +762,48 @@ function MessageBubble({
{/* Аудио (mp3 файлы) */}
{hasAudio && (() => {
const audioMedia = media.find((m) => m.type === 'audio');
const formatSize = (bytes?: number | null) => {
if (!bytes) return "";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
};
return (
<div className="min-w-[220px]">
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2">
<Volume2 size={14} className={isMine ? 'text-white/60' : 'text-knot-400'} />
<span className={`text-xs truncate ${isMine ? 'text-white/70' : 'text-zinc-400'}`}>{audioMedia.filename}</span>
<div className="flex items-center gap-2 mb-2 min-w-0">
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
<span className={`text-[11px] font-bold truncate ${isMine ? 'text-[#0a0a0a]/80' : 'text-zinc-200'}`}>{audioMedia.filename}</span>
</div>
)}
<div className="flex items-center gap-3">
<audio
ref={audioRef}
src={audioMedia?.url}
src={getMediaUrl(audioMedia?.url)}
preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-knot-500/20 hover:bg-knot-500/30'
} transition-colors`}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-[#0a0a0a]/10 hover:bg-[#0a0a0a]/20 text-[#0a0a0a]' : 'bg-primary text-white shadow-lg'} transition-all active:scale-95`}
>
{isPlaying ? (
<Pause size={16} className={isMine ? 'text-white' : 'text-knot-400'} />
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} className={`${isMine ? 'text-white' : 'text-knot-400'} ml-0.5`} />
<Play size={16} fill="currentColor" className="ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-[2px] h-6">
<div className="flex items-center gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
setAudioProgress(pct * 100);
}}>
{Array.from({ length: 28 }).map((_, i) => {
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
const progress = audioProgress / 100;
@@ -798,20 +812,36 @@ function MessageBubble({
return (
<div
key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-knot-400'
: isMine ? 'bg-white/20' : 'bg-white/10'
className={`flex-1 rounded-full transition-all duration-150 ${isActive
? isMine ? 'bg-[#0a0a0a]/70' : 'bg-primary'
: isMine ? 'bg-[#0a0a0a]/10' : 'bg-white/20'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || 0)}
</span>
<div className="flex justify-between items-center mt-0.5">
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: (typeof audioMedia?.duration === 'number'
? formatDuration(audioMedia.duration)
: (audioMedia?.duration || formatDuration(audioDuration || 0)))}
</span>
<div className="flex items-center gap-2">
<span className={`text-[10px] font-black uppercase tracking-tighter ${isMine ? 'text-[#0a0a0a]/40' : 'text-zinc-500'}`}>{formatSize(audioMedia?.size)}</span>
<a
href={getMediaUrl(audioMedia?.url)}
download={audioMedia?.filename || 'audio'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center p-1 rounded-md transition-all ${isMine ? 'hover:bg-[#0a0a0a]/10 text-[#0a0a0a]/40 hover:text-[#0a0a0a]' : 'hover:bg-white/10 text-zinc-500 hover:text-white'}`}
>
<Download size={12} />
</a>
</div>
</div>
</div>
</div>
</div>
@@ -821,30 +851,39 @@ function MessageBubble({
{/* Файлы */}
{hasFile &&
media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio')
.map((m) => (
<a
key={m.id}
href={m.url}
download={m.filename || 'file'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 p-2 rounded-xl ${isMine ? 'bg-white/10 hover:bg-white/15' : 'bg-surface-tertiary hover:bg-surface-hover'
} transition-colors mb-1`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-knot-500/20'
}`}>
<FileText size={20} className={isMine ? 'text-white' : 'text-knot-400'} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p>
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}>
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')}
</p>
</div>
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} />
</a>
))}
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif')
.map((m) => {
const formatSize = (bytes?: number | null) => {
if (!bytes) return "";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
};
return (
<a
key={m.id}
href={getMediaUrl(m.url)}
download={m.filename || 'file'}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
} transition-all mb-1 group/file`}
>
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
} group-hover/file:scale-110 transition-transform`}>
<FileText size={22} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-[13px] font-bold truncate ${isMine ? 'text-[#0a0a0a]' : 'text-zinc-200'}`}>{m.filename || t('fileLabel')}</p>
<p className={`text-[11px] font-medium ${isMine ? 'text-[#0a0a0a]/50' : 'text-zinc-500'}`}>
{formatSize(m.size) || t('download')}
</p>
</div>
<Download size={18} className={isMine ? 'text-[#0a0a0a]/30' : 'text-zinc-500'} />
</a>
);
})}
{/* Текст */}
{message.content && (() => {
@@ -1056,7 +1095,10 @@ function MessageBubble({
<AnimatePresence>
{lightboxData && (
<ImageLightbox
images={media.filter(m => m.type === 'image' || m.type === 'video').map(m => ({ url: m.url, type: m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4') ? 'video' : m.type }))}
images={media.filter(m => m.type === 'image' || m.type === 'video' || isMediaGif?.(m) || m.type === 'gif').map(m => ({
url: getMediaUrl(m.url),
type: (isMediaGif?.(m) || m.type === 'gif' || (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4'))) ? 'gif' : m.type
}))}
initialIndex={lightboxData.index}
onClose={() => setLightboxData(null)}
/>

View File

@@ -212,7 +212,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
exit={{ opacity: 0, scale: 0.98, y: 15 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-[560px] h-[85vh] bg-[#0a0a0a] rounded-[3rem] border border-white/10 shadow-2xl overflow-hidden flex flex-col backdrop-blur-3xl"
className="relative w-full max-w-[850px] h-[85vh] bg-[#0a0a0a] rounded-[3rem] border border-white/10 shadow-2xl overflow-hidden flex flex-col backdrop-blur-3xl"
>
<div className="flex items-center justify-between px-8 py-5 border-b border-white/5 bg-white/[0.01]">
<span className="text-sm font-black uppercase tracking-[0.3em] text-white/50">{t('userProfileUpper')}</span>
@@ -268,18 +268,18 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{/* Tabs Nav */}
{availableTabs.length > 0 && (
<div className="bg-white/[0.03] p-1.5 rounded-2xl flex items-center mb-8 border border-white/5 shadow-inner">
<div className="bg-white/[0.03] p-2 rounded-2xl flex items-center mb-8 border border-white/5 shadow-inner gap-2">
{availableTabs.map((t) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={`relative flex-1 flex items-center justify-center gap-2.5 py-4 rounded-2xl transition-all duration-300 ${activeTab === t.id ? 'text-primary bg-white/[0.08]' : 'text-white/20 hover:text-white/60'}`}
className={`relative flex-1 flex items-center justify-center gap-3 py-4 rounded-2xl transition-all duration-300 ${activeTab === t.id ? 'text-primary bg-white/[0.08] shadow-[0_0_20px_rgba(168,85,247,0.15)]' : 'text-white/20 hover:text-white/60'}`}
>
<t.icon size={18} className="relative z-10" />
<div className="relative z-10 flex items-center gap-2">
<span className="text-[11px] font-black uppercase tracking-widest leading-none">{t.label}</span>
<t.icon size={20} className="relative z-10" />
<div className="relative z-10 flex items-center gap-2.5">
<span className="text-[12px] font-black uppercase tracking-widest leading-none">{t.label}</span>
{counts[t.id] > 0 && (
<span className="flex items-center justify-center min-w-[22px] h-[20px] px-1.5 rounded-lg bg-[#1e1e1e] border border-white/10 text-primary text-[10px] font-black shadow-md">
<span className="flex items-center justify-center min-w-[24px] h-[22px] px-2 rounded-lg bg-[#1e1e1e] border border-white/10 text-primary text-[11px] font-black shadow-md">
{counts[t.id]}
</span>
)}
@@ -301,14 +301,17 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{(activeTab === 'media' || activeTab === 'gif') ? (
tabData.flatMap((item) => (item.media || []).map((m: any) => {
const mediaType = m.type?.toLowerCase() || 'image';
const isGif = (mediaType === 'image' && (m.url.toLowerCase().endsWith('.gif') || m.url.toLowerCase().endsWith('.mp4'))) || m.url.toLowerCase().indexOf('klipy') !== -1;
const mType = m.type?.toLowerCase() || 'image';
const mFilename = m.filename?.toLowerCase() || '';
const isGif = mType === 'gif' || (mType === 'image' && (mFilename.endsWith('.gif') || mFilename.endsWith('.mp4'))) || mFilename.includes('gif') || mFilename.includes('animation') || m.url?.toLowerCase().includes('klipy');
if (activeTab === 'media' && isGif) return null;
const isVideo = mediaType === 'video' || m.url.toLowerCase().endsWith('.mp4');
if (activeTab === 'gif' && !isGif) return null;
const isVideo = mType === 'video' || m.url?.toLowerCase().endsWith('.mp4');
return (
<div key={`${item.messageId}-${m.id}`} className="relative aspect-square group/item">
<div key={`${item.id}-${m.id}`} className="relative aspect-square group/item">
<button
onClick={() => {
const gIdx = galleryItems.findIndex(g => g.id === m.id);
@@ -319,7 +322,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{isVideo ? (
<div className="w-full h-full relative">
<video src={resolveUrl(m.url)} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" />
{!isGif && (
{(activeTab === 'media') && (
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
@@ -333,8 +336,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{activeTab === 'gif' && <div className="absolute bottom-2 right-2 px-1.5 py-0.5 rounded-md bg-black/80 text-[8px] font-black text-white uppercase tracking-tighter">GIF</div>}
</button>
{/* Jump to Message Button */}
<div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-opacity z-20 flex gap-1.5">
<div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-opacity z-20">
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.createdAt); }}
className="p-2.5 rounded-2xl bg-primary/95 text-white shadow-xl backdrop-blur-md hover:scale-110 active:scale-95 transition-all"
@@ -350,7 +352,16 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
tabData.map((item, idx) => {
const mediaItem = item.media?.[0];
const linkUrl = item.links && item.links.length > 0 ? item.links[0] : (mediaItem?.url || '');
if (activeTab === 'files' && !mediaItem) return null;
const mType = mediaItem?.type?.toLowerCase() || 'file';
const mFilename = mediaItem?.filename?.toLowerCase() || '';
const isGif = mType === 'gif' || (mType === 'image' && (mFilename.endsWith('.mp4') || mFilename.endsWith('.gif'))) || mFilename.includes('gif') || mFilename.includes('animation') || (mediaItem?.url?.toLowerCase().includes('klipy'));
if (activeTab === 'files') {
if (!mediaItem) return null;
if (mType === 'image' || mType === 'video' || isGif) return null;
}
if (activeTab === 'links' && (!item.links || item.links.length === 0)) return null;
return (
@@ -359,8 +370,12 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{activeTab === 'files' ? <FileText size={24} /> : <LinkIcon size={24} />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-black text-white truncate mb-1">{mediaItem?.filename || item.content}</div>
<div className="text-xs text-white/40 font-bold uppercase tracking-widest">{mediaItem?.size ? formatSize(mediaItem.size) : (linkUrl?.length > 40 ? linkUrl.slice(0, 40) + '...' : linkUrl)}</div>
<div className="text-sm font-black text-white truncate mb-1">
{activeTab === 'links' ? (item.content || linkUrl) : (mediaItem?.filename || item.content || 'File')}
</div>
<div className="text-xs text-white/40 font-bold uppercase tracking-widest">
{activeTab === 'files' ? formatSize(mediaItem?.size) : (linkUrl?.length > 50 ? linkUrl.slice(0, 50) + '...' : linkUrl)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -376,14 +391,14 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
<Download size={20} />
</button>
) : (
<button onClick={() => window.open(resolveUrl(linkUrl), '_blank')} className="p-3 rounded-2xl bg-white/5 hover:bg-primary/20 hover:text-primary transition-all">
<button onClick={() => window.open(linkUrl.startsWith('http') ? linkUrl : 'https://' + linkUrl, '_blank')} className="p-3 rounded-2xl bg-white/5 hover:bg-primary/20 hover:text-primary transition-all">
<ExternalLink size={20} />
</button>
)}
</div>
</div>
);
})
}).filter(Boolean)
)}
</motion.div>
)}