diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 1c82967..375a4af 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -127,13 +127,36 @@ public sealed class MaxBridgeSyncService( changed++; } + 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 + ? [] + : await db.Messages + .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)) + .ToListAsync(cancellationToken); + var lastKnownMessage = lastKnownMessages + .OrderByDescending(x => x.SentAt) + .FirstOrDefault(); + if (ApplyListPreview( update, chat, chatWasCreated, incrementUnread, sendPushNotifications, - previewPushNotifications)) + previewPushNotifications, + lastKnownMessage)) { changed++; } @@ -255,7 +278,8 @@ public sealed class MaxBridgeSyncService( bool chatWasCreated, bool incrementUnread, bool sendPushNotifications, - ICollection<(Chat Chat, Message Message)> previewPushNotifications) + ICollection<(Chat Chat, Message Message)> previewPushNotifications, + LastKnownMessageSnapshot? lastKnownMessage) { var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); if (string.IsNullOrWhiteSpace(preview)) @@ -283,8 +307,10 @@ public sealed class MaxBridgeSyncService( chat.LastMessageAt = previewAt; chat.UpdatedAt = DateTimeOffset.UtcNow; + var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt); var shouldNotifyCandidate = !chatWasCreated && !string.IsNullOrWhiteSpace(previousPreview) && + !echoesKnownOutgoing && update.LastMessageIsOutgoing != true && update.Messages.Count == 0 && IsFreshListPreview(update, previewAt); @@ -317,6 +343,31 @@ public sealed class MaxBridgeSyncService( return true; } + private static bool IsKnownOutgoingPreviewEcho( + LastKnownMessageSnapshot? lastKnownMessage, + string preview, + DateTimeOffset previewAt) + { + if (lastKnownMessage is null || + lastKnownMessage.Direction != MessageDirection.Outgoing || + string.IsNullOrWhiteSpace(lastKnownMessage.Text)) + { + return false; + } + + var lastText = MessageTextSanitizer.CleanChatPreview(lastKnownMessage.Text); + if (!string.Equals(lastText, preview, StringComparison.Ordinal)) + { + return false; + } + + var sentAt = lastKnownMessage.SentAt.ToUniversalTime(); + var previewUtc = previewAt.ToUniversalTime(); + var now = DateTimeOffset.UtcNow; + return Math.Abs((sentAt - previewUtc).TotalMinutes) <= 30 || + sentAt >= now.AddMinutes(-30); + } + private static bool RememberPreviewNotification(Guid chatId, string preview, DateTimeOffset previewAt) { var now = DateTimeOffset.UtcNow; @@ -755,4 +806,9 @@ public sealed class MaxBridgeSyncService( _ => null }; } + + private sealed record LastKnownMessageSnapshot( + MessageDirection Direction, + string? Text, + DateTimeOffset SentAt); } diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 18d6a3e..69af4fa 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -604,6 +604,68 @@ public sealed class ApiSmokeTests : IDisposable Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????")); } + [Fact] + public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingMessage() + { + var pushNotifications = new RecordingPushNotificationService(); + var bridge = new OutgoingPreviewEchoMaxBridgeClient( + $"mock-self-echo-chat-{Guid.NewGuid():N}", + "message from me"); + 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 Echo", + LastMessagePreview = bridge.PreviewText, + LastMessageAt = sentAt, + UnreadCount = 0 + }; + db.Chats.Add(chat); + db.Messages.Add(new Message + { + Chat = chat, + ExternalId = "self-echo-message", + Direction = MessageDirection.Outgoing, + Text = bridge.PreviewText, + SentAt = sentAt, + DeliveryState = MessageDeliveryState.Sent + }); + 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(bridge.PreviewText, chat.LastMessagePreview); + } + } + [Fact] public async Task FirebasePushSkipsOutgoingMessages() { @@ -749,6 +811,111 @@ public sealed class ApiSmokeTests : IDisposable } } + private sealed class RecordingPushNotificationService : IPushNotificationService + { + public List<(Chat Chat, Message Message)> SentMessages { get; } = []; + + public Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken) + { + SentMessages.Add((chat, message)); + return Task.CompletedTask; + } + } + + private sealed class OutgoingPreviewEchoMaxBridgeClient : IMaxBridgeClient + { + public OutgoingPreviewEchoMaxBridgeClient(string chatExternalId, string previewText) + { + ChatExternalId = chatExternalId; + PreviewText = previewText; + } + + public string ChatExternalId { get; } + public string PreviewText { get; } + + public Task GetStatusAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); + } + + public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) + { + return GetStatusAsync(cancellationToken); + } + + public Task GetSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); + } + + public Task> FetchUpdatesAsync(CancellationToken cancellationToken) + { + return Task.FromResult>( + [ + new MaxChatUpdate( + ChatExternalId, + "Self Echo", + null, + DateTimeOffset.UtcNow, + [], + PreviewText, + null, + null, + DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm")) + ]); + } + + public Task FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); + } + + public Task SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null)); + } + + public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + } + private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient { public Task GetStatusAsync(CancellationToken cancellationToken)