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

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
@@ -21,7 +21,7 @@ public class MediaMessage : Message
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { } : this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
public void AddMedia(string type, string url, string? filename, long? size) => _media.Add(new Media { Type = type, Url = url, FileId = filename, Size = size }); public void AddMedia(string type, string url, string? filename, long? size, string? duration = null) => _media.Add(new Media { Type = type, Url = url, Filename = filename, FileId = filename, Size = size, Duration = duration });
public override void Edit(string newCaption) => base.Edit(newCaption); public override void Edit(string newCaption) => base.Edit(newCaption);
@@ -95,20 +95,10 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
(mType == "image" && (filename.EndsWith(".mp4") || filename.EndsWith(".gif") || url.EndsWith(".gif") || filename.Contains("gif"))) || (mType == "image" && (filename.EndsWith(".mp4") || filename.EndsWith(".gif") || url.EndsWith(".gif") || filename.Contains("gif"))) ||
(mType == "video" && (filename.Contains("animation") || filename.Contains("gif"))); (mType == "video" && (filename.Contains("animation") || filename.Contains("gif")));
if (filterType == "gifs") if (filterType == "gifs") return isGif;
{ if (filterType == "media") return (mType == "image" || mType == "video") && !isGif;
return isGif; if (filterType == "files") return mType == "file" || (mType != "image" && mType != "video" && mType != "link" && !isGif);
} if (filterType == "links") return mType == "link";
if (filterType == "files")
{
return mType != "image" && mType != "video" && mType != "link" && !isGif;
}
if (filterType == "media")
{
return (mType == "image" || mType == "video") && !isGif;
}
return true; return true;
}).ToList(); }).ToList();
@@ -15,5 +15,5 @@ public record TelegramMessage(
long OrderIndex = 0 long OrderIndex = 0
); );
public record TelegramMedia(string FilePath, string FileName, string MimeType); public record TelegramMedia(string FilePath, string FileName, string MimeType, string? Duration = null);
public record TelegramReaction(string Emoji, List<string> UserNames); public record TelegramReaction(string Emoji, List<string> UserNames);
@@ -175,7 +175,7 @@ public class TelegramImportWorker : BackgroundService
else if (mediaItem.MimeType.Contains("voice") || mediaItem.MimeType.Contains("audio")) itemType = "audio"; else if (mediaItem.MimeType.Contains("voice") || mediaItem.MimeType.Contains("audio")) itemType = "audio";
var fileId = await fileStorage.UploadFileAsync(mediaStream, mediaItem.FileName, mimeToUpload); var fileId = await fileStorage.UploadFileAsync(mediaStream, mediaItem.FileName, mimeToUpload);
mediaMsg.AddMedia(itemType, $"/api/files/{fileId}", mediaItem.FileName, zipEntry.Length); mediaMsg.AddMedia(itemType, $"/api/files/{fileId}", mediaItem.FileName, zipEntry.Length, mediaItem.Duration);
} }
else else
{ {
@@ -186,16 +186,82 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
var fullPath = BuildPath(baseDir, href); var fullPath = BuildPath(baseDir, href);
if (mediaList.Any(m => m.FilePath == fullPath)) continue; if (mediaList.Any(m => m.FilePath == fullPath)) continue;
var fileName = Path.GetFileName(href); // Priority 1: Extract real filename from .title or .name or .description or specific body part
string fileName = "";
var titleNode = link.QuerySelector(".title") ?? link.QuerySelector(".name") ?? link.QuerySelector(".description");
if (titleNode != null)
{
fileName = titleNode.TextContent.Trim();
}
else
{
// Try body but exclude status
var bodyNode = link.QuerySelector(".body");
if (bodyNode != null)
{
var clone = (IElement)bodyNode.Clone();
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right")) s.Remove();
fileName = clone.TextContent.Trim();
}
// Final desperate attempt: link's own content excluding status tags
if (string.IsNullOrWhiteSpace(fileName))
{
var clone = (IElement)link.Clone();
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right, .details_icon")) s.Remove();
fileName = clone.TextContent.Trim();
}
}
// Cleanup & Russian Support
if (!string.IsNullOrWhiteSpace(fileName))
{
// Decrypt potential HTML entities
fileName = System.Net.WebUtility.HtmlDecode(fileName);
// Remove extra lines if any
var lines = fileName.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
fileName = lines.Length > 0 ? lines[0].Trim() : "";
// If the "name" is just "Animation" or generic, we might want to fallback to path
if (fileName.Equals("Animation", StringComparison.OrdinalIgnoreCase)) fileName = "";
}
// Priority 2: Fallback to Path filename (e.g. file_1.mp3)
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length < 2)
{
var rawName = Path.GetFileName(href);
fileName = System.Net.WebUtility.UrlDecode(rawName);
}
var mimeType = GetMimeType(href); var mimeType = GetMimeType(href);
bool hasGifClass = link.ClassList.Contains("animated_wrap") || link.ClassList.Contains("media_video"); bool isVoice = link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages");
bool hasAnimationText = link.TextContent.Contains("Animation", StringComparison.OrdinalIgnoreCase); bool isAudio = link.ClassList.Contains("media_audio_file") || (mimeType.StartsWith("audio") && !isVoice);
bool isVideo = link.ClassList.Contains("media_video") || mimeType.StartsWith("video");
bool isGif = link.ClassList.Contains("animated_wrap") || link.TextContent.Contains("Animation", StringComparison.OrdinalIgnoreCase);
if (hasGifClass || hasAnimationText) mimeType = "image/gif"; if (isGif) mimeType = "image/gif";
if (link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages")) mimeType = "audio/ogg"; else if (isVoice) mimeType = "audio/ogg";
else if (isAudio && mimeType == "application/octet-stream") mimeType = "audio/mpeg";
mediaList.Add(new TelegramMedia(fullPath, fileName, mimeType)); // Extract Duration from .status
string? duration = null;
var statusNode = link.QuerySelector(".status") ?? link.QuerySelector(".details");
if (statusNode != null)
{
var text = statusNode.TextContent.Trim();
// Status is often: "01:23, 1.2 MB" or just "01:23"
var parts = text.Split(',');
var first = parts[0].Trim();
if (first.Contains(":") && first.All(c => char.IsDigit(c) || c == ':'))
{
duration = first;
}
}
mediaList.Add(new TelegramMedia(fullPath, fileName, mimeType, duration));
} }
foreach (var audioEl in node.QuerySelectorAll("audio[src]")) foreach (var audioEl in node.QuerySelectorAll("audio[src]"))
@@ -204,7 +270,7 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
if (href == null) continue; if (href == null) continue;
var fullPath = BuildPath(baseDir, href); var fullPath = BuildPath(baseDir, href);
if (!mediaList.Any(m => m.FilePath == fullPath)) if (!mediaList.Any(m => m.FilePath == fullPath))
mediaList.Add(new TelegramMedia(fullPath, Path.GetFileName(href), "audio/ogg")); mediaList.Add(new TelegramMedia(fullPath, System.Net.WebUtility.UrlDecode(Path.GetFileName(href)), "audio/ogg"));
} }
return mediaList; return mediaList;
@@ -244,6 +310,11 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
".mp4" => "video/mp4", ".mp4" => "video/mp4",
".mov" => "video/quicktime", ".mov" => "video/quicktime",
".webm" => "video/webm", ".webm" => "video/webm",
".mp3" => "audio/mpeg",
".ogg" => "audio/ogg",
".wav" => "audio/wav",
".m4a" => "audio/mp4",
".aac" => "audio/aac",
_ => "application/octet-stream" _ => "application/octet-stream"
}; };
} }
@@ -102,11 +102,13 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose }
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 inset-y-12 flex items-center justify-center p-4" className="absolute inset-x-0 inset-y-12 flex items-center justify-center p-4"
> >
{currentType === 'video' ? ( {(currentType === 'video' || currentType === 'gif') ? (
<video <video
src={currentUrl} src={currentUrl}
controls controls={currentType === 'video'}
autoPlay autoPlay
loop={currentType === 'gif'}
muted={currentType === 'gif'}
playsInline playsInline
preload="metadata" preload="metadata"
className="w-full h-full object-contain outline-none bg-black/50" className="w-full h-full object-contain outline-none bg-black/50"
@@ -63,6 +63,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const [profileUserId, setProfileUserId] = useState<string | null>(null); const [profileUserId, setProfileUserId] = useState<string | null>(null);
const [showGroupSettings, setShowGroupSettings] = useState(false); const [showGroupSettings, setShowGroupSettings] = useState(false);
const [showScrollDown, setShowScrollDown] = 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 [muted, setMuted] = useState(false);
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
@@ -380,6 +383,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
setShowScrollDown(!isNearBottom); setShowScrollDown(!isNearBottom);
}, []); }, []);
const lastScrollTopRef = useRef(0);
const handleScroll = () => { const handleScroll = () => {
if (isInitializingRef.current || isScrollingToBottomRef.current) return; if (isInitializingRef.current || isScrollingToBottomRef.current) return;
@@ -389,6 +393,42 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current); if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150); 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) { if (container.scrollTop < 100 && hasMoreMessages[activeChat] && !isLoadingMessages) {
useChatStore.getState().loadMessages(activeChat, false, true); useChatStore.getState().loadMessages(activeChat, false, true);
} }
@@ -1085,106 +1125,125 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</button> </button>
)} )}
<div <div className="relative flex-1 flex flex-col overflow-hidden">
ref={messagesContainerRef} <AnimatePresence mode="wait">
onScroll={handleScroll} {showStickyDate && stickyDate && (
className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`} <motion.div
> initial={{ opacity: 0, scale: 0.9, y: -20 }}
{isLoadingMessages && chatMessages.length === 0 ? ( animate={{ opacity: 1, scale: 1, y: 0 }}
<div className="flex justify-center py-8"> exit={{ opacity: 0, scale: 0.9, y: -20 }}
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" /> transition={{ type: 'spring', damping: 25, stiffness: 300 }}
</div> key="sticky-date"
) : chatMessages.length === 0 ? ( className="absolute top-6 left-1/2 -translate-x-1/2 z-[200] pointer-events-none"
<div className="flex items-center justify-center h-full"> >
<div className="flex flex-col items-center gap-4 opacity-30 select-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">
<MessagesSquare size={64} className="text-on-surface-variant" /> {stickyDate}
<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}
</span> </span>
)} </motion.div>
</motion.button> )}
)} </AnimatePresence>
</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"> <footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe">
<MessageInput chatId={activeChat} /> <MessageInput chatId={activeChat} />
@@ -336,7 +336,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
animate={{ opacity: 1, x: 0, scale: 1 }} animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 50, scale: 0.95 }} exit={{ opacity: 0, x: 50, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }} 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 */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border/40"> <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 <button
key={tab.key} key={tab.key}
onClick={() => setActiveTab(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 activeTab === tab.key
? 'bg-white/5 text-knot-400' ? 'bg-white/10 text-knot-400 shadow-[inset_0_-2px_0_0_#A855F7]'
: 'text-zinc-500 hover:text-zinc-300' : 'text-zinc-500 hover:text-zinc-400'
}`} }`}
> >
<div className="flex items-center gap-1.5 mb-0.5"> <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) => ( renderGrouped(sortedGifs, (m, idx) => (
<div <div
key={m.id} key={m.id}
onClick={() => { onClick={() => setLightboxIndex(idx)}
const eContext = { stopPropagation: () => {} } as any;
onGoToMessage?.(m.messageId);
}}
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group" className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
> >
{(m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? ( {(m.url?.toLowerCase().endsWith('.gif') || m.filename?.toLowerCase().endsWith('.gif')) ? (
@@ -823,7 +820,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
<AnimatePresence> <AnimatePresence>
{lightboxIndex !== null && ( {lightboxIndex !== null && (
<ImageLightbox <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} initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
/> />
@@ -762,34 +762,48 @@ function MessageBubble({
{/* Аудио (mp3 файлы) */} {/* Аудио (mp3 файлы) */}
{hasAudio && (() => { {hasAudio && (() => {
const audioMedia = media.find((m) => m.type === 'audio'); 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 ( return (
<div className="min-w-[220px]"> <div className="min-w-[220px]">
{audioMedia?.filename && ( {audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2 min-w-0">
<Volume2 size={14} className={isMine ? 'text-white/60' : 'text-knot-400'} /> <Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
<span className={`text-xs truncate ${isMine ? 'text-white/70' : 'text-zinc-400'}`}>{audioMedia.filename}</span> <span className={`text-[11px] font-bold truncate ${isMine ? 'text-[#0a0a0a]/80' : 'text-zinc-200'}`}>{audioMedia.filename}</span>
</div> </div>
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<audio <audio
ref={audioRef} ref={audioRef}
src={audioMedia?.url} src={getMediaUrl(audioMedia?.url)}
preload="auto" preload="auto"
onError={(e) => console.error('Audio load error:', e)}
/> />
<button <button
onClick={toggleAudio} 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' 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`}
} transition-colors`}
> >
{isPlaying ? ( {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> </button>
<div className="flex-1 min-w-0"> <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) => { {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 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; const progress = audioProgress / 100;
@@ -798,20 +812,36 @@ function MessageBubble({
return ( return (
<div <div
key={i} key={i}
className={`flex-1 rounded-full transition-colors duration-150 ${isActive className={`flex-1 rounded-full transition-all duration-150 ${isActive
? isMine ? 'bg-white/80' : 'bg-knot-400' ? isMine ? 'bg-[#0a0a0a]/70' : 'bg-primary'
: isMine ? 'bg-white/20' : 'bg-white/10' : isMine ? 'bg-[#0a0a0a]/10' : 'bg-white/20'
}`} }`}
style={{ height: `${barHeight}%` }} style={{ height: `${barHeight}%` }}
/> />
); );
})} })}
</div> </div>
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}> <div className="flex justify-between items-center mt-0.5">
{isPlaying <span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-500'}`}>
? formatDuration(audioRef.current?.currentTime || 0) {isPlaying
: formatDuration(audioDuration || 0)} ? formatDuration(audioRef.current?.currentTime || 0)
</span> : (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> </div>
</div> </div>
@@ -821,30 +851,39 @@ function MessageBubble({
{/* Файлы */} {/* Файлы */}
{hasFile && {hasFile &&
media media
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio') .filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio' && m.type !== 'gif')
.map((m) => ( .map((m) => {
<a const formatSize = (bytes?: number | null) => {
key={m.id} if (!bytes) return "";
href={m.url} if (bytes < 1024) return bytes + " B";
download={m.filename || 'file'} if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
target="_blank" if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
rel="noopener noreferrer" return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
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`} return (
> <a
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-knot-500/20' key={m.id}
}`}> href={getMediaUrl(m.url)}
<FileText size={20} className={isMine ? 'text-white' : 'text-knot-400'} /> download={m.filename || 'file'}
</div> target="_blank"
<div className="flex-1 min-w-0"> rel="noopener noreferrer"
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p> 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'
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}> } transition-all mb-1 group/file`}
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')} >
</p> <div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
</div> } group-hover/file:scale-110 transition-transform`}>
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} /> <FileText size={22} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />
</a> </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 && (() => { {message.content && (() => {
@@ -1056,7 +1095,10 @@ function MessageBubble({
<AnimatePresence> <AnimatePresence>
{lightboxData && ( {lightboxData && (
<ImageLightbox <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} initialIndex={lightboxData.index}
onClose={() => setLightboxData(null)} onClose={() => setLightboxData(null)}
/> />
@@ -212,7 +212,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
exit={{ opacity: 0, scale: 0.98, y: 15 }} exit={{ opacity: 0, scale: 0.98, y: 15 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }} transition={{ type: 'spring', damping: 25, stiffness: 300 }}
onClick={(e) => e.stopPropagation()} 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]"> <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> <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 */} {/* Tabs Nav */}
{availableTabs.length > 0 && ( {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) => ( {availableTabs.map((t) => (
<button <button
key={t.id} key={t.id}
onClick={() => setActiveTab(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" /> <t.icon size={20} className="relative z-10" />
<div className="relative z-10 flex items-center gap-2"> <div className="relative z-10 flex items-center gap-2.5">
<span className="text-[11px] font-black uppercase tracking-widest leading-none">{t.label}</span> <span className="text-[12px] font-black uppercase tracking-widest leading-none">{t.label}</span>
{counts[t.id] > 0 && ( {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]} {counts[t.id]}
</span> </span>
)} )}
@@ -301,14 +301,17 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{(activeTab === 'media' || activeTab === 'gif') ? ( {(activeTab === 'media' || activeTab === 'gif') ? (
tabData.flatMap((item) => (item.media || []).map((m: any) => { tabData.flatMap((item) => (item.media || []).map((m: any) => {
const mediaType = m.type?.toLowerCase() || 'image'; const mType = 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 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; 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 ( 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 <button
onClick={() => { onClick={() => {
const gIdx = galleryItems.findIndex(g => g.id === m.id); const gIdx = galleryItems.findIndex(g => g.id === m.id);
@@ -319,7 +322,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{isVideo ? ( {isVideo ? (
<div className="w-full h-full relative"> <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" /> <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="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"> <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> <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>} {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> </button>
{/* Jump to Message Button */} <div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-opacity z-20">
<div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-opacity z-20 flex gap-1.5">
<button <button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(item.id, item.createdAt); }} 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" 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) => { tabData.map((item, idx) => {
const mediaItem = item.media?.[0]; const mediaItem = item.media?.[0];
const linkUrl = item.links && item.links.length > 0 ? item.links[0] : (mediaItem?.url || ''); 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; if (activeTab === 'links' && (!item.links || item.links.length === 0)) return null;
return ( return (
@@ -359,8 +370,12 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
{activeTab === 'files' ? <FileText size={24} /> : <LinkIcon size={24} />} {activeTab === 'files' ? <FileText size={24} /> : <LinkIcon size={24} />}
</div> </div>
<div className="flex-1 min-w-0"> <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-sm font-black text-white truncate mb-1">
<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> {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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -376,14 +391,14 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
<Download size={20} /> <Download size={20} />
</button> </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} /> <ExternalLink size={20} />
</button> </button>
)} )}
</div> </div>
</div> </div>
); );
}) }).filter(Boolean)
)} )}
</motion.div> </motion.div>
)} )}