Suppress pushes for outgoing preview echoes
This commit is contained in:
@@ -127,13 +127,36 @@ public sealed class MaxBridgeSyncService(
|
|||||||
changed++;
|
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<LastKnownMessageSnapshot> 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(
|
if (ApplyListPreview(
|
||||||
update,
|
update,
|
||||||
chat,
|
chat,
|
||||||
chatWasCreated,
|
chatWasCreated,
|
||||||
incrementUnread,
|
incrementUnread,
|
||||||
sendPushNotifications,
|
sendPushNotifications,
|
||||||
previewPushNotifications))
|
previewPushNotifications,
|
||||||
|
lastKnownMessage))
|
||||||
{
|
{
|
||||||
changed++;
|
changed++;
|
||||||
}
|
}
|
||||||
@@ -255,7 +278,8 @@ public sealed class MaxBridgeSyncService(
|
|||||||
bool chatWasCreated,
|
bool chatWasCreated,
|
||||||
bool incrementUnread,
|
bool incrementUnread,
|
||||||
bool sendPushNotifications,
|
bool sendPushNotifications,
|
||||||
ICollection<(Chat Chat, Message Message)> previewPushNotifications)
|
ICollection<(Chat Chat, Message Message)> previewPushNotifications,
|
||||||
|
LastKnownMessageSnapshot? lastKnownMessage)
|
||||||
{
|
{
|
||||||
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
|
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
|
||||||
if (string.IsNullOrWhiteSpace(preview))
|
if (string.IsNullOrWhiteSpace(preview))
|
||||||
@@ -283,8 +307,10 @@ public sealed class MaxBridgeSyncService(
|
|||||||
chat.LastMessageAt = previewAt;
|
chat.LastMessageAt = previewAt;
|
||||||
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
chat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt);
|
||||||
var shouldNotifyCandidate = !chatWasCreated &&
|
var shouldNotifyCandidate = !chatWasCreated &&
|
||||||
!string.IsNullOrWhiteSpace(previousPreview) &&
|
!string.IsNullOrWhiteSpace(previousPreview) &&
|
||||||
|
!echoesKnownOutgoing &&
|
||||||
update.LastMessageIsOutgoing != true &&
|
update.LastMessageIsOutgoing != true &&
|
||||||
update.Messages.Count == 0 &&
|
update.Messages.Count == 0 &&
|
||||||
IsFreshListPreview(update, previewAt);
|
IsFreshListPreview(update, previewAt);
|
||||||
@@ -317,6 +343,31 @@ public sealed class MaxBridgeSyncService(
|
|||||||
return true;
|
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)
|
private static bool RememberPreviewNotification(Guid chatId, string preview, DateTimeOffset previewAt)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
@@ -755,4 +806,9 @@ public sealed class MaxBridgeSyncService(
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record LastKnownMessageSnapshot(
|
||||||
|
MessageDirection Direction,
|
||||||
|
string? Text,
|
||||||
|
DateTimeOffset SentAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -604,6 +604,68 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????"));
|
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<IHostedService>();
|
||||||
|
services.RemoveAll<IMaxBridgeClient>();
|
||||||
|
services.RemoveAll<IPushNotificationService>();
|
||||||
|
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||||
|
services.AddSingleton<IPushNotificationService>(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<QMaxDbContext>();
|
||||||
|
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<QMaxDbContext>();
|
||||||
|
var chat = await db.Chats.FindAsync(chatId);
|
||||||
|
Assert.NotNull(chat);
|
||||||
|
Assert.Equal(0, chat!.UnreadCount);
|
||||||
|
Assert.Equal(bridge.PreviewText, chat.LastMessagePreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FirebasePushSkipsOutgoingMessages()
|
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<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return GetStatusAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return GetStatusAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(
|
||||||
|
[
|
||||||
|
new MaxChatUpdate(
|
||||||
|
ChatExternalId,
|
||||||
|
"Self Echo",
|
||||||
|
null,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
[],
|
||||||
|
PreviewText,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<MaxChatUpdate?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<MaxChatPresence?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxActionResult(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxActionResult(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxActionResult(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxActionResult(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<MaxMediaDownload?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient
|
private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient
|
||||||
{
|
{
|
||||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
Reference in New Issue
Block a user