diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt index 2713c5e..a6db492 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalDecodedImageCache.android.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.core.graphics.scale import androidx.exifinterface.media.ExifInterface +import java.io.ByteArrayInputStream actual object PlatformDecodedBitmapCache { private val cache: LruCache = object : LruCache(maxCacheBytes()) { @@ -85,23 +86,34 @@ private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightP if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val orientation = runCatching { + ExifInterface(ByteArrayInputStream(bytes)).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation) + return scaleBitmapToFitWithin( - BitmapFactory.decodeByteArray( - bytes, - 0, - bytes.size, - BitmapFactory.Options().apply { - inSampleSize = calculateInSampleSize( - bounds.outWidth, - bounds.outHeight, - reqWidthPx, - reqHeightPx - ) - inPreferredConfig = Bitmap.Config.ARGB_8888 - } - ) ?: return null, + applyExifOrientation( + BitmapFactory.decodeByteArray( + bytes, + 0, + bytes.size, + BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize( + orientedW, + orientedH, + reqWidthPx, + reqHeightPx, + ) + inPreferredConfig = Bitmap.Config.ARGB_8888 + }, + ) ?: return null, + orientation, + ), reqWidthPx, - reqHeightPx + reqHeightPx, ) } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt index 4e75430..dfc78e5 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.android.kt @@ -2,6 +2,7 @@ package ru.fromchat.api.local.download import android.graphics.BitmapFactory import androidx.exifinterface.media.ExifInterface +import java.io.ByteArrayInputStream internal actual fun readLocalImageDimensions(absolutePath: String): Pair? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } @@ -13,11 +14,28 @@ internal actual fun readLocalImageDimensions(absolutePath: String): Pair? { + if (data.isEmpty()) return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(ByteArrayInputStream(data), null, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val orientation = runCatching { + ExifInterface(ByteArrayInputStream(data)).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + return orientedPixelDimensions(bounds.outWidth, bounds.outHeight, orientation) +} + +private fun orientedPixelDimensions(width: Int, height: Int, orientation: Int): Pair = + when (orientation) { ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_270, ExifInterface.ORIENTATION_TRANSPOSE, - ExifInterface.ORIENTATION_TRANSVERSE -> bounds.outHeight to bounds.outWidth - else -> bounds.outWidth to bounds.outHeight + ExifInterface.ORIENTATION_TRANSVERSE -> height to width + else -> width to height } -} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 379a8a0..b37de16 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -58,6 +58,7 @@ Скоро… Скоро Список контактов появится здесь, когда функция будет готова. + Прокрутить к новым сообщениям Общий чат Вы: последнее сообщение Вложение diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index db7ae70..7c620c1 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -66,6 +66,7 @@ Coming soon… Coming soon Your contacts will appear here when this feature is ready. + Scroll to latest messages Main chat You: last message Attachment diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt index 59a0fd5..7a6d95b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt @@ -16,6 +16,7 @@ import ru.fromchat.api.schema.messages.dm.DmFile import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.download.readLocalImageDimensions +import ru.fromchat.ui.chat.isImageFilename private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } @@ -118,6 +119,7 @@ fun resolveLocalPreviewUri(message: Message): String? { message.pendingFileUri?.takeIf { uri -> DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri) }?.let { return it } + if (!messageQualifiesForImageCacheHydration(message)) return null val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() } if (cid != null) { DecryptedImageCache.getCached(message.id, fileIndex = 0, cid) @@ -132,6 +134,35 @@ fun resolveLocalPreviewUri(message: Message): String? { return null } +/** True when disk lookup may attach a decrypted image preview to [message]. */ +internal fun messageQualifiesForImageCacheHydration(message: Message): Boolean { + if (message.files.orEmpty().any { isImageFilename(it.name) }) return true + if (message.dmEnvelope?.files.orEmpty().any { isImageFilename(it.name) }) return true + if (message.pendingFileAspectRatio != null) return true + message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }?.let { name -> + if (isImageFilename(name)) return true + } + message.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() }?.let { uri -> + if (DecryptedImageCache.isDecryptedImageCacheUri(uri)) return true + val name = uri.substringAfterLast('/').substringBefore('?') + if (isImageFilename(name)) return true + } + val parsed = parseDmMessageContent(message.content) + if (!parsed.fileThumbnails.isNullOrEmpty() && !parsed.files.isNullOrEmpty()) return true + return false +} + +/** Restores pending preview URIs from DB without attaching orphaned image cache files to text rows. */ +internal fun resolveStoredPendingFileUri( + message: Message, + parsed: ParsedDmMessageContent, +): String? { + parsed.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + if (!messageQualifiesForImageCacheHydration(message)) return null + parsed.localPreviewUri?.takeIf { localPreviewFileExists(it) }?.let { return it } + return resolveLocalPreviewUri(message) +} + /** Sync disk lookup for cold-start chat open (no suspend alias copy). */ fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message { val previewUri = resolveLocalPreviewUri(message) ?: return hydrateDiskAspectRatioSync(message) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index 8fe7928..023103e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -26,8 +26,8 @@ import ru.fromchat.api.local.db.encodeOptimisticOutboundMessage import ru.fromchat.api.local.db.encodePersistedDmMessage import ru.fromchat.api.local.db.encodePersistedPublicMessage import ru.fromchat.api.local.db.parseDmMessageContent +import ru.fromchat.api.local.db.resolveStoredPendingFileUri import ru.fromchat.api.local.db.hydrateAttachmentPreviewFromDiskSync -import ru.fromchat.api.local.db.resolveLocalPreviewUri import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.send.DmAttachmentOutboxPayload import ru.fromchat.api.local.send.PublicAttachmentOutboxPayload @@ -45,6 +45,8 @@ import ru.fromchat.api.crypto.decryptEnvelope import ru.fromchat.api.local.cache.DecryptedFileCache import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.download.DownloadedFileRegistry +import ru.fromchat.api.local.download.readImageDimensionsFromBytes +import com.pr0gramm3r101.utils.crypto.Base64 import ru.fromchat.ui.chat.utils.attachPublicReplyReferences import ru.fromchat.ui.chat.isImageFilename import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId @@ -166,21 +168,20 @@ object MessageCacheStore { } suspend fun replacePublicMessages(messages: List) { + val convId = conversationIdForPublic() val resolved = messages.map { it.resolvePublicAttachmentLayout() } ProfileCache.mergePreviewFromPublicMessages(resolved) - conversationIdForPublic().let { - replaceMessages( - it, - sortMessagesForChatDisplay( - dedupeMessagesByClientId( - resolved + loadPendingMessages(it).filter { p -> - val cid = p.client_message_id - cid == null || resolved.none { it.client_message_id == cid } - } - ) - ) - ) + val pending = loadPendingMessages(convId) + val stillPending = filterStillPendingForReplace(convId, pending, resolved) + val before = resolved + stillPending + val merged = dedupeMessagesByClientId( + dropSupersededOptimisticMessages(before, ApiClient.user?.id), + ).let { sortMessagesForChatDisplay(it) } + val iid = instanceId() + withContext(Dispatchers.Default) { + purgeSupersededPendingRows(iid, convId, before, merged) } + replaceMessages(convId, merged) } suspend fun clearPublicMessages() { @@ -305,6 +306,62 @@ object MessageCacheStore { var resolved = confirmed.resolvePublicAttachmentLayout() resolved = hydrateAttachmentPreviewFromDisk(resolved) ProfileCache.mergePreviewFromPublicMessage(resolved) + // If we have a pending optimistic row with a local aspect, prefer it when server + // aspect appears to be a rotated reciprocal (common when EXIF/metadata were swapped). + try { + val convId = conversationIdForPublic() + val pending = loadPendingMessages(convId) + val local = pending.firstOrNull { it.client_message_id == clientMessageId } + if (local != null && !local.files.isNullOrEmpty()) { + val localAspect = local.pendingFileAspectRatio + ?: local.fileDimensions?.firstOrNull()?.let { (w, h) -> + if (h > 0) w.toFloat() / h.toFloat() else null + } + val serverAspect = resolved.fileAspectRatios?.firstOrNull() + ?: resolved.fileDimensions?.firstOrNull()?.let { (w, h) -> + if (h > 0) w.toFloat() / h.toFloat() else null + } + if (localAspect != null && serverAspect != null) { + val product = localAspect * serverAspect + if (product in 0.92f..1.08f && kotlin.math.abs(localAspect - serverAspect) > 0.15f) { + // Swap to local aspect to preserve correct orientation + resolved = resolved.copy( + fileAspectRatios = listOf(localAspect), + fileDimensions = local.fileDimensions ?: resolved.fileDimensions, + ) + } + } + } + } catch (_: Exception) { + // Best-effort only; fall back to server-resolved layout on any failure. + } + // If no local optimistic row, prefer decoded server thumbnail dims when available + try { + val convId = conversationIdForPublic() + val pending = loadPendingMessages(convId) + val local = pending.firstOrNull { it.client_message_id == clientMessageId } + if (local == null) { + val thumbB64 = resolved.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() } + if (!thumbB64.isNullOrBlank()) { + val bytes = runCatching { Base64.decode(thumbB64) }.getOrNull() + val thumbDims = bytes?.let { readImageDimensionsFromBytes(it) } + val decodedAspect = thumbDims?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null } + val serverAspect = resolved.fileAspectRatios?.firstOrNull() + ?: resolved.fileDimensions?.firstOrNull()?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null } + if (decodedAspect != null && serverAspect != null) { + val product = decodedAspect * serverAspect + if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) { + resolved = resolved.copy( + fileAspectRatios = listOf(decodedAspect), + fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions, + ) + } + } + } + } + } catch (_: Exception) { + // Best-effort only + } confirmMessage(conversationIdForPublic(), clientMessageId, resolved) } } @@ -1220,9 +1277,7 @@ object MessageCacheStore { isContentCorrupted = parsed.isContentCorrupted, ) val hydrated = base.copy( - pendingFileUri = parsed.pendingFileUri - ?: parsed.localPreviewUri - ?: resolveLocalPreviewUri(base), + pendingFileUri = resolveStoredPendingFileUri(base, parsed), pendingFilename = parsed.pendingFilename ?: base.pendingFilename, uploadJobId = parsed.uploadJobId ?: base.uploadJobId, fileSizes = parsed.fileSizes ?: base.fileSizes, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt index f7f4f31..4935ba5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.kt @@ -2,3 +2,6 @@ package ru.fromchat.api.local.download /** EXIF-oriented width/height from a local file (bounds read only; no full decode). */ internal expect fun readLocalImageDimensions(absolutePath: String): Pair? + +/** Bounds-only width/height from encoded image bytes (JPEG EXIF when supported). */ +internal expect fun readImageDimensionsFromBytes(data: ByteArray): Pair? diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt index e8d57ac..30cf7bf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ChatListPreview.kt @@ -1,5 +1,6 @@ package ru.fromchat.api.local.messages +import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.DmEnvelope @@ -36,6 +37,7 @@ fun messageHasImageAttachment(message: Message): Boolean { if (isImageFilename(file.name)) return true } if (message.pendingFileUri != null) { + if (DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri)) return true val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() } ?: message.pendingFileUri.substringAfterLast('/').substringBefore('?') if (isImageFilename(pendingName)) return true diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendNotifier.kt index 8b9f1ae..7bc7618 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendNotifier.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendNotifier.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.launch sealed class OutboundSendProgress { data class Pending(val clientMessageId: String) : OutboundSendProgress() + data class Success(val clientMessageId: String, val message: ru.fromchat.api.schema.messages.Message) : + OutboundSendProgress() data class Failed(val clientMessageId: String, val error: String) : OutboundSendProgress() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt index 32993e7..3211d84 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt @@ -30,6 +30,7 @@ import ru.fromchat.api.local.db.buildDmOutboundPlaintext import ru.fromchat.api.local.cache.clearUploadArtifacts import ru.fromchat.api.local.cache.clearUploadSecretsOnly import ru.fromchat.api.local.AttachmentMediaLog +import ru.fromchat.Logger /** * Single entry point for enqueueing outbound messages (DB row + outbox + worker). @@ -66,15 +67,21 @@ object OutgoingMessageCoordinator { } var ok = true sendResult.onSuccess { confirmed -> + val resolved = confirmed.copy(client_message_id = row.clientMessageId) + Logger.d("OutgoingMessageCoordinator", "handlePublicOutboxSend: success clientId=${row.clientMessageId.take(12)} realId=${resolved.id}") withContext(Dispatchers.Default) { MessageCacheStore.confirmPublicMessage( row.clientMessageId, - confirmed.copy(client_message_id = row.clientMessageId), + resolved, ) MessageDatabaseProvider.database.messageDatabaseQueries .deleteOutboxItem(instanceId, row.clientMessageId) } + OutboundSendNotifier.emit( + OutboundSendProgress.Success(row.clientMessageId, resolved), + ) }.onFailure { error -> + Logger.d("OutgoingMessageCoordinator", "handlePublicOutboxSend: failure clientId=${row.clientMessageId.take(12)} err=${error.message ?: error::class.simpleName}") when { error.isOutboundPermanentFailure() -> { val errorKey = outboundFailureErrorKey(error) @@ -152,6 +159,7 @@ object OutgoingMessageCoordinator { val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) withContext(Dispatchers.Default) { MessageRepository.upsertPublicMessage(optimisticMessage) + Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}") val payload = json.encodeToString(PublicOutboxPayload(content, replyToId)) MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox( instanceId = instanceId, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 589b2f5..9056931 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -105,6 +105,7 @@ import ru.fromchat.ui.chat.utils.attachmentTileLayout import ru.fromchat.ui.chat.utils.coalesceDecodeTarget import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap +import ru.fromchat.ui.chat.utils.preferDecodedAspectRatio import ru.fromchat.ui.components.Text import com.pr0gramm3r101.utils.scaleOnPress import ru.fromchat.ui.chat.MessageGroupInfo @@ -202,6 +203,9 @@ fun AttachmentPreview( var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) { mutableStateOf(false) } + var effectiveAspect by remember(messageId, fileIndex, file?.path, pendingFileUri, fileAspectRatio) { + mutableStateOf(fileAspectRatio) + } Box( modifier = modifier .then( @@ -219,7 +223,7 @@ fun AttachmentPreview( ) // Explicit px size from dp max + aspect — do not wrap to thumb intrinsics // (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small). - .attachmentTileLayout(aspectRatio = fileAspectRatio) + .attachmentTileLayout(aspectRatio = effectiveAspect) .clip(attachmentImageCornerShape(isAuthor, messageGroup)) .then( if (onImageBounds != null && showImageTile) { @@ -273,6 +277,12 @@ fun AttachmentPreview( onCancelUpload = onCancelUpload, onFullyLoaded = { if (it) isFullyLoaded = true }, messageGroup = messageGroup, + onResolvedAspectRatio = { width, height -> + val resolved = preferDecodedAspectRatio(fileAspectRatio, width, height) + if (resolved != effectiveAspect) { + effectiveAspect = resolved + } + }, ) } } @@ -307,6 +317,7 @@ private fun ChatImageTileContent( hasSameAuthorAbove = false, hasSameAuthorBelow = false, ), + onResolvedAspectRatio: ((width: Int, height: Int) -> Unit)? = null, ) { val scope = rememberCoroutineScope() val clipShape = attachmentImageCornerShape(isAuthor, messageGroup) @@ -671,6 +682,11 @@ private fun ChatImageTileContent( if (placeholderBitmap != null) decryptFinished = true } + LaunchedEffect(fullBitmap, placeholderBitmap) { + val bitmap = fullBitmap ?: placeholderBitmap ?: return@LaunchedEffect + onResolvedAspectRatio?.invoke(bitmap.width, bitmap.height) + } + LaunchedEffect(treatAsOutbound, messageId, fileIndex, cacheClientId) { if (treatAsOutbound) { AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index a3fc810..6079d0a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -93,7 +93,10 @@ abstract class ChatPanel( addMessageMutex.withLock { batchStateUpdates { updateState { current -> - val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages) + val merged = mergeDatabaseMessagesWithPanelState( + panelMessagesForDbMerge(), + messages, + ) val withReplies = attachPublicReplyReferences(merged) if (current.messages == withReplies) current else current.copy(messages = withReplies) @@ -345,14 +348,39 @@ abstract class ChatPanel( updateState { it.copy(messages = emptyList()) } } - /** In-flight sends only (active [pendingMessages]), not stale cache optimistics. */ + /** In-flight sends ([pendingMessages]), including rows cleared from [_state] by a DB refresh. */ protected fun snapshotPendingOptimisticMessages(): List { if (pendingMessages.isEmpty()) return emptyList() val pendingClientIds = pendingMessages.keys - return _state.messages.filter { msg -> + val fromState = _state.messages.filter { msg -> val cid = msg.client_message_id?.trim().orEmpty() cid.isNotEmpty() && cid in pendingClientIds } + val coveredClientIds = fromState.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet() + val fromMap = pendingMessages.values.map { it.second }.filter { msg -> + val cid = msg.client_message_id?.trim().orEmpty() + cid.isEmpty() || cid !in coveredClientIds + } + if (fromMap.isEmpty()) return fromState + return ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(fromState + fromMap) + } + + protected fun pendingOptimisticMessage(clientMessageId: String): Message? { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return null + return _state.messages.find { it.client_message_id == cid } + ?: pendingMessages[cid]?.second + } + + /** Returns comma-separated client ids of in-flight pending messages (for debug). */ + protected fun debugPendingKeys(): String = + pendingMessages.keys.joinToString(",") + + /** Panel snapshot for DB observe merges — keeps in-flight sends when SQL omits them. */ + protected fun panelMessagesForDbMerge(): List { + val pending = snapshotPendingOptimisticMessages() + if (pending.isEmpty()) return _state.messages + return ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(_state.messages + pending) } protected suspend fun restorePendingOptimisticMessages(messages: List) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 008dec0..33d34ae 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -23,15 +23,20 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -73,6 +78,8 @@ import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.decodeFromJsonElement @@ -119,6 +126,7 @@ import ru.fromchat.back import ru.fromchat.action_delete_chat import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.cd_call +import ru.fromchat.chat_scroll_to_bottom_cd import ru.fromchat.chat_group_label import ru.fromchat.status_connecting import ru.fromchat.status_updating @@ -175,6 +183,16 @@ fun ChatScreen( val listState = rememberSaveable(panelId, saver = LazyListState.Saver) { LazyListState(0, 0) } + var isNearBottom by rememberSaveable(panelId) { mutableStateOf(true) } + LaunchedEffect(listState, panelState.messages.size) { + snapshotFlow { + val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index } + ?: Int.MAX_VALUE + minVisibleIndex <= 2 + } + .distinctUntilChanged() + .collect { nearBottom -> isNearBottom = nearBottom } + } val density = LocalDensity.current val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } } val scope = rememberCoroutineScope() @@ -428,6 +446,7 @@ fun ChatScreen( val statusUpdating = stringResource(Res.string.status_updating) val presenceRecently = stringResource(Res.string.presence_recently) val chatGroupLabel = stringResource(Res.string.chat_group_label) + val scrollToBottomCd = stringResource(Res.string.chat_scroll_to_bottom_cd) val cdCall = stringResource(Res.string.cd_call) LaunchedEffect(currentTypingUsers) { Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") @@ -482,9 +501,13 @@ fun ChatScreen( } } - // Scroll to specific message when requested (e.g., from notification click) - LaunchedEffect(scrollToMessageId, panelState.messages) { - scrollToMessageId?.let(scrollToChatMessage) + // Scroll to specific message once when requested (e.g., notification / deep link). + LaunchedEffect(scrollToMessageId) { + val messageId = scrollToMessageId ?: return@LaunchedEffect + snapshotFlow { lazyIndexForMessageId(listItems, messageId) } + .filterNotNull() + .first() + scrollToChatMessage(messageId) } // UI state @@ -693,6 +716,8 @@ fun ChatScreen( panel.updateMessageByClientMessageId(progress.clientMessageId) { it.copy(uploadError = null) } + is OutboundSendProgress.Success -> + panel.handleMessageConfirmed(progress.clientMessageId, progress.message) is OutboundSendProgress.Failed -> panel.updateMessageByClientMessageId(progress.clientMessageId) { it.copy(uploadError = progress.error) @@ -1271,6 +1296,34 @@ fun ChatScreen( item { Spacer(modifier.height(floatingHeaderClearance)) } } + val showScrollToBottomFab = panel.usesPublicGroupSubtitle && + !isNearBottom && + panelState.messages.isNotEmpty() && + !contextMenuState.isOpen + AnimatedVisibility( + visible = showScrollToBottomFab, + enter = fadeIn(tween(150)) + + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut(tween(120)) + + shrinkVertically(shrinkTowards = Alignment.Bottom), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp), + ) { + SmallFloatingActionButton( + onClick = { + scope.launch { listState.animateScrollToItem(0) } + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = scrollToBottomCd, + ) + } + } + ChatTopBar( hazeState = hazeState, onBack = { navController.navigateUp() }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 753b858..90714a5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -478,6 +478,8 @@ fun MessageItem( horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start ) { val pendingIsImage = when { + message.pendingFileUri != null && + DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri) -> true message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename) message.pendingFileUri != null -> isImageFilename( @@ -487,7 +489,8 @@ fun MessageItem( } val pendingHasOutboundFile = message.pendingFileUri != null && message.files.isNullOrEmpty() && - !pendingIsImage + !pendingIsImage && + !DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri) val uploadFailed = !message.uploadError.isNullOrBlank() val canCancelUpload = message.isQueuedOutbound() && isAuthor && !uploadFailed && @@ -725,6 +728,13 @@ fun MessageItem( (primaryFile != null && !primaryIsImage) if (showPrimaryImageSlot) { val imageKey = imageAttachmentKey(message, 0) + val primaryThumbBytes = remember( + message.id, + message.fileThumbnails?.firstOrNull(), + ) { + message.fileThumbnails?.firstOrNull() + ?.let { decodeAttachmentThumbnailBase64(it) } + } val layoutAspect = imageAspectRatioForMessage( fileAspectRatios = message.fileAspectRatios, fileDimensions = message.fileDimensions, @@ -733,6 +743,7 @@ fun MessageItem( fileIndex = 0, confirmed = message.id > 0, hasLocalPreview = !message.pendingFileUri.isNullOrBlank(), + thumbnailBytes = primaryThumbBytes, ) LaunchedEffect( message.id, @@ -896,6 +907,9 @@ fun MessageItem( .isDecryptedImageCacheUri( message.pendingFileUri, ), + thumbnailBytes = message.fileThumbnails + ?.getOrNull(index) + ?.let { decodeAttachmentThumbnailBase64(it) }, ), fileSizeBytes = message.fileSizes?.getOrNull(index), @@ -1121,6 +1135,7 @@ private fun MessageReplyQuote( ) { Row( modifier = Modifier + .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(quoteBg) .height(IntrinsicSize.Min), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt index bd0360d..25c2c2e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt @@ -203,7 +203,10 @@ class PublicChatPanel( else -> { // Never replace the in-memory list with the DB snapshot alone — that // dropped paginated / ahead-of-network rows when reopening (e.g. profile). - val merged = mergeDatabaseMessagesWithPanelState(shown, cached) + val merged = mergeDatabaseMessagesWithPanelState( + panelMessagesForDbMerge(), + cached, + ) if (merged != shown) { updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) } } @@ -272,16 +275,26 @@ class PublicChatPanel( } } val ahead = shown.filter { it.id > 0 && it.id !in networkIds } - if (ahead.isEmpty()) return merged + val inFlight = shown.filter { msg -> + msg.id < 0 && ( + !msg.client_message_id.isNullOrBlank() || + msg.pendingFileUri != null || + !msg.uploadJobId.isNullOrBlank() + ) + } + if (ahead.isEmpty() && inFlight.isEmpty()) return merged + val combined = merged + ahead + inFlight return ru.fromchat.api.local.messages.sortMessagesForChatDisplay( - ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(merged + ahead), + ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined), ) } + private fun snapshotUiMessagesForNetworkMerge(): List = panelMessagesForDbMerge() + override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { val cid = clientMessageId?.trim().orEmpty() if (cid.isEmpty()) return - val optimistic = _state.messages.find { it.client_message_id == cid } ?: return + val optimistic = pendingOptimisticMessage(cid) ?: return OutgoingMessageCoordinator.enqueuePublicMessage( content = content, replyToId = replyToId, @@ -334,8 +347,15 @@ class PublicChatPanel( if (response != null && response.messages.isNotEmpty()) { val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() } ProfileCache.mergePreviewFromPublicMessages(networkMessages) + val optimisticSnapshot = snapshotPendingOptimisticMessages() + val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)" + val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf() } + val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}" + Logger.d("PublicChatPanel", loadMsg) + var mergedForCache: List? = null withContext(Dispatchers.Main) { - val shown = _state.messages + val shown = snapshotUiMessagesForNetworkMerge() + Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}") if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) { Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add") val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages) @@ -344,6 +364,7 @@ class PublicChatPanel( } if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.isLoading) setLoading(false) + mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages) } else { batchStateUpdates { val merged = preserveReplyToFromExisting( @@ -354,14 +375,22 @@ class PublicChatPanel( addMessages( ProfileCache.enrichPublicMessagesForDisplay(merged), ) + Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}") + restorePendingOptimisticMessages(optimisticSnapshot) setHasMoreMessages(false) // TODO: Implement has_more from API setLoading(false) + mergedForCache = mergeNetworkHistoryWithShown( + panelMessagesForDbMerge(), + networkMessages, + ) } } } withContext(Dispatchers.Default) { - val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, networkMessages) - MessageCacheStore.replacePublicMessages(mergedForCache) + val toPersist = mergedForCache + ?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages) + Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}") + MessageCacheStore.replacePublicMessages(toPersist) } } else if (responseResult.isFailure) { val cause = responseResult.exceptionOrNull() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt index 80d5d4c..1bef871 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/AttachmentImageGeometry.kt @@ -135,6 +135,7 @@ internal fun imageAspectRatioForMessage( fileIndex: Int = 0, @Suppress("UNUSED_PARAMETER") confirmed: Boolean = true, @Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false, + thumbnailBytes: ByteArray? = null, ): Float? { val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f } val pair = fileAspectRatioPairs?.getOrNull(fileIndex) @@ -148,14 +149,42 @@ internal fun imageAspectRatioForMessage( val serverDim = fileDimensions?.getOrNull(fileIndex) val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f } - pairRatio?.let { return it } - serverDim?.let { (w, h) -> - if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) { - return aspectRatioFromDimensionPair(w, h) + val metadataAspect = pairRatio + ?: serverDim?.let { (w, h) -> + if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) { + aspectRatioFromDimensionPair(w, h) + } else { + null + } + } + ?: serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) } + ?: localRatio + + val thumbDims = thumbnailBytes?.let { ru.fromchat.api.local.download.readImageDimensionsFromBytes(it) } + if (thumbDims != null) { + val (tw, th) = thumbDims + if (tw > 0 && th > 0 && !isPlaceholderAttachmentDimensions(tw, th)) { + return preferDecodedAspectRatio(metadataAspect, tw, th) } } - serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { return it } - return localRatio + return metadataAspect +} + +/** + * Prefer decoded / thumbnail pixels when metadata ignored EXIF (common on huge JPEGs): + * metadata and decoded aspects are reciprocals (~90° apart). + */ +internal fun preferDecodedAspectRatio( + metadataAspect: Float?, + decodedWidth: Int, + decodedHeight: Int, +): Float { + if (decodedWidth <= 0 || decodedHeight <= 0) { + return metadataAspect?.takeIf { it.isFinite() && it > 0f } ?: 1f + } + // Always trust decoded pixels for layout; metadata can disagree on EXIF orientation, + // especially for very large images or when dimensions were cached incorrectly. + return decodedWidth.toFloat() / decodedHeight.toFloat() } internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt index 5a0dafd..edfdfca 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt @@ -43,3 +43,4 @@ fun DisplayName( } } } + diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt index 9689b9c..2e062fe 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/download/LocalImageFileDimensions.ios.kt @@ -1,3 +1,5 @@ package ru.fromchat.api.local.download internal actual fun readLocalImageDimensions(absolutePath: String): Pair? = null + +internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair? = null