Compare commits

..

2 Commits

Author SHA1 Message Date
sevenhill 30361370da Fix media notifications and photo bubbles 2026-07-06 13:17:47 +03:00
sevenhill 61d7aae7eb Fix media placeholders and image cache 2026-07-06 09:10:31 +03:00
10 changed files with 352 additions and 33 deletions
@@ -3,6 +3,7 @@ package xyz.kusoft.qmax
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
@@ -157,8 +158,10 @@ import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
@@ -1310,6 +1313,8 @@ private fun cleanChatPreview(value: String?): String? {
private fun isGenericMediaPreview(value: String?): Boolean {
return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e",
"photo",
"image",
@@ -2224,7 +2229,8 @@ private fun MessageRow(
attachments = visualAttachments,
session = session,
vm = vm,
imagePreviewSources = imagePreviewSources
imagePreviewSources = imagePreviewSources,
mediaShape = shape
)
} else {
sortedAttachments.forEach { attachment ->
@@ -2232,7 +2238,8 @@ private fun MessageRow(
attachment = attachment,
session = session,
vm = vm,
imagePreviewSources = imagePreviewSources
imagePreviewSources = imagePreviewSources,
mediaShape = shape
)
}
}
@@ -2512,13 +2519,16 @@ private fun MediaAlbumGrid(
attachments: List<AttachmentDto>,
session: QMaxSession,
vm: QMaxViewModel,
imagePreviewSources: List<String>
imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) {
val visible = attachments.take(4)
val extraCount = (attachments.size - visible.size).coerceAtLeast(0)
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clip(mediaShape),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
when (visible.size) {
@@ -2593,21 +2603,37 @@ private fun MediaAlbumCell(
val state by vm.state
val cachedImagePath = state.cachedImagePaths[attachment.id]
Box(modifier = modifier.background(Color(0xFFE1E8ED))) {
Box(
modifier = modifier
.background(Color(0xFFE1E8ED))
) {
if (attachment.isImageAttachment()) {
LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment)
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken)
val previewSource = cachedImagePath ?: url
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clickable { vm.openImage(previewSource, imagePreviewSources) }
.clickable { vm.openImage(previewSource, imagePreviewSources) },
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
)
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
} else {
Box(
modifier = Modifier
@@ -2648,7 +2674,8 @@ private fun AttachmentView(
attachment: AttachmentDto,
session: QMaxSession,
vm: QMaxViewModel,
imagePreviewSources: List<String>
imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) {
val url = rememberAttachmentUrl(session, attachment)
val state by vm.state
@@ -2668,7 +2695,8 @@ private fun AttachmentView(
session.accessToken,
cachedImagePath,
vm,
imagePreviewSources
imagePreviewSources,
mediaShape
)
attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken)
attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken)
@@ -2704,23 +2732,91 @@ private fun ImageAttachment(
token: String,
cachedImagePath: String?,
vm: QMaxViewModel,
imagePreviewSources: List<String>
imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) {
LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment)
}
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
val previewSource = cachedImagePath ?: url
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Crop,
val aspectRatio = rememberImageAspectRatio(cachedImagePath)
var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.25f)
.aspectRatio(aspectRatio)
.clip(mediaShape)
.background(Color(0xFFE1E8ED))
.clickable { vm.openImage(previewSource, imagePreviewSources) }
)
) {
AsyncImage(
model = model,
contentDescription = attachment.fileName,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
onSuccess = {
imageLoaded = true
imageFailed = false
},
onError = {
imageLoaded = false
imageFailed = true
}
)
if (!imageLoaded) {
MediaImagePlaceholder(failed = imageFailed)
}
}
}
@Composable
private fun rememberImageAspectRatio(cachedImagePath: String?): Float {
var aspectRatio by remember(cachedImagePath) { mutableStateOf(1.25f) }
LaunchedEffect(cachedImagePath) {
val path = cachedImagePath ?: return@LaunchedEffect
val decoded = withContext(Dispatchers.IO) {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(path, options)
if (options.outWidth > 0 && options.outHeight > 0) {
(options.outWidth.toFloat() / options.outHeight.toFloat()).coerceIn(0.58f, 1.9f)
} else {
null
}
}
if (decoded != null) {
aspectRatio = decoded
}
}
return aspectRatio
}
@Composable
private fun MediaImagePlaceholder(failed: Boolean) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE1E8ED)),
contentAlignment = Alignment.Center
) {
if (failed) {
Icon(
Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = QMaxMuted,
modifier = Modifier.size(28.dp)
)
} else {
CircularProgressIndicator(
color = QMaxBlue,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
}
@Composable
@@ -102,10 +102,10 @@ class QMaxRepository(
return messageCache.messages(session, chatId)
}
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> {
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) }
suspend fun messages(session: QMaxSession, chatId: String, sync: Boolean = false): List<MessageDto> {
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId, sync) }
messageCache.saveMessages(session, chatId, messages)
return messages
return messageCache.messages(session, chatId)
}
suspend fun markChatRead(session: QMaxSession, chatId: String) {
@@ -219,7 +219,7 @@ class QMaxRepository(
suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto {
val meta = queryMeta(uri)
val body = contentUriRequestBody(uri, meta.contentType)
return withFreshSession(session) {
val message = withFreshSession(session) {
api.uploadAttachment(
it.serverUrl,
it.accessToken,
@@ -230,7 +230,12 @@ class QMaxRepository(
caption,
replyToMessageId
)
}.also { messageCache.upsertMessage(session, it) }
}
messageCache.upsertMessage(session, message)
message.attachments.firstOrNull()?.let { attachment ->
cacheUploadedAttachment(session, attachment, uri)
}
return message
}
suspend fun uploadVoice(session: QMaxSession, chatId: String, file: File, replyToMessageId: String?): MessageDto {
@@ -526,6 +531,22 @@ class QMaxRepository(
}
}
private suspend fun cacheUploadedAttachment(session: QMaxSession, attachment: AttachmentDto, uri: Uri) {
runCatching {
attachmentCache.storeFile(session, attachment) { target ->
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Cannot open selected file" }
target.outputStream().use { output ->
input.copyTo(output)
}
}
target.length()
}
}.onFailure { error ->
Log.w(RepositoryLogTag, "Cannot prefill attachment cache", error)
}
}
private data class FileMeta(val fileName: String, val contentType: String)
private suspend fun <T> withFreshSession(
@@ -573,6 +594,7 @@ class QMaxRepository(
}
}
private const val RepositoryLogTag = "QMaxRepository"
private const val PushLogTag = "QMaxPush"
private data class SharedAttachment(
@@ -37,6 +37,36 @@ class AttachmentDiskCache(context: Context) {
}
}
suspend fun storeFile(
session: QMaxSession,
attachment: AttachmentDto,
writer: suspend (File) -> Long
): File {
return withContext(Dispatchers.IO) {
val target = targetFile(session, attachment)
target.parentFile?.mkdirs()
val part = File(target.parentFile, target.name + ".part")
if (part.exists()) part.delete()
writer(part)
if (!isValid(part, attachment)) {
val expected = attachment.fileSizeBytes.takeIf { it > 0 }?.toString() ?: "non-empty"
val actual = if (part.exists()) part.length().toString() else "missing"
part.delete()
error("Attachment cache mismatch: expected $expected, got $actual")
}
if (target.exists()) target.delete()
if (!part.renameTo(target)) {
part.delete()
error("Cannot move uploaded file into attachment cache")
}
target
}
}
suspend fun clearAll() {
withContext(Dispatchers.IO) {
root.deleteRecursively()
@@ -145,6 +145,8 @@ class LocalMessageCache(context: Context) {
private fun isGenericMediaLabel(value: String?): Boolean {
return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e",
"photo",
"image",
@@ -79,8 +79,8 @@ class QMaxApi {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
}
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> {
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=false", token)
suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=$sync", token)
}
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) {
@@ -81,6 +81,8 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
private val imageCacheInFlight = mutableSetOf<String>()
private val avatarCacheInFlight = mutableSetOf<String>()
private val messageHydrationInFlight = mutableSetOf<String>()
private val lastMessageHydrationAt = mutableMapOf<String, Long>()
init {
viewModelScope.launch {
@@ -88,6 +90,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val previousSession = state.value.session
val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
previousSession?.userName != session?.userName
if (sessionChanged) {
messageHydrationInFlight.clear()
lastMessageHydrationAt.clear()
}
state.value = state.value.copy(
session = session,
serverUrl = session?.serverUrl ?: state.value.serverUrl,
@@ -330,7 +336,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
realtime.joinChat(chat.id)
}
loadMessages(showLoading = false)
loadMessages(showLoading = false, forceHydrate = true)
presencePollJob?.cancel()
presencePollJob = viewModelScope.launch {
while (state.value.selectedChat?.id == chat.id) {
@@ -456,7 +462,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false)
}
fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) {
fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) {
return@launchLoading
}
@@ -485,6 +491,39 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
messages = fresh
)
persistCurrentChats()
hydrateMessages(chat.id, force = forceHydrate)
}
}
private fun hydrateMessages(chatId: String, force: Boolean = false) {
val session = state.value.session ?: return
val now = System.currentTimeMillis()
val lastHydratedAt = lastMessageHydrationAt[chatId] ?: 0L
if (!force && now - lastHydratedAt < MessageHydrationIntervalMs) {
return
}
if (!messageHydrationInFlight.add(chatId)) {
return
}
lastMessageHydrationAt[chatId] = now
viewModelScope.launch {
try {
val hydrated = withTimeout(MessageSyncTimeoutMs) {
repository.messages(session, chatId, sync = true)
}
if (state.value.selectedChat?.id == chatId) {
state.value = state.value.copy(messages = hydrated)
}
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
if (BuildConfig.DEBUG) {
Log.d(LogTag, "Message hydration failed", error)
}
} finally {
messageHydrationInFlight.remove(chatId)
}
}
}
@@ -501,9 +540,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val replacement = findReplacementChat(staleChat, orderedFresh)
if (replacement == null) {
state.value = state.value.copy(
selectedChat = null,
chats = orderedFresh,
messages = emptyList(),
error = null
)
repository.cacheChats(session, orderedFresh)
@@ -1236,6 +1273,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val LogTag = "QMAX"
const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L
const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L
@@ -106,6 +106,7 @@ public sealed class ChatsController(
}
var messages = (await query.ToArrayAsync(cancellationToken))
.Where(message => !IsGenericPreviewPlaceholder(message))
.OrderByDescending(x => x.SentAt)
.Take(Math.Clamp(take, 1, 200))
.ToArray();
@@ -121,6 +122,11 @@ public sealed class ChatsController(
return;
}
if (MessageTextSanitizer.IsGenericMediaLabel(preview))
{
return;
}
var previewAt = chat.LastMessageAt.Value;
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{
@@ -202,6 +208,14 @@ public sealed class ChatsController(
return hash.ToString("x8");
}
private static bool IsGenericPreviewPlaceholder(Message message)
{
return message.Direction == MessageDirection.Incoming &&
message.Attachments.Count == 0 &&
message.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true &&
MessageTextSanitizer.IsGenericMediaLabel(message.Text);
}
[HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
{
@@ -73,6 +73,7 @@ public sealed class MaxBridgeSyncService(
var changed = 0;
var createdMessages = new List<(Chat Chat, Message Message)>();
var updatedMessages = new List<(Chat Chat, Message Message)>();
var previewNotificationMessages = new List<(Chat Chat, Message Message)>();
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
foreach (var update in updates)
@@ -190,6 +191,10 @@ public sealed class MaxBridgeSyncService(
createdMessages.Add((chat, previewResult.Message));
}
}
if (previewResult.NotificationMessage is not null)
{
previewNotificationMessages.Add((chat, previewResult.NotificationMessage));
}
if (previewResult.Changed)
{
@@ -350,6 +355,14 @@ public sealed class MaxBridgeSyncService(
}
}
foreach (var (chat, message) in previewNotificationMessages)
{
if (sendPushNotifications && message.Direction == MessageDirection.Incoming)
{
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
}
}
foreach (var (chat, message) in updatedMessages)
{
var updated = await db.Messages
@@ -379,12 +392,12 @@ public sealed class MaxBridgeSyncService(
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview))
{
return new ListPreviewApplyResult(false, null);
return new ListPreviewApplyResult(false, null, null);
}
if (IsPresencePreview(preview))
{
return new ListPreviewApplyResult(false, null);
return new ListPreviewApplyResult(false, null, null);
}
var previousPreview = chat.LastMessagePreview;
@@ -392,7 +405,7 @@ public sealed class MaxBridgeSyncService(
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{
return new ListPreviewApplyResult(false, null);
return new ListPreviewApplyResult(false, null, null);
}
var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
@@ -400,7 +413,7 @@ public sealed class MaxBridgeSyncService(
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
if (samePreview && samePreviewAt)
{
return new ListPreviewApplyResult(false, null);
return new ListPreviewApplyResult(false, null, null);
}
chat.LastMessagePreview = preview;
@@ -422,10 +435,13 @@ public sealed class MaxBridgeSyncService(
chat.UnreadCount++;
}
var message = shouldNotify
var message = shouldNotify && !MessageTextSanitizer.IsGenericMediaLabel(preview)
? CreatePreviewMessage(chat, preview, previewAt)
: null;
return new ListPreviewApplyResult(true, message);
var notificationMessage = shouldNotify && message is null && MessageTextSanitizer.IsGenericMediaLabel(preview)
? CreatePreviewMessage(chat, preview, previewAt)
: null;
return new ListPreviewApplyResult(true, message, notificationMessage);
}
private static bool IsKnownOutgoingPreviewEcho(
@@ -604,7 +620,7 @@ public sealed class MaxBridgeSyncService(
text.Contains("file", StringComparison.Ordinal);
}
private sealed record ListPreviewApplyResult(bool Changed, Message? Message);
private sealed record ListPreviewApplyResult(bool Changed, Message? Message, Message? NotificationMessage);
private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
@@ -35,6 +35,8 @@ public static class MessageTextSanitizer
{
return value?.Trim().ToLowerInvariant() switch
{
"\u043c\u0435\u0434\u0438\u0430" => true,
"media" => true,
"\u0444\u043e\u0442\u043e" => true,
"photo" => true,
"image" => true,
+99
View File
@@ -773,6 +773,40 @@ public sealed class ApiSmokeTests : IDisposable
Assert.StartsWith("preview:", backfilled.ExternalId);
}
[Fact]
public async Task GetMessagesDoesNotBackfillGenericMediaPreviewWhenAttachmentRowIsMissing()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var marker = Guid.NewGuid().ToString("N");
var chat = await CreateDirectChatAsync(client, $"generic-media-preview-chat-{marker}", "Generic Media Preview");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.LastMessagePreview = "\u0424\u043e\u0442\u043e";
stored.LastMessageAt = DateTimeOffset.UtcNow;
stored.UnreadCount = 1;
await db.SaveChangesAsync();
}
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.NotNull(messages);
Assert.DoesNotContain(messages!, x => x.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
Assert.False(await verifyDb.Messages.AnyAsync(x => x.ChatId == chat.Id));
}
[Fact]
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate()
{
@@ -836,6 +870,71 @@ public sealed class ApiSmokeTests : IDisposable
}
}
[Fact]
public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate()
{
var pushNotifications = new RecordingPushNotificationService();
var marker = Guid.NewGuid().ToString("N");
var externalId = $"generic-media-sync-chat-{marker}";
var now = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
externalId,
"Generic Media Sync",
null,
now,
[],
"\u0424\u043e\u0442\u043e",
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<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, externalId, "Generic Media Sync");
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
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 verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await verifyDb.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", storedChat.LastMessagePreview);
Assert.False(await verifyDb.Messages.AnyAsync(x =>
x.ChatId == chat.Id &&
x.ExternalId != null &&
x.ExternalId.StartsWith("preview:")));
var previewPush = Assert.Single(pushNotifications.SentMessages);
Assert.Equal(chat.Id, previewPush.Chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", previewPush.Message.Text);
Assert.Equal(MessageDirection.Incoming, previewPush.Message.Direction);
}
[Fact]
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
{