diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index 19032aa..06950aa 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -83,6 +83,11 @@ public sealed class ChatsController( } } + if (before is null) + { + await EnsureLastPreviewMessageAsync(chat, cancellationToken); + } + if (before is null && chat.UnreadCount > 0) { chat.UnreadCount = 0; @@ -108,6 +113,95 @@ public sealed class ChatsController( return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray(); } + private async Task EnsureLastPreviewMessageAsync(Chat chat, CancellationToken cancellationToken) + { + var preview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview); + if (string.IsNullOrWhiteSpace(preview) || chat.LastMessageAt is null) + { + return; + } + + var previewAt = chat.LastMessageAt.Value; + if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) + { + return; + } + + var latestMessage = (await db.Messages + .AsNoTracking() + .Where(x => x.ChatId == chat.Id && x.DeletedAt == null) + .ToArrayAsync(cancellationToken)) + .OrderByDescending(x => x.SentAt) + .FirstOrDefault(); + if (latestMessage is not null && latestMessage.SentAt >= previewAt.AddSeconds(-1)) + { + return; + } + + var externalId = PreviewExternalId(chat.Id, preview, previewAt); + if (await HasPreviewPlaceholderAsync(chat.Id, externalId, preview, previewAt, cancellationToken)) + { + return; + } + + db.Messages.Add(new Message + { + ChatId = chat.Id, + ExternalId = externalId, + Direction = MessageDirection.Incoming, + Text = preview, + SentAt = previewAt, + DeliveryState = MessageDeliveryState.Delivered, + SenderExternalId = chat.ExternalId, + SenderName = chat.Title + }); + await db.SaveChangesAsync(cancellationToken); + } + + private async Task HasPreviewPlaceholderAsync( + Guid chatId, + string externalId, + string preview, + DateTimeOffset previewAt, + CancellationToken cancellationToken) + { + var previewMessages = await db.Messages + .AsNoTracking() + .Where(x => + x.ChatId == chatId && + x.DeletedAt == null && + x.Direction == MessageDirection.Incoming && + x.ExternalId != null && + (x.ExternalId == externalId || x.ExternalId.StartsWith("preview:"))) + .ToArrayAsync(cancellationToken); + + return previewMessages.Any(message => + string.Equals(message.ExternalId, externalId, StringComparison.Ordinal) || + string.Equals( + MessageTextSanitizer.CleanMessageText(message.Text, false), + preview, + StringComparison.Ordinal) && + Math.Abs((message.SentAt.ToUniversalTime() - previewAt.ToUniversalTime()).TotalMinutes) <= 30); + } + + private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt) + { + var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute; + return $"preview:{chatId:N}:{previewMinute}:{StableHash(preview.Trim().ToLowerInvariant())}"; + } + + private static string StableHash(string value) + { + var hash = 2166136261u; + foreach (var character in value) + { + hash ^= character; + hash *= 16777619; + } + + return hash.ToString("x8"); + } + [HttpPost("{chatId:guid}/read")] public async Task MarkRead(Guid chatId, CancellationToken cancellationToken) { diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 8dd06a6..18ff34e 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -16,6 +16,7 @@ public sealed class MaxBridgeSyncService( IHubContext hubContext, ILogger logger) { + private const string PreviewExternalIdPrefix = "preview:"; private static readonly ConcurrentDictionary PreviewNotifications = new(); public async Task SyncOnceAsync(CancellationToken cancellationToken) @@ -72,7 +73,6 @@ public sealed class MaxBridgeSyncService( var changed = 0; var createdMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>(); - var previewPushNotifications = new List<(Chat Chat, Message Message)>(); var trackedChatsByExternalId = new Dictionary(StringComparer.Ordinal); foreach (var update in updates) @@ -176,14 +176,22 @@ public sealed class MaxBridgeSyncService( x.SentAt)) .FirstOrDefault(); - if (ApplyListPreview( + var previewResult = ApplyListPreview( update, chat, chatWasCreated, incrementUnread, - sendPushNotifications, - previewPushNotifications, - lastKnownMessage)) + lastKnownMessage); + if (previewResult.Message is not null) + { + if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken)) + { + db.Messages.Add(previewResult.Message); + createdMessages.Add((chat, previewResult.Message)); + } + } + + if (previewResult.Changed) { changed++; } @@ -266,6 +274,37 @@ public sealed class MaxBridgeSyncService( continue; } + var previewPlaceholder = await FindPreviewPlaceholderAsync(db, chat.Id, incoming, cancellationToken); + if (previewPlaceholder is not null) + { + var merge = await MergeExistingMessageAsync( + db, + previewPlaceholder, + incoming, + maxBridgeClient, + storage, + cancellationToken); + if (!string.Equals(previewPlaceholder.ExternalId, incoming.ExternalId, StringComparison.Ordinal)) + { + previewPlaceholder.ExternalId = incoming.ExternalId; + merge = merge with { Changed = true }; + } + + if (merge.Changed) + { + updatedMessages.Add((chat, previewPlaceholder)); + if (chat.LastMessageAt is null || previewPlaceholder.SentAt >= chat.LastMessageAt) + { + chat.LastMessagePreview = LastMessagePreview(previewPlaceholder.Text, previewPlaceholder.Attachments.Concat(merge.AddedAttachments)); + chat.LastMessageAt = previewPlaceholder.SentAt; + } + + changed++; + } + + continue; + } + var message = new Message { Chat = chat, @@ -311,11 +350,6 @@ public sealed class MaxBridgeSyncService( } } - foreach (var (chat, message) in previewPushNotifications) - { - await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); - } - foreach (var (chat, message) in updatedMessages) { var updated = await db.Messages @@ -335,24 +369,22 @@ public sealed class MaxBridgeSyncService( return changed; } - private static bool ApplyListPreview( + private static ListPreviewApplyResult ApplyListPreview( MaxChatUpdate update, Chat chat, bool chatWasCreated, bool incrementUnread, - bool sendPushNotifications, - ICollection<(Chat Chat, Message Message)> previewPushNotifications, LastKnownMessageSnapshot? lastKnownMessage) { var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); if (string.IsNullOrWhiteSpace(preview)) { - return false; + return new ListPreviewApplyResult(false, null); } if (IsPresencePreview(preview)) { - return false; + return new ListPreviewApplyResult(false, null); } var previousPreview = chat.LastMessagePreview; @@ -360,7 +392,7 @@ public sealed class MaxBridgeSyncService( var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt); if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) { - return false; + return new ListPreviewApplyResult(false, null); } var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal); @@ -368,7 +400,7 @@ public sealed class MaxBridgeSyncService( Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1; if (samePreview && samePreviewAt) { - return false; + return new ListPreviewApplyResult(false, null); } chat.LastMessagePreview = preview; @@ -390,25 +422,10 @@ public sealed class MaxBridgeSyncService( chat.UnreadCount++; } - if (sendPushNotifications && shouldNotify) - { - previewPushNotifications.Add(( - chat, - new Message - { - Id = Guid.NewGuid(), - ChatId = chat.Id, - Chat = chat, - Direction = MessageDirection.Incoming, - Text = preview, - SentAt = previewAt, - DeliveryState = MessageDeliveryState.Delivered, - SenderExternalId = chat.ExternalId, - SenderName = chat.Title - })); - } - - return true; + var message = shouldNotify + ? CreatePreviewMessage(chat, preview, previewAt) + : null; + return new ListPreviewApplyResult(true, message); } private static bool IsKnownOutgoingPreviewEcho( @@ -587,8 +604,111 @@ public sealed class MaxBridgeSyncService( text.Contains("file", StringComparison.Ordinal); } + private sealed record ListPreviewApplyResult(bool Changed, Message? Message); + private sealed record MessageMergeResult(bool Changed, IReadOnlyList AddedAttachments); + private static Message CreatePreviewMessage(Chat chat, string preview, DateTimeOffset previewAt) + { + return new Message + { + ChatId = chat.Id, + Chat = chat, + ExternalId = PreviewExternalId(chat.Id, preview, previewAt), + Direction = MessageDirection.Incoming, + Text = preview, + SentAt = previewAt, + DeliveryState = MessageDeliveryState.Delivered, + SenderExternalId = chat.ExternalId, + SenderName = chat.Title + }; + } + + private static async Task FindPreviewPlaceholderAsync( + QMaxDbContext db, + Guid chatId, + MaxMessageUpdate incoming, + CancellationToken cancellationToken) + { + if (incoming.IsOutgoing) + { + return null; + } + + var incomingAttachments = DistinctAttachments(incoming.Attachments ?? []); + var incomingText = MessageTextSanitizer.CleanMessageText( + incoming.Text, + incomingAttachments.Count > 0); + if (string.IsNullOrWhiteSpace(incomingText)) + { + return null; + } + + var candidates = await db.Messages + .Include(x => x.Attachments) + .Where(x => + x.ChatId == chatId && + x.DeletedAt == null && + x.Direction == MessageDirection.Incoming && + x.ExternalId != null && + x.ExternalId.StartsWith(PreviewExternalIdPrefix)) + .ToListAsync(cancellationToken); + + return candidates + .Where(candidate => candidate.Attachments.Count == 0) + .Where(candidate => string.Equals( + MessageTextSanitizer.CleanMessageText(candidate.Text, false), + incomingText, + StringComparison.Ordinal)) + .Where(candidate => Math.Abs((candidate.SentAt.ToUniversalTime() - incoming.SentAt.ToUniversalTime()).TotalMinutes) <= 30) + .OrderBy(candidate => Math.Abs((candidate.SentAt.ToUniversalTime() - incoming.SentAt.ToUniversalTime()).TotalSeconds)) + .FirstOrDefault(); + } + + private static async Task HasPreviewPlaceholderAsync( + QMaxDbContext db, + Guid chatId, + Message previewMessage, + CancellationToken cancellationToken) + { + var previewText = MessageTextSanitizer.CleanMessageText(previewMessage.Text, false); + var candidates = await db.Messages + .AsNoTracking() + .Where(x => + x.ChatId == chatId && + x.DeletedAt == null && + x.Direction == MessageDirection.Incoming && + x.ExternalId != null && + x.ExternalId.StartsWith(PreviewExternalIdPrefix)) + .ToArrayAsync(cancellationToken); + + return candidates.Any(candidate => + string.Equals(candidate.ExternalId, previewMessage.ExternalId, StringComparison.Ordinal) || + string.Equals( + MessageTextSanitizer.CleanMessageText(candidate.Text, false), + previewText, + StringComparison.Ordinal) && + Math.Abs((candidate.SentAt.ToUniversalTime() - previewMessage.SentAt.ToUniversalTime()).TotalMinutes) <= 30); + } + + private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt) + { + var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute; + return $"{PreviewExternalIdPrefix}{chatId:N}:{previewMinute}:{StableHash(preview.Trim().ToLowerInvariant())}"; + } + + private static string StableHash(string value) + { + var hash = 2166136261u; + foreach (var character in value) + { + hash ^= character; + hash *= 16777619; + } + + return hash.ToString("x8"); + } + private static async Task FindPendingOutgoingEchoAsync( QMaxDbContext db, Guid chatId, diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 3b7eb42..c2e4233 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -739,6 +739,103 @@ public sealed class ApiSmokeTests : IDisposable Assert.Equal(1, bridge.HistoryFetchCount); } + [Fact] + public async Task GetMessagesBackfillsLastPreviewWhenMessageRowIsMissing() + { + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var marker = Guid.NewGuid().ToString("N"); + var previewText = $"message only in preview {marker}"; + var chat = await CreateDirectChatAsync(client, $"preview-backfill-chat-{marker}", "Preview Backfill"); + var previewAt = DateTimeOffset.UtcNow; + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + stored.LastMessagePreview = previewText; + stored.LastMessageAt = previewAt; + stored.UnreadCount = 1; + await db.SaveChangesAsync(); + } + + var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + + var backfilled = Assert.Single(messages!, x => x.Text == previewText); + Assert.Equal(MessageDirection.Incoming, backfilled.Direction); + Assert.StartsWith("preview:", backfilled.ExternalId); + } + + [Fact] + public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate() + { + var marker = Guid.NewGuid().ToString("N"); + var externalId = $"preview-sync-chat-{marker}"; + var previewText = $"fresh preview message {marker}"; + var now = DateTimeOffset.UtcNow; + var bridge = new ResolvingDeleteMaxBridgeClient + { + Updates = + [ + new MaxChatUpdate( + externalId, + "Preview Sync", + null, + now, + [], + previewText, + false, + now, + now.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"), + $"https://max.test/chat/{externalId}") + ] + }; + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, externalId, "Preview Sync"); + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + stored.LastMessagePreview = "previous preview"; + stored.LastMessageAt = now.AddMinutes(-5); + await db.SaveChangesAsync(); + } + + var sync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, sync); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var message = await db.Messages.SingleAsync(x => + x.ChatId == chat.Id && + x.Text == previewText && + x.ExternalId != null && + x.ExternalId.StartsWith("preview:")); + Assert.Equal(previewText, message.Text); + Assert.Equal(MessageDirection.Incoming, message.Direction); + Assert.StartsWith("preview:", message.ExternalId); + } + } + [Fact] public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure() {