diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 375a4af..8bdf41c 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -130,23 +130,28 @@ public sealed class MaxBridgeSyncService( var previewHint = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); var shouldLoadLastKnownMessage = !chatWasCreated && !string.IsNullOrWhiteSpace(previewHint) && - !string.IsNullOrWhiteSpace(chat.LastMessagePreview) && - string.Equals(MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview), previewHint, StringComparison.Ordinal) && update.LastMessageIsOutgoing != true && update.Messages.Count == 0 && HasListClockTime(update.LastMessageTimeText); - List lastKnownMessages = !shouldLoadLastKnownMessage + var lastKnownOutgoingMessages = !shouldLoadLastKnownMessage ? [] : await db.Messages + .AsNoTracking() + .Include(x => x.Attachments) .Where(x => x.ChatId == chat.Id && x.DeletedAt == null && - x.Direction == MessageDirection.Outgoing && - x.Text == previewHint) - .Select(x => new LastKnownMessageSnapshot(x.Direction, x.Text, x.SentAt)) + x.Direction == MessageDirection.Outgoing) + .OrderByDescending(x => x.SentAt) + .Take(5) .ToListAsync(cancellationToken); - var lastKnownMessage = lastKnownMessages - .OrderByDescending(x => x.SentAt) + var lastKnownMessage = lastKnownOutgoingMessages + .Select(x => new LastKnownMessageSnapshot( + x.Direction, + x.Text, + LastMessagePreview(x.Text, x.Attachments), + x.Attachments.Count > 0, + x.SentAt)) .FirstOrDefault(); if (ApplyListPreview( @@ -349,14 +354,15 @@ public sealed class MaxBridgeSyncService( DateTimeOffset previewAt) { if (lastKnownMessage is null || - lastKnownMessage.Direction != MessageDirection.Outgoing || - string.IsNullOrWhiteSpace(lastKnownMessage.Text)) + lastKnownMessage.Direction != MessageDirection.Outgoing) { return false; } - var lastText = MessageTextSanitizer.CleanChatPreview(lastKnownMessage.Text); - if (!string.Equals(lastText, preview, StringComparison.Ordinal)) + var lastPreview = MessageTextSanitizer.CleanChatPreview(lastKnownMessage.Preview ?? lastKnownMessage.Text); + var previewMatches = string.Equals(lastPreview, preview, StringComparison.Ordinal) || + lastKnownMessage.HasAttachments && MessageTextSanitizer.IsGenericMediaLabel(preview); + if (!previewMatches) { return false; } @@ -810,5 +816,7 @@ public sealed class MaxBridgeSyncService( private sealed record LastKnownMessageSnapshot( MessageDirection Direction, string? Text, + string? Preview, + bool HasAttachments, DateTimeOffset SentAt); } diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 69af4fa..59286df 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -666,6 +666,77 @@ public sealed class ApiSmokeTests : IDisposable } } + [Fact] + public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingAttachment() + { + var pushNotifications = new RecordingPushNotificationService(); + var bridge = new OutgoingPreviewEchoMaxBridgeClient( + $"mock-self-echo-attachment-chat-{Guid.NewGuid():N}", + "\u0424\u043e\u0442\u043e"); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + services.AddSingleton(pushNotifications); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + Guid chatId; + var sentAt = DateTimeOffset.UtcNow.AddSeconds(-20); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var chat = new Chat + { + ExternalId = bridge.ChatExternalId, + Title = "Self Attachment Echo", + LastMessagePreview = "\u041c\u0435\u0434\u0438\u0430", + LastMessageAt = sentAt, + UnreadCount = 0 + }; + var message = new Message + { + Chat = chat, + ExternalId = "self-echo-attachment-message", + Direction = MessageDirection.Outgoing, + SentAt = sentAt, + DeliveryState = MessageDeliveryState.Sent + }; + message.Attachments.Add(new MessageAttachment + { + Message = message, + OriginalFileName = "sent-photo.jpg", + StorageFileName = "sent-photo.jpg", + ContentType = "image/jpeg", + FileSizeBytes = 1024, + Kind = AttachmentKind.Image + }); + db.Chats.Add(chat); + db.Messages.Add(message); + await db.SaveChangesAsync(); + chatId = chat.Id; + } + + var sync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, sync); + + Assert.Empty(pushNotifications.SentMessages); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var chat = await db.Chats.FindAsync(chatId); + Assert.NotNull(chat); + Assert.Equal(0, chat!.UnreadCount); + Assert.Equal("\u041c\u0435\u0434\u0438\u0430", chat.LastMessagePreview); + } + } + [Fact] public async Task FirebasePushSkipsOutgoingMessages() { diff --git a/worker/src/server.js b/worker/src/server.js index eb440ad..d777e2e 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -2610,7 +2610,6 @@ async function clearSidebarSearch(p) { input.dispatchEvent(new Event("change", { bubbles: true })); } }).catch(() => {}); - await p.keyboard.press("Escape").catch(() => {}); await p.waitForTimeout(150); } @@ -2830,7 +2829,7 @@ async function openDomChatByExternalId(p, externalChatId) { const rect = match.button.getBoundingClientRect(); return { opened: false, - x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.35))), + x: Math.round(rect.left + Math.min(rect.width - 8, Math.max(8, rect.width * 0.50))), y: Math.round(rect.top + rect.height / 2) }; }, { @@ -3659,7 +3658,13 @@ async function uploadAttachmentFile(p, path, caption) { return false; } - await waitForAttachmentPreview(p, 8000); + await p.waitForTimeout(2000); + const previewReady = await waitForAttachmentPreview(p, 10000); + console.info(`[send/attachment] file selected; previewReady=${previewReady} url=${p.url()}`); + if (!previewReady) { + return false; + } + if (caption.trim()) { await fillComposerText(p, caption.trim(), false); await p.waitForTimeout(300); @@ -3731,6 +3736,90 @@ async function attachFileViaUploadButton(p, filePath) { return false; } + try { + const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 800 }).catch(() => null); + await p.mouse.click(point.x, point.y); + const fileChooser = await fileChooserPromise; + if (fileChooser) { + await fileChooser.setFiles(filePath); + return true; + } + + return await attachFileViaMenuItem(p, filePath); + } catch { + return false; + } +} + +async function attachFileViaMenuItem(p, filePath) { + const preferMedia = /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm)$/i.test(filePath); + const deadline = Date.now() + 3500; + let point = null; + while (Date.now() < deadline && !point) { + point = await p.evaluate(({ preferMedia }) => { + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const cleanText = (value) => String(value || "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + const menuItems = Array.from(document.querySelectorAll("[role='menuitem'], button[class*='actionsMenuItem' i]")) + .filter((element) => !element.disabled && isVisible(element)) + .filter((element) => !element.closest?.("[data-testid='composer'], [class*='composer' i]")) + .map((element) => { + const rect = element.getBoundingClientRect(); + const text = cleanText([ + element.getAttribute("aria-label"), + element.getAttribute("title"), + element.getAttribute("data-testid"), + element.className, + element.innerText, + element.textContent + ].join(" ")); + const media = /photo|video|media|\u0444\u043e\u0442\u043e|\u0432\u0438\u0434\u0435\u043e/.test(text); + const file = /file|\u0444\u0430\u0439\u043b/.test(text); + return { element, rect, text, media, file }; + }) + .filter((item) => item.media || item.file) + .sort((a, b) => { + const aPreferred = preferMedia ? a.media : a.file; + const bPreferred = preferMedia ? b.media : b.file; + return Number(bPreferred) - Number(aPreferred) || + a.rect.top - b.rect.top || + a.rect.left - b.rect.left; + }); + const item = menuItems[0]?.element || null; + if (!item) { + return null; + } + + const rect = item.getBoundingClientRect(); + return { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2) + }; + }, { preferMedia }).catch(() => null); + + if (!point) { + await p.waitForTimeout(100); + } + } + + if (!point) { + return false; + } + try { const fileChooserPromise = p.waitForEvent("filechooser", { timeout: 5000 }).catch(() => null); await p.mouse.click(point.x, point.y); @@ -3767,6 +3856,31 @@ async function waitForAttachmentPreview(p, timeoutMs = 8000) { const rect = element.getBoundingClientRect(); return rect.left > window.innerWidth * 0.30 && rect.top > window.innerHeight * 0.45; }; + const isPendingUploadSurface = (element) => { + if (element.closest?.("aside, [class*='messageWrapper' i], [class*='bubble' i], [class*='message--' i], [role='listitem']")) { + return false; + } + + const surface = element.closest?.([ + "[data-testid='composer']", + "[class*='composer' i]", + "[role='dialog']", + "[class*='modal' i]", + "[class*='preview' i]", + "[class*='attach' i]", + "[class*='upload' i]", + "[class*='file' i]", + "[class*='media' i]" + ].join(",")); + if (surface) { + return true; + } + + const rect = element.getBoundingClientRect(); + return rect.left > window.innerWidth * 0.38 && + rect.top > window.innerHeight * 0.55 && + rect.bottom > window.innerHeight * 0.70; + }; const previewSelectors = [ "[class*='preview' i]", "[class*='attach' i]", @@ -3778,24 +3892,17 @@ async function waitForAttachmentPreview(p, timeoutMs = 8000) { ]; const hasPreview = previewSelectors .flatMap((selector) => Array.from(document.querySelectorAll(selector))) - .some((element) => isVisible(element) && bottomArea(element)); - const sendButton = Array.from(document.querySelectorAll("button, [role='button']")) - .some((button) => { - if (button.disabled || !isVisible(button) || !bottomArea(button)) { + .some((element) => isVisible(element) && bottomArea(element) && isPendingUploadSurface(element)); + const hasFileName = Array.from(document.querySelectorAll("div, span, p")) + .some((element) => { + if (!isVisible(element) || !bottomArea(element) || !isPendingUploadSurface(element)) { return false; } - const text = [ - button.getAttribute("aria-label"), - button.getAttribute("title"), - button.getAttribute("data-testid"), - button.className, - button.innerText, - button.textContent - ].join(" ").toLowerCase(); - return /send|submit|\u043e\u0442\u043f\u0440\u0430\u0432/.test(text); + const text = String(element.innerText || element.textContent || "").trim(); + return /\.(jpe?g|png|webp|gif|heic|mp4|mov|webm|mp3|ogg|m4a|wav|pdf|docx?|xlsx?|zip|rar|7z)\b/i.test(text); }); - return hasPreview || sendButton; + return hasPreview || hasFileName; }).catch(() => false); if (ready) { @@ -3805,7 +3912,7 @@ async function waitForAttachmentPreview(p, timeoutMs = 8000) { await p.waitForTimeout(250); } - console.warn("[send/attachment] attachment preview was not detected before sending; continuing with send attempt"); + console.warn("[send/attachment] attachment preview was not detected before sending"); return false; }