Починка импорта, плеер для аудио
This commit is contained in:
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+4
-14
@@ -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();
|
||||||
|
|||||||
+1
-1
@@ -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);
|
||||||
|
|||||||
+1
-1
@@ -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,10 +1125,28 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={messagesContainerRef}
|
ref={messagesContainerRef}
|
||||||
onScroll={handleScroll}
|
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' : ''}`}
|
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 ? (
|
{isLoadingMessages && chatMessages.length === 0 ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
@@ -1174,7 +1232,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
onClick={() => scrollToBottom(true)}
|
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"
|
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} />
|
<ArrowDown size={28} />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
@@ -1185,6 +1243,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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">
|
||||||
|
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-500'}`}>
|
||||||
{isPlaying
|
{isPlaying
|
||||||
? formatDuration(audioRef.current?.currentTime || 0)
|
? formatDuration(audioRef.current?.currentTime || 0)
|
||||||
: formatDuration(audioDuration || 0)}
|
: (typeof audioMedia?.duration === 'number'
|
||||||
|
? formatDuration(audioMedia.duration)
|
||||||
|
: (audioMedia?.duration || formatDuration(audioDuration || 0)))}
|
||||||
</span>
|
</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) => {
|
||||||
|
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
|
<a
|
||||||
key={m.id}
|
key={m.id}
|
||||||
href={m.url}
|
href={getMediaUrl(m.url)}
|
||||||
download={m.filename || 'file'}
|
download={m.filename || 'file'}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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'
|
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-colors mb-1`}
|
} transition-all mb-1 group/file`}
|
||||||
>
|
>
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-knot-500/20'
|
<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={20} className={isMine ? 'text-white' : 'text-knot-400'} />
|
<FileText size={22} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p>
|
<p className={`text-[13px] font-bold truncate ${isMine ? 'text-[#0a0a0a]' : 'text-zinc-200'}`}>{m.filename || t('fileLabel')}</p>
|
||||||
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}>
|
<p className={`text-[11px] font-medium ${isMine ? 'text-[#0a0a0a]/50' : 'text-zinc-500'}`}>
|
||||||
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')}
|
{formatSize(m.size) || t('download')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} />
|
<Download size={18} className={isMine ? 'text-[#0a0a0a]/30' : 'text-zinc-500'} />
|
||||||
</a>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user