Fix image and profile bugs

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-12 20:21:51 +03:00
Unverified
parent e0ba6db41e
commit 3c08830755
18 changed files with 363 additions and 57 deletions
@@ -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<String, ImageBitmap> = object : LruCache<String, ImageBitmap>(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(
applyExifOrientation(
BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply {
inSampleSize = calculateInSampleSize(
bounds.outWidth,
bounds.outHeight,
orientedW,
orientedH,
reqWidthPx,
reqHeightPx
reqHeightPx,
)
inPreferredConfig = Bitmap.Config.ARGB_8888
}
},
) ?: return null,
orientation,
),
reqWidthPx,
reqHeightPx
reqHeightPx,
)
}
@@ -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<Int, Int>? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
@@ -13,11 +14,28 @@ internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, In
ExifInterface.ORIENTATION_NORMAL,
)
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
return when (orientation) {
return orientedPixelDimensions(bounds.outWidth, bounds.outHeight, orientation)
}
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? {
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<Int, Int> =
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
}
@@ -58,6 +58,7 @@
<string name="coming_soon">Скоро…</string>
<string name="contacts_empty_title">Скоро</string>
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
<string name="chat_scroll_to_bottom_cd">Прокрутить к новым сообщениям</string>
<string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
<string name="chat_preview_attachment">Вложение</string>
@@ -66,6 +66,7 @@
<string name="coming_soon">Coming soon…</string>
<string name="contacts_empty_title">Coming soon</string>
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
<string name="chat_scroll_to_bottom_cd">Scroll to latest messages</string>
<string name="public_chat">Main chat</string>
<string name="chat_last_mesaage">You: last message</string>
<string name="chat_preview_attachment">Attachment</string>
@@ -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)
@@ -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<Message>) {
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,
@@ -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<Int, Int>?
/** Bounds-only width/height from encoded image bytes (JPEG EXIF when supported). */
internal expect fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>?
@@ -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
@@ -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()
}
@@ -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,
@@ -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)
@@ -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<Message> {
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<Message> {
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<Message>) {
@@ -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() },
@@ -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),
@@ -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
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(merged + ahead),
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(combined),
)
}
private fun snapshotUiMessagesForNetworkMerge(): List<Message> = 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<String>() }
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
Logger.d("PublicChatPanel", loadMsg)
var mergedForCache: List<Message>? = 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()
@@ -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) ->
val metadataAspect = pairRatio
?: serverDim?.let { (w, h) ->
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
return aspectRatioFromDimensionPair(w, h)
aspectRatioFromDimensionPair(w, h)
} else {
null
}
}
serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { return it }
return localRatio
?: 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)
}
}
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 {
@@ -43,3 +43,4 @@ fun DisplayName(
}
}
}
@@ -1,3 +1,5 @@
package ru.fromchat.api.local.download
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = null
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? = null