From d96e4ec7d4c82f60595d03133c9be8b1f644ff9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 6 Apr 2026 22:35:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GetSharedMedia/GetSharedMediaQuery.cs | 2 +- .../Parser/TelegramHtmlParser.cs | 96 ++++++++++--------- .../presentation/components/GroupSettings.tsx | 12 +-- 3 files changed, 56 insertions(+), 54 deletions(-) diff --git a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs index cea1e01..1e73ab6 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetSharedMedia/GetSharedMediaQuery.cs @@ -102,7 +102,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler(); string? lastSenderName = null; + DateTime? lastCreatedAt = null; var messageNodes = doc.QuerySelectorAll(".message"); foreach (var node in messageNodes) { - // STOP ALL MERGING OF SEPARATE NODES. - // Telegram HTML nodes represent visual bubbles. If we merge them, we break the original hierarchy - // and often misplace captions or hide media. + bool isJoined = node.ClassList.Contains("joined"); + var media = ExtractMedia(node, baseDirInZip); + var textNode = node.QuerySelector(".text"); + var hasText = textNode != null && !string.IsNullOrWhiteSpace(textNode.TextContent); + + var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]"); + var dateStr = dateNode?.GetAttribute("title") ?? ""; + DateTime? currentAt = ParseDate(dateStr); + + // REFINED ALBUM MERGING LOGIC: + // 1. Current is .joined + // 2. Current has media but NO text (important: if it has text, it's a new bubble group) + // 3. Sender is the same as previous message + // 4. Time difference is minimal (<= 2 seconds). Telegram albums usually share the exact same timestamp. + if (isJoined && media.Count > 0 && !hasText && messages.Count > 0) + { + var prev = messages[messages.Count - 1]; + bool isSameSender = lastSenderName != null; // Since it's 'joined', it's the same visual group + bool isCloseInTime = lastCreatedAt.HasValue && currentAt.HasValue && + Math.Abs((currentAt.Value - lastCreatedAt.Value).TotalSeconds) <= 2; + + if (isSameSender && isCloseInTime) + { + var mergedMedia = (prev.Media ?? new List()).Concat(media).ToList(); + messages[messages.Count - 1] = prev with { Media = mergedMedia }; + continue; + } + } + var msg = ParseSingleMessage(node, baseDirInZip, ref lastSenderName); - if (msg != null) messages.Add(msg); + if (msg != null) + { + messages.Add(msg); + lastCreatedAt = currentAt; + } } return messages; @@ -82,11 +113,9 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser if (idAttr.StartsWith("message")) long.TryParse(idAttr.Replace("message", ""), out numericId); var fromNameNode = node.QuerySelector(".body > .from_name"); - // Ignore forwarded from_name as message sender if (fromNameNode != null && fromNameNode.Closest(".forwarded") != null) fromNameNode = null; var senderName = fromNameNode != null ? CleanName(fromNameNode) : lastSenderName; - // If it's a new bubble group (no "joined"), update lastSenderName if (senderName != null && !node.ClassList.Contains("joined")) lastSenderName = senderName; var textNode = node.QuerySelector(".text"); @@ -94,30 +123,10 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]"); var dateStr = dateNode?.GetAttribute("title") ?? ""; - DateTime createdAt = DateTime.UtcNow; - - if (!string.IsNullOrEmpty(dateStr)) - { - var cleanDateStr = dateStr.Trim(); - var formats = new[] { "dd.MM.yyyy HH:mm:ss 'UTC'zzz", "dd.MM.yyyy HH:mm:ss", "d.MM.yyyy HH:mm:ss", "dd.M.yyyy HH:mm:ss", "d.M.yyyy HH:mm:ss" }; - - try - { - if (DateTimeOffset.TryParseExact(cleanDateStr, formats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto)) - { - createdAt = dto.UtcDateTime; - } - else if (DateTimeOffset.TryParse(cleanDateStr.Replace("UTC", ""), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto2)) - { - createdAt = dto2.UtcDateTime; - } - } - catch { } - } + DateTime createdAt = ParseDate(dateStr) ?? DateTime.UtcNow; var mediaList = ExtractMedia(node, baseDir); - // Replies string? replyToId = null; var replyNode = node.QuerySelector(".reply_to a[href^=\"#go_to_message\"]"); if (replyNode != null) @@ -126,12 +135,10 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser replyToId = href?.Replace("#go_to_message", "message"); } - // Forwards string? forwardedFrom = null; var forwardNode = node.QuerySelector(".forwarded .from_name"); if (forwardNode != null) forwardedFrom = CleanName(forwardNode); - // Reactions var reactions = new List(); var reactionNodes = node.QuerySelectorAll(".reactions .reaction"); foreach (var r in reactionNodes) @@ -153,11 +160,20 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser } } + private static DateTime? ParseDate(string? dateStr) + { + if (string.IsNullOrEmpty(dateStr)) return null; + var cleanDateStr = dateStr.Trim(); + var formats = new[] { "dd.MM.yyyy HH:mm:ss 'UTC'zzz", "dd.MM.yyyy HH:mm:ss", "d.MM.yyyy HH:mm:ss", "dd.M.yyyy HH:mm:ss", "d.M.yyyy HH:mm:ss" }; + + if (DateTimeOffset.TryParseExact(cleanDateStr, formats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto)) return dto.UtcDateTime; + if (DateTimeOffset.TryParse(cleanDateStr.Replace("UTC", ""), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto2)) return dto2.UtcDateTime; + return null; + } + private List ExtractMedia(IElement node, string baseDir) { var mediaList = new List(); - - // Look for links that represent media containers var allLinks = node.QuerySelectorAll("a[href]").ToList(); foreach (var link in allLinks) @@ -165,7 +181,6 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser var href = NormalizeHref(link.GetAttribute("href")); if (href == null || !href.Contains("/")) continue; - // Skip internal pages if (href.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) continue; var fullPath = BuildPath(baseDir, href); @@ -174,28 +189,15 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser var fileName = Path.GetFileName(href); var mimeType = GetMimeType(href); - // ANIMATION DETECTION: - // 1. Check classes (standard TG export) bool hasGifClass = link.ClassList.Contains("animated_wrap") || link.ClassList.Contains("media_video"); - - // 2. Check full text of the link container for "Animation" string - // This is the most reliable way to find TG "GIFs" which are actually MP4s bool hasAnimationText = link.TextContent.Contains("Animation", StringComparison.OrdinalIgnoreCase); - if (hasGifClass || hasAnimationText) - { - mimeType = "image/gif"; - } - - if (link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages")) - { - mimeType = "audio/ogg"; - } + if (hasGifClass || hasAnimationText) mimeType = "image/gif"; + if (link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages")) mimeType = "audio/ogg"; mediaList.Add(new TelegramMedia(fullPath, fileName, mimeType)); } - // Alternative check for voice messages in audio tags foreach (var audioEl in node.QuerySelectorAll("audio[src]")) { var href = NormalizeHref(audioEl.GetAttribute("src")); diff --git a/client-web/src/modules/chats/presentation/components/GroupSettings.tsx b/client-web/src/modules/chats/presentation/components/GroupSettings.tsx index 3df2b5a..d7c2575 100644 --- a/client-web/src/modules/chats/presentation/components/GroupSettings.tsx +++ b/client-web/src/modules/chats/presentation/components/GroupSettings.tsx @@ -308,10 +308,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const tabsConfig = [ { key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length }, - { key: 'gifs' as const, label: t('gifs') || 'GIF', icon: Play, count: sortedGifs.length }, - { key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length }, - { key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length }, - { key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length }, + { key: 'gifs' as const, label: 'GIF', icon: Play, count: sortedGifs.length }, + { key: 'media' as const, label: t('mediaTab') || 'Медиа', icon: ImageIcon, count: sortedMedia.length }, + { key: 'files' as const, label: t('filesTab') || 'Файлы', icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length }, + { key: 'links' as const, label: t('linksTab') || 'Ссылки', icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length }, ]; const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0); @@ -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-[650px] 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-[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" > {/* Header */}
@@ -496,7 +496,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet