From 97b70250de356bd95b66c44165879793b3af99be 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, 16 Mar 2026 23:13:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=B2=20=D1=81=20=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=BC=D0=BC,=20=D0=B0=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Host/Controllers/MessagesController.cs | 70 +++-- .../Controllers/TelegramImportController.cs | 266 ++++++++++++++---- apps/server-net/src/Host/Host.csproj | 1 + .../Chats/Domain/IMessageRepository.cs | 1 + .../src/Modules/Chats/Domain/Message.cs | 4 +- .../Persistence/MessageRepository.cs | 19 ++ apps/web/src/components/ChatView.tsx | 27 +- apps/web/src/components/SideMenu.tsx | 2 +- .../src/components/TelegramImportModal.tsx | 12 +- apps/web/src/stores/chatStore.ts | 21 +- 10 files changed, 331 insertions(+), 92 deletions(-) diff --git a/apps/server-net/src/Host/Controllers/MessagesController.cs b/apps/server-net/src/Host/Controllers/MessagesController.cs index 206103d..ccae746 100644 --- a/apps/server-net/src/Host/Controllers/MessagesController.cs +++ b/apps/server-net/src/Host/Controllers/MessagesController.cs @@ -9,6 +9,7 @@ using Knot.Modules.Identity.Application.Abstractions; using System.Linq; using System.Text.RegularExpressions; using Knot.Shared.Kernel.Configuration; +using System.Runtime.CompilerServices; namespace Host.Controllers; @@ -29,41 +30,56 @@ public sealed class MessagesController : ControllerBase } [HttpGet("chat/{chatId:guid}")] - public async Task GetMessages(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? cursor, CancellationToken ct) + public async Task GetMessages(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? cursor, CancellationToken ct = default) { - var messages = await messageRepository.GetChatMessagesAsync(chatId, 50, 0, ct); - - // Filter out messages deleted for this user - messages = messages.Where(m => !m.DeletedByUsers.Contains(_userContext.UserId)).ToList(); - - // Collect all user IDs needed (senders, forwarded from) - var userIds = messages.Select(m => m.SenderId).ToList(); - userIds.AddRange(messages.Where(m => m.ForwardedFromId.HasValue).Select(m => m.ForwardedFromId!.Value)); - - var senders = new Dictionary(); - foreach (var id in userIds.Distinct()) + DateTime? cursorDate = null; + if (!string.IsNullOrEmpty(cursor) && DateTime.TryParse(cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) { - var user = await _userRepository.GetByIdAsync(id, ct); - if (user != null) senders[id] = user; + cursorDate = parsed.ToUniversalTime(); } + var messages = await messageRepository.GetChatMessagesCursorAsync(chatId, cursorDate, 100, ct); + var senders = new Dictionary(); var result = new List(); + foreach (var m in messages) { + if (m.DeletedByUsers.Contains(_userContext.UserId)) continue; + + if (!senders.ContainsKey(m.SenderId)) + { + var user = await _userRepository.GetByIdAsync(m.SenderId, ct); + if (user != null) senders[m.SenderId] = user; + } + + if (m.ForwardedFromId.HasValue && !senders.ContainsKey(m.ForwardedFromId.Value)) + { + var fuser = await _userRepository.GetByIdAsync(m.ForwardedFromId.Value, ct); + if (fuser != null) senders[m.ForwardedFromId.Value] = fuser; + } + object? replyToObj = null; if (m.ReplyToId.HasValue) { var replyMsg = await messageRepository.GetByIdAsync(m.ReplyToId.Value, ct); if (replyMsg != null) { - var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, ct); + if (!senders.ContainsKey(replyMsg.SenderId)) + { + var replySender = await _userRepository.GetByIdAsync(replyMsg.SenderId, ct); + if (replySender != null) senders[replyMsg.SenderId] = replySender; + } + var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs) + ? new { id = rs.Id, username = rs.Username, displayName = rs.DisplayName, avatar = rs.Avatar } + : null; + replyToObj = new { id = replyMsg.Id, content = replyMsg.Content, isDeleted = replyMsg.IsDeleted, media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(), - sender = replySender != null ? new { id = replySender.Id, username = replySender.Username, displayName = replySender.DisplayName } : null + sender = senderObj }; } } @@ -71,15 +87,21 @@ public sealed class MessagesController : ControllerBase var reactionsWithUser = new List(); foreach (var r in m.Reactions) { - var rUser = await _userRepository.GetByIdAsync(r.UserId, ct); + if (!senders.ContainsKey(r.UserId)) + { + var rUser = await _userRepository.GetByIdAsync(r.UserId, ct); + if (rUser != null) senders[r.UserId] = rUser; + } + var userObj = senders.TryGetValue(r.UserId, out var ru) + ? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName } + : new { id = r.UserId, username = "unknown", displayName = "Unknown" }; + reactionsWithUser.Add(new { id = r.Id, emoji = r.Emoji, userId = r.UserId, - user = rUser != null - ? new { id = rUser.Id, username = rUser.Username, displayName = rUser.DisplayName } - : new { id = r.UserId, username = "unknown", displayName = "Unknown" } + user = userObj }); } @@ -118,12 +140,12 @@ public sealed class MessagesController : ControllerBase username = s.Username, displayName = s.DisplayName, avatar = s.Avatar - } : new { id = m.SenderId, username = "unknown", displayName = "Unknown", avatar = (string?)null }, - reactions = reactionsWithUser, - readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList() + } : null, + readBy = m.ReadBy.Select(r => r.UserId).ToList(), + reactions = reactionsWithUser }); } - + return Ok(result); } diff --git a/apps/server-net/src/Host/Controllers/TelegramImportController.cs b/apps/server-net/src/Host/Controllers/TelegramImportController.cs index 6779f55..811591b 100644 --- a/apps/server-net/src/Host/Controllers/TelegramImportController.cs +++ b/apps/server-net/src/Host/Controllers/TelegramImportController.cs @@ -77,24 +77,28 @@ public sealed class TelegramImportController : ControllerBase // Open zip and quickly scan messages.html using (var archive = ZipFile.OpenRead(tempPath)) { - var htmlEntries = archive.Entries.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)); + var htmlEntries = archive.Entries + .Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)); foreach (var entry in htmlEntries) { using var stream = entry.Open(); - var doc = new HtmlDocument(); - doc.Load(stream); + var parser = new AngleSharp.Html.Parser.HtmlParser(); + var doc = parser.ParseDocument(stream); - var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'message ')]"); + var messageNodes = doc.QuerySelectorAll(".message"); if (messageNodes == null) continue; foreach (var node in messageNodes) { - var fromNameNode = node.SelectSingleNode(".//div[contains(@class, 'from_name')]"); + var fromNameNode = node.QuerySelector(".from_name"); if (fromNameNode != null) { - var name = fromNameNode.InnerText.Trim(); - // Ignore standard system names if obvious (for now everything is recorded) + var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone(); + var innerSpans = nameNodeText.QuerySelectorAll("span"); + foreach (var span in innerSpans) span.Remove(); + + var name = nameNodeText.TextContent.Trim(); if (!string.IsNullOrWhiteSpace(name)) { names.Add(name); @@ -169,15 +173,25 @@ public sealed class TelegramImportController : ControllerBase { var htmlEntries = archive.Entries .Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)) - .OrderBy(e => e.FullName); // Read chronologically + .OrderBy(e => + { + var name = e.Name.ToLower().Replace("messages", "").Replace(".html", ""); + return string.IsNullOrEmpty(name) ? 0 : int.TryParse(name, out var num) ? num : 999999; + }) + .ToList(); + + Guid lastSenderGuid = myId; + DateTime lastCreatedAt = DateTime.UtcNow; + Dictionary messageIdMap = new(); + Message? lastSavedMessage = null; foreach (var entry in htmlEntries) { using var stream = entry.Open(); - var doc = new HtmlDocument(); - doc.Load(stream); + var parser = new AngleSharp.Html.Parser.HtmlParser(); + var doc = parser.ParseDocument(stream); - var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'message ')]"); + var messageNodes = doc.QuerySelectorAll(".message"); if (messageNodes == null) continue; var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? ""; @@ -185,56 +199,193 @@ public sealed class TelegramImportController : ControllerBase foreach (var node in messageNodes) { - try + try { - var fromNameNode = node.SelectSingleNode(".//div[contains(@class, 'from_name')]"); - var textNode = node.SelectSingleNode(".//div[contains(@class, 'text')]"); - var dateNode = node.SelectSingleNode(".//div[contains(@class, 'pull_right date')]"); + var fromNameNode = node.QuerySelector(".from_name"); + var textNode = node.QuerySelector(".text"); - // Default values - var senderGuid = myId; // Fallback + var dateNode = node.QuerySelector(".date[title]") + ?? node.QuerySelector(".pull_right[title]") + ?? node.QuerySelector("[title]"); + if (fromNameNode != null) { - var name = fromNameNode.InnerText.Trim(); + var nameNodeText = (AngleSharp.Dom.IElement)fromNameNode.Clone(); + var innerSpans = nameNodeText.QuerySelectorAll("span"); + foreach (var span in innerSpans) span.Remove(); + + var name = nameNodeText.TextContent.Trim(); if (req.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty) - senderGuid = mappedId; + lastSenderGuid = mappedId; + else + lastSenderGuid = myId; } - var content = textNode?.InnerText?.Trim() ?? ""; + Guid senderGuid = lastSenderGuid; + + string content = ""; + if (textNode != null) + { + var html = textNode.InnerHtml + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); + var tempParser = new AngleSharp.Html.Parser.HtmlParser(); + var tempDoc = tempParser.ParseDocument("
" + html + "
"); + content = tempDoc.Body.TextContent.Trim(); + } - DateTime createdAt = DateTime.UtcNow; - if (dateNode != null && dateNode.Attributes["title"] != null) - { - var dateStr = dateNode.Attributes["title"].Value; - if (DateTime.TryParse(dateStr, out var d)) - createdAt = d.ToUniversalTime(); - } + DateTime createdAt = lastCreatedAt; + var titleNodes = node.QuerySelectorAll("[title]"); + bool parsed = false; - var mediaNodes = node.SelectNodes(".//a[contains(@class, 'photo_wrap')] | .//video | .//a[contains(@class, 'document')] | .//a[contains(@class, 'media_voice_message')]"); - - var messageType = "text"; - if (mediaNodes != null && mediaNodes.Count > 0) + if (titleNodes != null) { - var firstHref = mediaNodes[0].Attributes["href"]?.Value ?? mediaNodes[0].Attributes["src"]?.Value; - if (firstHref != null) + foreach (var tnode in titleNodes) { - if (firstHref.EndsWith(".jpg") || firstHref.EndsWith(".png")) messageType = "image"; - else if (firstHref.EndsWith(".mp4")) messageType = "video"; - else if (firstHref.EndsWith(".ogg")) messageType = "voice"; - else messageType = "file"; + var dateStr = tnode.GetAttribute("title")?.Trim() ?? ""; + + if (dateStr.Length >= 10 && char.IsDigit(dateStr[0]) && char.IsDigit(dateStr[1])) + { + // Remove "UTC" so we can parse timezone offset generically, e.g. "17.10.2025 12:56:25 +03:00" + var cleanStr = dateStr.Replace("UTC", "", StringComparison.OrdinalIgnoreCase).Trim(); + + if (DateTimeOffset.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto)) + { + createdAt = dto.UtcDateTime; + parsed = true; + break; + } + else if (DateTime.TryParseExact(cleanStr, "dd.MM.yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt)) + { + createdAt = dt; + parsed = true; + break; + } + else if (DateTime.TryParse(cleanStr, out var dFallback)) + { + createdAt = dFallback.ToUniversalTime(); + parsed = true; + break; + } + } } } - var newMessage = Message.Import(chatId, senderGuid, content, messageType, createdAt); + if (!parsed) + { + Console.WriteLine("Warning: Could not parse date in imported message! Using lastCreatedAt."); + } + else + { + lastCreatedAt = createdAt; + } + + // Parse forwards + var forwardedNode = node.QuerySelector(".forwarded.body"); + if (forwardedNode != null) + { + var fwdNameNode = forwardedNode.QuerySelector(".from_name"); + var fwdNameText = fwdNameNode != null ? (AngleSharp.Dom.IElement)fwdNameNode.Clone() : null; + if (fwdNameText != null) + { + var innerSpans = fwdNameText.QuerySelectorAll("span"); + foreach (var s in innerSpans) s.Remove(); + } + var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного"; + + var fwdTextNode = forwardedNode.QuerySelector(".text"); + string fwdContent = ""; + if (fwdTextNode != null) + { + var fHtml = fwdTextNode.InnerHtml + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); + var tempParser = new AngleSharp.Html.Parser.HtmlParser(); + var tempDoc = tempParser.ParseDocument("
" + fHtml + "
"); + fwdContent = tempDoc.Body.TextContent.Trim(); + } + + content = string.IsNullOrEmpty(content) + ? $"[Переслано от {fwdName}]:\n{fwdContent}" + : $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}"; + } + + // Parse replies + Guid? replyToId = null; + var replyNode = node.QuerySelector(".reply_to a"); + if (replyNode != null) + { + var href = replyNode.GetAttribute("href"); + if (href != null && href.StartsWith("#go_to_")) + { + var tgId = href.Substring("#go_to_".Length); + if (messageIdMap.TryGetValue(tgId, out var mappedMsgId)) + { + replyToId = mappedMsgId; + } + } + } + + var mediaNodes = node.QuerySelectorAll("a.photo_wrap, a.animated_wrap, video, audio, a.document, a.media_voice_message, a.media_video, img.sticker").ToList(); + if (mediaNodes.Count == 0) + { + var fallback = node.QuerySelectorAll(".media_wrap a[href]"); + mediaNodes.AddRange(fallback); + } + + var messageType = "text"; + + // Helper for categorizing files + (string mType, string cType) GetMediaTypes(string fileUrl) + { + var ext = Path.GetExtension(fileUrl)?.ToLower(); + return ext switch + { + ".jpg" or ".jpeg" or ".png" or ".webp" => ("image", "image/jpeg"), + ".mp4" or ".mov" or ".avi" => ("video", "video/mp4"), + ".ogg" or ".mp3" => ("voice", "audio/ogg"), + _ => ("file", "application/octet-stream") + }; + } + + if (mediaNodes != null && mediaNodes.Count > 0) + { + var firstHref = mediaNodes[0].GetAttribute("href") ?? mediaNodes[0].GetAttribute("src"); + if (firstHref != null) + { + messageType = GetMediaTypes(firstHref).mType; + } + } + + bool isJoined = fromNameNode == null; + bool isMediaOnly = string.IsNullOrEmpty(content) && forwardedNode == null && replyToId == null; + Message? targetMessage = null; + + if (isJoined && isMediaOnly && lastSavedMessage != null && Math.Abs((createdAt - lastSavedMessage.CreatedAt).TotalSeconds) <= 60 && lastSavedMessage.SenderId == senderGuid) + { + targetMessage = lastSavedMessage; + } + else + { + string finalContent = content; + targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, null); + + var idAttr = node.GetAttribute("id"); + if (!string.IsNullOrEmpty(idAttr)) + { + messageIdMap[idAttr] = targetMessage.Id; + } + } if (mediaNodes != null) { foreach (var mediaNode in mediaNodes) { - string? href = mediaNode.Attributes["href"]?.Value ?? mediaNode.Attributes["src"]?.Value; + string? href = mediaNode.GetAttribute("href") ?? mediaNode.GetAttribute("src"); if (!string.IsNullOrEmpty(href) && !href.StartsWith("http")) { - // Local file in zip var zipPath = baseDir + href.Replace("\\", "/"); var zipEntry = archive.GetEntry(zipPath); if (zipEntry != null) @@ -244,23 +395,36 @@ public sealed class TelegramImportController : ControllerBase await zipfs.CopyToAsync(ms, ct); ms.Position = 0; - string cType = "application/octet-stream"; - var mType = "file"; - if (href.EndsWith(".jpg") || href.EndsWith(".png")) { cType = "image/jpeg"; mType = "image"; messageType = "image"; } - else if (href.EndsWith(".mp4")) { cType = "video/mp4"; mType = "video"; messageType = "video"; } - else if (href.EndsWith(".ogg")) { cType = "audio/ogg"; mType = "voice"; messageType = "voice"; } - - var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), cType); - newMessage.AddMedia(mType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); + var types = GetMediaTypes(href); + var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType); + targetMessage.AddMedia(types.mType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); } } } } - // Save text message without media if content is just empty, but wait, if it's empty and has no media, it was probably a system message (like pinned, joined). - if (!string.IsNullOrEmpty(content) || newMessage.Media.Any()) + // Parse reactions + var reactionNodes = node.QuerySelectorAll(".reactions .reaction"); + foreach (var reactionNode in reactionNodes) { - _messageRepository.Add(newMessage); + var emojiNode = reactionNode.QuerySelector(".emoji"); + if (emojiNode == null) continue; + var emoji = emojiNode.TextContent.Trim(); + + var userpicNodes = reactionNode.QuerySelectorAll(".userpics .userpic .initials[title]"); + foreach (var userpicNode in userpicNodes) + { + var title = userpicNode.GetAttribute("title")?.Trim(); + if (!string.IsNullOrEmpty(title) && req.Mapping.TryGetValue(title, out var rUserId) && rUserId != Guid.Empty) + { + targetMessage.AddReaction(rUserId, emoji); + } + } + } + if (targetMessage != lastSavedMessage && (!string.IsNullOrEmpty(content) || targetMessage.Media.Any())) + { + _messageRepository.Add(targetMessage); + lastSavedMessage = targetMessage; importedCount++; } } diff --git a/apps/server-net/src/Host/Host.csproj b/apps/server-net/src/Host/Host.csproj index ef55eab..a12a735 100644 --- a/apps/server-net/src/Host/Host.csproj +++ b/apps/server-net/src/Host/Host.csproj @@ -7,6 +7,7 @@ + diff --git a/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs b/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs index f6c824a..39075d1 100644 --- a/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs +++ b/apps/server-net/src/Modules/Chats/Domain/IMessageRepository.cs @@ -11,5 +11,6 @@ public interface IMessageRepository Task AddReadReceiptsAsync(Guid userId, List messageIds, CancellationToken cancellationToken); Task AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken); Task RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken); + Task> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken); Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); } diff --git a/apps/server-net/src/Modules/Chats/Domain/Message.cs b/apps/server-net/src/Modules/Chats/Domain/Message.cs index 1092559..72bc67f 100644 --- a/apps/server-net/src/Modules/Chats/Domain/Message.cs +++ b/apps/server-net/src/Modules/Chats/Domain/Message.cs @@ -64,9 +64,9 @@ public sealed class Message : AggregateRoot return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false); } - public static Message Import(Guid chatId, Guid senderId, string? content, string type, DateTime createdAt, Guid? replyToId = null) + public static Message Import(Guid chatId, Guid senderId, string? content, string type, DateTime createdAt, Guid? replyToId = null, Guid? forwardedFromId = null) { - return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, null, null, null, null, createdAt, true); + return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, forwardedFromId, null, null, null, createdAt, true); } public void AddMedia(string type, string url, string? filename, long? size) diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs index ba173b9..0a092db 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs @@ -39,6 +39,25 @@ public sealed class MessageRepository : IMessageRepository .ToListAsync(cancellationToken); } + public async Task> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken) + { + var query = _dbContext.Messages + .Where(m => m.ChatId == chatId); + + if (cursor.HasValue) + { + query = query.Where(m => m.CreatedAt < cursor.Value); + } + + return await query + .OrderByDescending(m => m.CreatedAt) + .Take(limit) + .Include(m => m.Media) + .Include(m => m.ReadBy) + .Include(m => m.Reactions) + .ToListAsync(cancellationToken); + } + public async Task> SearchMessagesAsync(string query, Guid? chatId, CancellationToken cancellationToken) { var q = _dbContext.Messages.AsQueryable(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 861f4c0..edced55 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -48,6 +48,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal typingUsers, pinnedMessages, isLoadingMessages, + hasMoreMessages, + loadMessages, setActiveChat, } = useChatStore(); @@ -113,6 +115,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal } const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; const lastObservedMessageIdRef = useRef(null); + const prevScrollHeightRef = useRef(0); // Load muted state useEffect(() => { @@ -199,7 +202,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal } setScrollReady(true); } - }, [activeChat, isLoadingMessages]); + }, [activeChat, isLoadingMessages && chatMessages.length === 0]); + + useLayoutEffect(() => { + if (prevScrollHeightRef.current > 0 && messagesContainerRef.current) { + const container = messagesContainerRef.current; + const diff = container.scrollHeight - prevScrollHeightRef.current; + container.scrollTop += diff; + prevScrollHeightRef.current = 0; + } + }, [chatMessages.length]); // Scroll on new message arrivals useEffect(() => { @@ -316,6 +328,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const handleScroll = () => { checkScrollPosition(); + + const container = messagesContainerRef.current; + if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) { + prevScrollHeightRef.current = container.scrollHeight; + useChatStore.getState().loadMessages(activeChat, false); + } }; useEffect(() => { @@ -851,7 +869,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal onScroll={handleScroll} className={`flex-1 overflow-y-auto px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`} > - {isLoadingMessages ? ( + {isLoadingMessages && chatMessages.length === 0 ? (
@@ -861,6 +879,11 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
) : (
+ {isLoadingMessages && ( +
+
+
+ )} {chatMessages.map((msg, i) => { const prevMsg = i > 0 ? chatMessages[i - 1] : null; const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId; diff --git a/apps/web/src/components/SideMenu.tsx b/apps/web/src/components/SideMenu.tsx index 32d5034..ad3f523 100644 --- a/apps/web/src/components/SideMenu.tsx +++ b/apps/web/src/components/SideMenu.tsx @@ -431,7 +431,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro

Хранилище и данные