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.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaRecorder import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -157,8 +158,10 @@ import java.time.OffsetDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatDto
@@ -1310,6 +1313,8 @@ private fun cleanChatPreview(value: String?): String? {
private fun isGenericMediaPreview(value: String?): Boolean { private fun isGenericMediaPreview(value: String?): Boolean {
return when (value?.trim()?.lowercase()) { return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e", "\u0444\u043e\u0442\u043e",
"photo", "photo",
"image", "image",
@@ -2224,7 +2229,8 @@ private fun MessageRow(
attachments = visualAttachments, attachments = visualAttachments,
session = session, session = session,
vm = vm, vm = vm,
imagePreviewSources = imagePreviewSources imagePreviewSources = imagePreviewSources,
mediaShape = shape
) )
} else { } else {
sortedAttachments.forEach { attachment -> sortedAttachments.forEach { attachment ->
@@ -2232,7 +2238,8 @@ private fun MessageRow(
attachment = attachment, attachment = attachment,
session = session, session = session,
vm = vm, vm = vm,
imagePreviewSources = imagePreviewSources imagePreviewSources = imagePreviewSources,
mediaShape = shape
) )
} }
} }
@@ -2512,13 +2519,16 @@ private fun MediaAlbumGrid(
attachments: List<AttachmentDto>, attachments: List<AttachmentDto>,
session: QMaxSession, session: QMaxSession,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
val visible = attachments.take(4) val visible = attachments.take(4)
val extraCount = (attachments.size - visible.size).coerceAtLeast(0) val extraCount = (attachments.size - visible.size).coerceAtLeast(0)
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clip(mediaShape),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
when (visible.size) { when (visible.size) {
@@ -2593,21 +2603,37 @@ private fun MediaAlbumCell(
val state by vm.state val state by vm.state
val cachedImagePath = state.cachedImagePaths[attachment.id] val cachedImagePath = state.cachedImagePaths[attachment.id]
Box(modifier = modifier.background(Color(0xFFE1E8ED))) { Box(
modifier = modifier
.background(Color(0xFFE1E8ED))
) {
if (attachment.isImageAttachment()) { if (attachment.isImageAttachment()) {
LaunchedEffect(attachment.id, url) { LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment) vm.cacheImage(attachment)
} }
val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken) val model = cachedImagePath?.let(::File) ?: imageRequest(url, session.accessToken)
val previewSource = cachedImagePath ?: url 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( AsyncImage(
model = model, model = model,
contentDescription = attachment.fileName, contentDescription = attachment.fileName,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.fillMaxSize() .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 { } else {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -2648,7 +2674,8 @@ private fun AttachmentView(
attachment: AttachmentDto, attachment: AttachmentDto,
session: QMaxSession, session: QMaxSession,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
val url = rememberAttachmentUrl(session, attachment) val url = rememberAttachmentUrl(session, attachment)
val state by vm.state val state by vm.state
@@ -2668,7 +2695,8 @@ private fun AttachmentView(
session.accessToken, session.accessToken,
cachedImagePath, cachedImagePath,
vm, vm,
imagePreviewSources imagePreviewSources,
mediaShape
) )
attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken) attachment.isVideoAttachment() -> VideoAttachment(attachment, url, session.accessToken)
attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken) attachment.isVoiceAttachment() || attachment.isAudioAttachment() -> VoiceAttachment(attachment, url, session.accessToken)
@@ -2704,23 +2732,91 @@ private fun ImageAttachment(
token: String, token: String,
cachedImagePath: String?, cachedImagePath: String?,
vm: QMaxViewModel, vm: QMaxViewModel,
imagePreviewSources: List<String> imagePreviewSources: List<String>,
mediaShape: RoundedCornerShape
) { ) {
LaunchedEffect(attachment.id, url) { LaunchedEffect(attachment.id, url) {
vm.cacheImage(attachment) vm.cacheImage(attachment)
} }
val model = cachedImagePath?.let(::File) ?: imageRequest(url, token) val model = cachedImagePath?.let(::File) ?: imageRequest(url, token)
val previewSource = cachedImagePath ?: url val previewSource = cachedImagePath ?: url
AsyncImage( val aspectRatio = rememberImageAspectRatio(cachedImagePath)
model = model, var imageLoaded by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
contentDescription = attachment.fileName, var imageFailed by remember(attachment.id, cachedImagePath, url) { mutableStateOf(false) }
contentScale = ContentScale.Crop, Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1.25f) .aspectRatio(aspectRatio)
.clip(mediaShape)
.background(Color(0xFFE1E8ED)) .background(Color(0xFFE1E8ED))
.clickable { vm.openImage(previewSource, imagePreviewSources) } .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 @Composable
@@ -102,10 +102,10 @@ class QMaxRepository(
return messageCache.messages(session, chatId) return messageCache.messages(session, chatId)
} }
suspend fun messages(session: QMaxSession, chatId: String): List<MessageDto> { suspend fun messages(session: QMaxSession, chatId: String, sync: Boolean = false): List<MessageDto> {
val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId) } val messages = withFreshSession(session) { api.messages(it.serverUrl, it.accessToken, chatId, sync) }
messageCache.saveMessages(session, chatId, messages) messageCache.saveMessages(session, chatId, messages)
return messages return messageCache.messages(session, chatId)
} }
suspend fun markChatRead(session: QMaxSession, chatId: String) { 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 { suspend fun upload(session: QMaxSession, chatId: String, uri: Uri, caption: String?, replyToMessageId: String?): MessageDto {
val meta = queryMeta(uri) val meta = queryMeta(uri)
val body = contentUriRequestBody(uri, meta.contentType) val body = contentUriRequestBody(uri, meta.contentType)
return withFreshSession(session) { val message = withFreshSession(session) {
api.uploadAttachment( api.uploadAttachment(
it.serverUrl, it.serverUrl,
it.accessToken, it.accessToken,
@@ -230,7 +230,12 @@ class QMaxRepository(
caption, caption,
replyToMessageId 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 { 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 data class FileMeta(val fileName: String, val contentType: String)
private suspend fun <T> withFreshSession( private suspend fun <T> withFreshSession(
@@ -573,6 +594,7 @@ class QMaxRepository(
} }
} }
private const val RepositoryLogTag = "QMaxRepository"
private const val PushLogTag = "QMaxPush" private const val PushLogTag = "QMaxPush"
private data class SharedAttachment( 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() { suspend fun clearAll() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
root.deleteRecursively() root.deleteRecursively()
@@ -145,6 +145,8 @@ class LocalMessageCache(context: Context) {
private fun isGenericMediaLabel(value: String?): Boolean { private fun isGenericMediaLabel(value: String?): Boolean {
return when (value?.trim()?.lowercase()) { return when (value?.trim()?.lowercase()) {
"\u043c\u0435\u0434\u0438\u0430",
"media",
"\u0444\u043e\u0442\u043e", "\u0444\u043e\u0442\u043e",
"photo", "photo",
"image", "image",
@@ -79,8 +79,8 @@ class QMaxApi {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title)) return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
} }
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> { suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=false", token) return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=$sync", token)
} }
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) { 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 locallyReadChatFingerprints = mutableMapOf<String, String>()
private val imageCacheInFlight = mutableSetOf<String>() private val imageCacheInFlight = mutableSetOf<String>()
private val avatarCacheInFlight = mutableSetOf<String>() private val avatarCacheInFlight = mutableSetOf<String>()
private val messageHydrationInFlight = mutableSetOf<String>()
private val lastMessageHydrationAt = mutableMapOf<String, Long>()
init { init {
viewModelScope.launch { viewModelScope.launch {
@@ -88,6 +90,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val previousSession = state.value.session val previousSession = state.value.session
val sessionChanged = previousSession?.serverUrl != session?.serverUrl || val sessionChanged = previousSession?.serverUrl != session?.serverUrl ||
previousSession?.userName != session?.userName previousSession?.userName != session?.userName
if (sessionChanged) {
messageHydrationInFlight.clear()
lastMessageHydrationAt.clear()
}
state.value = state.value.copy( state.value = state.value.copy(
session = session, session = session,
serverUrl = session?.serverUrl ?: state.value.serverUrl, serverUrl = session?.serverUrl ?: state.value.serverUrl,
@@ -330,7 +336,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
} }
realtime.joinChat(chat.id) realtime.joinChat(chat.id)
} }
loadMessages(showLoading = false) loadMessages(showLoading = false, forceHydrate = true)
presencePollJob?.cancel() presencePollJob?.cancel()
presencePollJob = viewModelScope.launch { presencePollJob = viewModelScope.launch {
while (state.value.selectedChat?.id == chat.id) { while (state.value.selectedChat?.id == chat.id) {
@@ -456,7 +462,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false) 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) { if (!showLoading && state.value.sendingMessage) {
return@launchLoading return@launchLoading
} }
@@ -485,6 +491,39 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
messages = fresh messages = fresh
) )
persistCurrentChats() 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) val replacement = findReplacementChat(staleChat, orderedFresh)
if (replacement == null) { if (replacement == null) {
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = null,
chats = orderedFresh, chats = orderedFresh,
messages = emptyList(),
error = null error = null
) )
repository.cacheChats(session, orderedFresh) repository.cacheChats(session, orderedFresh)
@@ -1236,6 +1273,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
const val LogTag = "QMAX" const val LogTag = "QMAX"
const val ChatListTimeoutMs = 45_000L const val ChatListTimeoutMs = 45_000L
const val MessageSyncTimeoutMs = 120_000L const val MessageSyncTimeoutMs = 120_000L
const val MessageHydrationIntervalMs = 120_000L
const val AutoLoginDelayMs = 1_000L const val AutoLoginDelayMs = 1_000L
const val PushRegistrationAttempts = 6 const val PushRegistrationAttempts = 6
const val InitialPushRegistrationRetryMs = 15_000L const val InitialPushRegistrationRetryMs = 15_000L
@@ -106,6 +106,7 @@ public sealed class ChatsController(
} }
var messages = (await query.ToArrayAsync(cancellationToken)) var messages = (await query.ToArrayAsync(cancellationToken))
.Where(message => !IsGenericPreviewPlaceholder(message))
.OrderByDescending(x => x.SentAt) .OrderByDescending(x => x.SentAt)
.Take(Math.Clamp(take, 1, 200)) .Take(Math.Clamp(take, 1, 200))
.ToArray(); .ToArray();
@@ -121,6 +122,11 @@ public sealed class ChatsController(
return; return;
} }
if (MessageTextSanitizer.IsGenericMediaLabel(preview))
{
return;
}
var previewAt = chat.LastMessageAt.Value; var previewAt = chat.LastMessageAt.Value;
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
{ {
@@ -202,6 +208,14 @@ public sealed class ChatsController(
return hash.ToString("x8"); 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")] [HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken) public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
{ {
@@ -73,6 +73,7 @@ public sealed class MaxBridgeSyncService(
var changed = 0; var changed = 0;
var createdMessages = new List<(Chat Chat, Message Message)>(); var createdMessages = new List<(Chat Chat, Message Message)>();
var updatedMessages = 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); var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
foreach (var update in updates) foreach (var update in updates)
@@ -190,6 +191,10 @@ public sealed class MaxBridgeSyncService(
createdMessages.Add((chat, previewResult.Message)); createdMessages.Add((chat, previewResult.Message));
} }
} }
if (previewResult.NotificationMessage is not null)
{
previewNotificationMessages.Add((chat, previewResult.NotificationMessage));
}
if (previewResult.Changed) 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) foreach (var (chat, message) in updatedMessages)
{ {
var updated = await db.Messages var updated = await db.Messages
@@ -379,12 +392,12 @@ public sealed class MaxBridgeSyncService(
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview)) if (string.IsNullOrWhiteSpace(preview))
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null);
} }
if (IsPresencePreview(preview)) if (IsPresencePreview(preview))
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null);
} }
var previousPreview = chat.LastMessagePreview; var previousPreview = chat.LastMessagePreview;
@@ -392,7 +405,7 @@ public sealed class MaxBridgeSyncService(
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt); var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) 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); var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
@@ -400,7 +413,7 @@ public sealed class MaxBridgeSyncService(
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1; Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
if (samePreview && samePreviewAt) if (samePreview && samePreviewAt)
{ {
return new ListPreviewApplyResult(false, null); return new ListPreviewApplyResult(false, null, null);
} }
chat.LastMessagePreview = preview; chat.LastMessagePreview = preview;
@@ -422,10 +435,13 @@ public sealed class MaxBridgeSyncService(
chat.UnreadCount++; chat.UnreadCount++;
} }
var message = shouldNotify var message = shouldNotify && !MessageTextSanitizer.IsGenericMediaLabel(preview)
? CreatePreviewMessage(chat, preview, previewAt) ? CreatePreviewMessage(chat, preview, previewAt)
: null; : 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( private static bool IsKnownOutgoingPreviewEcho(
@@ -604,7 +620,7 @@ public sealed class MaxBridgeSyncService(
text.Contains("file", StringComparison.Ordinal); 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); private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
@@ -35,6 +35,8 @@ public static class MessageTextSanitizer
{ {
return value?.Trim().ToLowerInvariant() switch return value?.Trim().ToLowerInvariant() switch
{ {
"\u043c\u0435\u0434\u0438\u0430" => true,
"media" => true,
"\u0444\u043e\u0442\u043e" => true, "\u0444\u043e\u0442\u043e" => true,
"photo" => true, "photo" => true,
"image" => true, "image" => true,
+99
View File
@@ -773,6 +773,40 @@ public sealed class ApiSmokeTests : IDisposable
Assert.StartsWith("preview:", backfilled.ExternalId); 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] [Fact]
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate() 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] [Fact]
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure() public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
{ {