Fix image issues

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-15 22:28:07 +03:00
Unverified
parent d0ddcf32db
commit d92f518d87
4 changed files with 278 additions and 130 deletions
@@ -69,8 +69,10 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.pr0gramm3r101.utils.crypto.Base64 import com.pr0gramm3r101.utils.crypto.Base64
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -84,7 +86,6 @@ import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.download.AttachmentDownloadScheduler import ru.fromchat.api.local.download.AttachmentDownloadScheduler
import ru.fromchat.api.local.download.ChatPreviewDecodeSize import ru.fromchat.api.local.download.ChatPreviewDecodeSize
import ru.fromchat.api.local.download.LocalDecodedImageCache import ru.fromchat.api.local.download.LocalDecodedImageCache
import ru.fromchat.api.local.download.rememberChatPreviewDecodeSize
import ru.fromchat.api.local.send.previewSeedDecodeSize import ru.fromchat.api.local.send.previewSeedDecodeSize
import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile import ru.fromchat.api.schema.messages.dm.DmFile
@@ -94,21 +95,20 @@ import ru.fromchat.attachment_upload_failed
import ru.fromchat.attachment_upload_failed_too_large import ru.fromchat.attachment_upload_failed_too_large
import ru.fromchat.cd_attachment_retry import ru.fromchat.cd_attachment_retry
import ru.fromchat.cd_attachment_upload_retry import ru.fromchat.cd_attachment_upload_retry
import ru.fromchat.ui.chat.MessageGroupInfo
import ru.fromchat.ui.chat.components.AttachmentLeadingTransitionMs import ru.fromchat.ui.chat.components.AttachmentLeadingTransitionMs
import ru.fromchat.ui.chat.components.CancellableAttachmentProgressIndicator import ru.fromchat.ui.chat.components.CancellableAttachmentProgressIndicator
import ru.fromchat.ui.chat.components.ChatFileAttachmentTile import ru.fromchat.ui.chat.components.ChatFileAttachmentTile
import ru.fromchat.ui.chat.components.FileAttachmentLeadingSlot import ru.fromchat.ui.chat.components.FileAttachmentLeadingSlot
import ru.fromchat.ui.chat.utils.ATTACHMENT_TILE_MAX_WIDTH
import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys import ru.fromchat.ui.chat.utils.attachmentDecodeCacheKeys
import ru.fromchat.ui.chat.utils.attachmentImageCornerShape import ru.fromchat.ui.chat.utils.attachmentImageCornerShape
import ru.fromchat.ui.chat.utils.attachmentTileLayout 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.decodeSizeChangedMeaningfully
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
import ru.fromchat.ui.chat.utils.preferDecodedAspectRatio import ru.fromchat.ui.chat.utils.preferDecodedAspectRatio
import ru.fromchat.ui.chat.utils.rememberAnimatedAttachmentImageCornerShape
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import ru.fromchat.ui.chat.MessageGroupInfo
private const val BLUR_FADE_MS = 450 private const val BLUR_FADE_MS = 450
@@ -157,6 +157,8 @@ fun AttachmentPreview(
), ),
/** When true, grow the tile to the bubble width (e.g. caption text is wider). */ /** When true, grow the tile to the bubble width (e.g. caption text is wider). */
expandToBubbleWidth: Boolean = false, expandToBubbleWidth: Boolean = false,
/** While true, keep the tile aspect fixed (message enter animation). */
freezeLayoutAspect: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isImage = when { val isImage = when {
@@ -202,12 +204,21 @@ fun AttachmentPreview(
) )
} }
showImageTile -> { showImageTile -> {
val imageCornerShape = rememberAnimatedAttachmentImageCornerShape(
isAuthor = isAuthor,
group = messageGroup,
)
var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) { var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) {
mutableStateOf(false) mutableStateOf(false)
} }
var effectiveAspect by remember(messageId, fileIndex, file?.path, pendingFileUri, fileAspectRatio) { var effectiveAspect by remember(messageId, fileIndex, file?.path, pendingFileUri, fileAspectRatio) {
mutableStateOf(fileAspectRatio) mutableStateOf(fileAspectRatio)
} }
LaunchedEffect(fileAspectRatio) {
fileAspectRatio?.takeIf { it.isFinite() && it > 0f }?.let { aspect ->
effectiveAspect = aspect
}
}
Box( Box(
modifier = modifier modifier = modifier
.then( .then(
@@ -227,7 +238,7 @@ fun AttachmentPreview(
aspectRatio = effectiveAspect, aspectRatio = effectiveAspect,
expandToBubbleWidth = expandToBubbleWidth, expandToBubbleWidth = expandToBubbleWidth,
) )
.clip(attachmentImageCornerShape(isAuthor, messageGroup)) .clip(imageCornerShape)
.then( .then(
if (onImageBounds != null && showImageTile) { if (onImageBounds != null && showImageTile) {
Modifier.onGloballyPositioned { coords -> Modifier.onGloballyPositioned { coords ->
@@ -280,11 +291,19 @@ fun AttachmentPreview(
onCancelUpload = onCancelUpload, onCancelUpload = onCancelUpload,
onFullyLoaded = { if (it) isFullyLoaded = true }, onFullyLoaded = { if (it) isFullyLoaded = true },
messageGroup = messageGroup, messageGroup = messageGroup,
imageCornerShape = imageCornerShape,
freezeLayoutAspect = freezeLayoutAspect,
onResolvedAspectRatio = { width, height -> onResolvedAspectRatio = { width, height ->
val resolved = preferDecodedAspectRatio(fileAspectRatio, width, height) if (!freezeLayoutAspect) {
val resolved = preferDecodedAspectRatio(
fileAspectRatio,
width,
height,
)
if (resolved != effectiveAspect) { if (resolved != effectiveAspect) {
effectiveAspect = resolved effectiveAspect = resolved
} }
}
}, },
) )
} }
@@ -320,23 +339,21 @@ private fun ChatImageTileContent(
hasSameAuthorAbove = false, hasSameAuthorAbove = false,
hasSameAuthorBelow = false, hasSameAuthorBelow = false,
), ),
imageCornerShape: RoundedCornerShape? = null,
freezeLayoutAspect: Boolean = false,
onResolvedAspectRatio: ((width: Int, height: Int) -> Unit)? = null, onResolvedAspectRatio: ((width: Int, height: Int) -> Unit)? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipShape = attachmentImageCornerShape(isAuthor, messageGroup) val clipShape = imageCornerShape ?: attachmentImageCornerShape(isAuthor, messageGroup)
val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() }
val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f } val layoutAspect = aspectRatio?.takeIf { it.isFinite() && it > 0f }
val fallbackDecodeSize = rememberChatPreviewDecodeSize(ATTACHMENT_TILE_MAX_WIDTH, layoutAspect)
val seedDecodeSize = remember(layoutAspect) { previewSeedDecodeSize(layoutAspect) }
val decodeCacheKeys = remember(cacheClientId, messageId, fileIndex) { val decodeCacheKeys = remember(cacheClientId, messageId, fileIndex) {
attachmentDecodeCacheKeys(messageId, fileIndex, cacheClientId) attachmentDecodeCacheKeys(messageId, fileIndex, cacheClientId)
} }
val bitmapStateKey = remember(decodeCacheKeys) { decodeCacheKeys.joinToString("|") } val bitmapStateKey = remember(decodeCacheKeys) { decodeCacheKeys.joinToString("|") }
val decryptCacheKey = decodeCacheKeys.first() val decryptCacheKey = decodeCacheKeys.first()
var tileDecodeSize by remember(bitmapStateKey) { mutableStateOf<ChatPreviewDecodeSize?>(null) } var tileDecodeSize by remember(bitmapStateKey) { mutableStateOf<ChatPreviewDecodeSize?>(null) }
val decodeSize = remember(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) { val tileLoadTarget = remember(bitmapStateKey) { previewSeedDecodeSize(layoutAspect) }
coalesceDecodeTarget(tileDecodeSize, fallbackDecodeSize, seedDecodeSize)
}
var cachedPath by remember(bitmapStateKey) { var cachedPath by remember(bitmapStateKey) {
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId))
@@ -346,7 +363,12 @@ private fun ChatImageTileContent(
val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri
val hasLocalSource = displayLocalUri != null val hasLocalSource = displayLocalUri != null
val initialFull = remember(bitmapStateKey) { peekDecodedAttachmentBitmap(decodeCacheKeys) } fun isPreviewQuality(bitmap: ImageBitmap): Boolean =
!LocalDecodedImageCache.needsUpscale(bitmap, tileLoadTarget)
val initialFull = remember(bitmapStateKey, tileLoadTarget) {
peekDecodedAttachmentBitmap(decodeCacheKeys)?.takeIf { isPreviewQuality(it) }
}
var fullBitmap by remember(bitmapStateKey) { var fullBitmap by remember(bitmapStateKey) {
mutableStateOf(initialFull) mutableStateOf(initialFull)
} }
@@ -434,8 +456,9 @@ private fun ChatImageTileContent(
} }
} }
val imageContentScale = ContentScale.Fit val imageContentScale = ContentScale.Crop
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f) val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f)
val thumbHazeState = rememberHazeState(blurEnabled = true)
LaunchedEffect(messageId, fileIndex, cacheClientId) { LaunchedEffect(messageId, fileIndex, cacheClientId) {
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri -> DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
@@ -443,61 +466,68 @@ private fun ChatImageTileContent(
} }
} }
// Keys omit localUri/diskCacheUri: Success patches pendingFileUri and would cancel mid-download.
LaunchedEffect( LaunchedEffect(
decryptCacheKey, decryptCacheKey,
localUri,
diskCacheUri,
serverFile?.path, serverFile?.path,
messageId, messageId,
isOutboundPending, isOutboundPending,
cacheClientId, cacheClientId,
loadAttempt, loadAttempt,
decodeSize,
) { ) {
val decodeTarget = tileLoadTarget
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
"load_start", "load_start",
"key" to decryptCacheKey, "key" to decryptCacheKey,
"msgId" to messageId, "msgId" to messageId,
"pending" to isOutboundPending, "pending" to isOutboundPending,
"localUri" to (localUri?.take(48) ?: "null"), "localUri" to (localUri?.take(48) ?: "null"),
"diskCache" to (diskCacheUri?.take(48) ?: "null"), "diskCache" to (DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.take(48) ?: "null"),
"target" to "${decodeSize.widthPx}x${decodeSize.heightPx}", "target" to "${decodeTarget.widthPx}x${decodeTarget.heightPx}",
) )
// Already showing a decoded preview — never flash failed/loading by re-fetching.
val cachedBitmap = fullBitmap ?: peekDecodedAttachmentBitmap(decodeCacheKeys) fun acceptFull(bitmap: ImageBitmap, path: String?) {
if (cachedBitmap != null && (isOutboundPending || isUploading || awaitingServerAck || fullBitmap = bitmap
isAuthor || hasLocalSource) if (path != null) cachedPath = path
) {
if (fullBitmap == null) {
fullBitmap = cachedBitmap
}
decryptFinished = true decryptFinished = true
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true) onFullyLoaded(true)
}
val peeked = fullBitmap ?: peekDecodedAttachmentBitmap(decodeCacheKeys)
if (peeked != null) {
val keep = isOutboundPending || isUploading || awaitingServerAck || isAuthor ||
isPreviewQuality(peeked)
if (keep) {
acceptFull(peeked, cachedPath ?: localUri)
AttachmentMediaLog.send( AttachmentMediaLog.send(
"tile_keep_local", "tile_keep_local",
"key" to decryptCacheKey, "key" to decryptCacheKey,
"msgId" to messageId, "msgId" to messageId,
"pending" to isOutboundPending, "pending" to isOutboundPending,
"uploading" to isUploading, "uploading" to isUploading,
"quality" to "${peeked.width}x${peeked.height}",
) )
return@LaunchedEffect return@LaunchedEffect
} }
val diskUri = diskCacheUri // Soft in-memory decode: drop so we show the thumb until HQ is ready.
?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.also { cachedPath = it } if (fullBitmap != null) fullBitmap = null
LocalDecodedImageCache.evict(decryptCacheKey)
}
val diskUri = DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)
?.also { if (cachedPath != it) cachedPath = it }
val localPaths = buildList { val localPaths = buildList {
localUri?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) } localUri?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
diskUri?.let { cached -> if (none { it == cached }) add(cached) } diskUri?.let { cached -> if (none { it == cached }) add(cached) }
} }
if (localPaths.isNotEmpty()) { if (localPaths.isNotEmpty()) {
val quickDecode = previewSeedDecodeSize(aspectRatio)
val loaded = withContext(Dispatchers.Default) { val loaded = withContext(Dispatchers.Default) {
peekDecodedAttachmentBitmap(decodeCacheKeys) localPaths.firstNotNullOfOrNull { path ->
?: localPaths.firstNotNullOfOrNull { path ->
LocalDecodedImageCache.loadFull( LocalDecodedImageCache.loadFull(
decryptCacheKey, decryptCacheKey,
path.removePrefix("file://"), path.removePrefix("file://"),
quickDecode, decodeTarget,
) )
} }
} }
@@ -508,13 +538,10 @@ private fun ChatImageTileContent(
"source" to "disk_or_pending", "source" to "disk_or_pending",
"bmp" to "${loaded.width}x${loaded.height}", "bmp" to "${loaded.width}x${loaded.height}",
) )
fullBitmap = loaded acceptFull(loaded, diskUri ?: localPaths.firstOrNull())
cachedPath = diskUri ?: localPaths.firstOrNull()
decryptFinished = true
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
// Own sends with a local preview never need a network round-trip for display.
if (isOutboundPending || isAuthor) return@LaunchedEffect if (isOutboundPending || isAuthor) return@LaunchedEffect
// Inbound: local HQ is enough — don't hit network again.
if (isPreviewQuality(loaded)) return@LaunchedEffect
} else { } else {
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
"load_local_miss", "load_local_miss",
@@ -523,7 +550,6 @@ private fun ChatImageTileContent(
) )
} }
} }
// Own sends: never fall through to network while a local/disk preview exists (or is expected).
if (isOutboundPending || isUploading || awaitingServerAck || isAuthor) { if (isOutboundPending || isUploading || awaitingServerAck || isAuthor) {
if (fullBitmap == null && localPaths.isEmpty()) { if (fullBitmap == null && localPaths.isEmpty()) {
DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri -> DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri ->
@@ -532,14 +558,11 @@ private fun ChatImageTileContent(
LocalDecodedImageCache.loadFull( LocalDecodedImageCache.loadFull(
decryptCacheKey, decryptCacheKey,
uri.removePrefix("file://"), uri.removePrefix("file://"),
decodeSize, decodeTarget,
) )
} }
if (loaded != null) { if (loaded != null) {
fullBitmap = loaded acceptFull(loaded, uri)
decryptFinished = true
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
AttachmentMediaLog.send( AttachmentMediaLog.send(
"tile_cache_hit", "tile_cache_hit",
"key" to decryptCacheKey, "key" to decryptCacheKey,
@@ -574,11 +597,12 @@ private fun ChatImageTileContent(
AttachmentMediaLog.tileLoad("load_skip_no_file", "key" to decryptCacheKey) AttachmentMediaLog.tileLoad("load_skip_no_file", "key" to decryptCacheKey)
return@LaunchedEffect return@LaunchedEffect
} }
if (diskUri != null) { if (diskUri != null && fullBitmap == null) {
cachedPath = diskUri
val loaded = withContext(Dispatchers.Default) { val loaded = withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadFull(decryptCacheKey, diskUri.removePrefix("file://"), LocalDecodedImageCache.loadFull(
decodeSize decryptCacheKey,
diskUri.removePrefix("file://"),
decodeTarget,
) )
} }
if (loaded != null) { if (loaded != null) {
@@ -588,9 +612,7 @@ private fun ChatImageTileContent(
"uri" to diskUri, "uri" to diskUri,
"bmp" to "${loaded.width}x${loaded.height}", "bmp" to "${loaded.width}x${loaded.height}",
) )
fullBitmap = loaded acceptFull(loaded, diskUri)
decryptFinished = true
onFullyLoaded(true)
return@LaunchedEffect return@LaunchedEffect
} }
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
@@ -626,6 +648,8 @@ private fun ChatImageTileContent(
messageLabel = messageLabel, messageLabel = messageLabel,
) )
} }
} catch (error: kotlinx.coroutines.CancellationException) {
throw error
} catch (error: Throwable) { } catch (error: Throwable) {
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
"load_exception", "load_exception",
@@ -637,28 +661,29 @@ private fun ChatImageTileContent(
} finally { } finally {
isAwaitingNetworkFull = false isAwaitingNetworkFull = false
} }
cachedPath = uri if (uri != null) cachedPath = uri
if (uri != null) { if (uri != null) {
fullBitmap = withContext(Dispatchers.Default) { val loaded = withContext(Dispatchers.Default) {
LocalDecodedImageCache.loadFull(decryptCacheKey, uri.removePrefix("file://"), LocalDecodedImageCache.loadFull(
decodeSize decryptCacheKey,
uri.removePrefix("file://"),
decodeTarget,
) )
} }
} if (loaded != null) acceptFull(loaded, uri)
else decryptFinished = true
} else {
decryptFinished = true decryptFinished = true
}
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
if (fullBitmap != null) "load_done" else "load_failed", if (fullBitmap != null) "load_done" else "load_failed",
"key" to decryptCacheKey, "key" to decryptCacheKey,
"uri" to uri, "uri" to uri,
"msg" to AttachmentMediaLog.messageLabel(messageLabel), "msg" to AttachmentMediaLog.messageLabel(messageLabel),
) )
if (fullBitmap != null) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
onFullyLoaded(true)
}
} }
LaunchedEffect(decryptCacheKey, tileDecodeSize) { LaunchedEffect(decryptCacheKey, tileDecodeSize, fullBitmap?.width, fullBitmap?.height) {
val target = tileDecodeSize ?: return@LaunchedEffect val target = tileDecodeSize ?: return@LaunchedEffect
val current = fullBitmap ?: return@LaunchedEffect val current = fullBitmap ?: return@LaunchedEffect
if (!LocalDecodedImageCache.needsUpscale(current, target)) return@LaunchedEffect if (!LocalDecodedImageCache.needsUpscale(current, target)) return@LaunchedEffect
@@ -685,7 +710,8 @@ private fun ChatImageTileContent(
if (placeholderBitmap != null) decryptFinished = true if (placeholderBitmap != null) decryptFinished = true
} }
LaunchedEffect(fullBitmap, placeholderBitmap) { LaunchedEffect(fullBitmap, placeholderBitmap, freezeLayoutAspect) {
if (freezeLayoutAspect) return@LaunchedEffect
val bitmap = fullBitmap ?: placeholderBitmap ?: return@LaunchedEffect val bitmap = fullBitmap ?: placeholderBitmap ?: return@LaunchedEffect
onResolvedAspectRatio?.invoke(bitmap.width, bitmap.height) onResolvedAspectRatio?.invoke(bitmap.width, bitmap.height)
} }
@@ -703,11 +729,11 @@ private fun ChatImageTileContent(
} }
val suppressNetworkChrome = treatAsOutbound || hasLocalSource || hasDiskCache || val suppressNetworkChrome = treatAsOutbound || hasLocalSource || hasDiskCache ||
fullBitmap != null || placeholderBitmap != null || (isAuthor && localUri != null) fullBitmap != null || (isAuthor && localUri != null)
val isDownloadingFullImage = !treatAsOutbound && fullBitmap == null && val isDownloadingFullImage = !treatAsOutbound && fullBitmap == null &&
(downloadProgress != null || isAwaitingNetworkFull) && !suppressNetworkChrome (downloadProgress != null || isAwaitingNetworkFull) && !suppressNetworkChrome
val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay &&
!downloadCancelled && !hasLocalSource !downloadCancelled && !hasLocalSource && placeholderBitmap == null
val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !treatAsOutbound && val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !treatAsOutbound &&
!suppressNetworkChrome !suppressNetworkChrome
val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !treatAsOutbound && val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !treatAsOutbound &&
@@ -755,16 +781,22 @@ private fun ChatImageTileContent(
} }
AttachmentTileVisual.Thumb -> { AttachmentTileVisual.Thumb -> {
placeholderBitmap?.let { thumb -> placeholderBitmap?.let { thumb ->
Box( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier
.fillMaxSize()
.hazeEffect(style = HazeMaterials.thin()),
) {
CachedAttachmentImage( CachedAttachmentImage(
bitmap = thumb, bitmap = thumb,
contentDescription = serverFile?.name, contentDescription = serverFile?.name,
contentScale = imageContentScale, contentScale = imageContentScale,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.hazeSource(thumbHazeState),
)
Box(
modifier = Modifier
.fillMaxSize()
.hazeEffect(
state = thumbHazeState,
style = HazeMaterials.thin(),
),
) )
} }
} }
@@ -12,11 +12,18 @@ import androidx.compose.ui.unit.dp
internal val BUBBLE_RADIUS_LARGE = 20.dp internal val BUBBLE_RADIUS_LARGE = 20.dp
internal val BUBBLE_RADIUS_SMALL = 4.dp internal val BUBBLE_RADIUS_SMALL = 4.dp
internal data class AnimatedBubbleRadii(
val topStart: Dp,
val topEnd: Dp,
val bottomStart: Dp,
val bottomEnd: Dp,
)
@Composable @Composable
internal fun rememberAnimatedBubbleShape( internal fun rememberAnimatedBubbleRadii(
isAuthor: Boolean, isAuthor: Boolean,
group: MessageGroupInfo, group: MessageGroupInfo,
): RoundedCornerShape { ): AnimatedBubbleRadii {
val large = BUBBLE_RADIUS_LARGE val large = BUBBLE_RADIUS_LARGE
val small = BUBBLE_RADIUS_SMALL val small = BUBBLE_RADIUS_SMALL
@@ -47,7 +54,7 @@ internal fun rememberAnimatedBubbleShape(
val bottomStart by animateDpAsState(bottomStartTarget, springSpec, label = "bubbleBottomStart") val bottomStart by animateDpAsState(bottomStartTarget, springSpec, label = "bubbleBottomStart")
val bottomEnd by animateDpAsState(bottomEndTarget, springSpec, label = "bubbleBottomEnd") val bottomEnd by animateDpAsState(bottomEndTarget, springSpec, label = "bubbleBottomEnd")
return RoundedCornerShape( return AnimatedBubbleRadii(
topStart = topStart, topStart = topStart,
topEnd = topEnd, topEnd = topEnd,
bottomStart = bottomStart, bottomStart = bottomStart,
@@ -55,6 +62,20 @@ internal fun rememberAnimatedBubbleShape(
) )
} }
@Composable
internal fun rememberAnimatedBubbleShape(
isAuthor: Boolean,
group: MessageGroupInfo,
): RoundedCornerShape {
val radii = rememberAnimatedBubbleRadii(isAuthor, group)
return RoundedCornerShape(
topStart = radii.topStart,
topEnd = radii.topEnd,
bottomStart = radii.bottomStart,
bottomEnd = radii.bottomEnd,
)
}
internal fun bubbleTopRadii( internal fun bubbleTopRadii(
isAuthor: Boolean, isAuthor: Boolean,
group: MessageGroupInfo, group: MessageGroupInfo,
@@ -67,3 +88,16 @@ internal fun bubbleTopRadii(
(if (group.hasSameAuthorAbove) small else large) to large (if (group.hasSameAuthorAbove) small else large) to large
} }
} }
internal fun bubbleBottomRadii(
isAuthor: Boolean,
group: MessageGroupInfo,
): Pair<Dp, Dp> {
val large = BUBBLE_RADIUS_LARGE
val small = BUBBLE_RADIUS_SMALL
return if (isAuthor) {
large to if (group.hasSameAuthorBelow) small else large
} else {
(if (group.hasSameAuthorBelow) small else large) to large
}
}
@@ -109,9 +109,7 @@ private fun isMessageCorrupted(message: Message): Boolean {
} }
/** /**
* Scales layout height with the enter animation so the list pushes older * Scales layout height with the exit animation so the list collapses in real time.
* messages up in real time. Visual scale is applied separately via graphicsLayer
* on the bubble (with a correct transform origin).
*/ */
private fun Modifier.enterLayoutHeight( private fun Modifier.enterLayoutHeight(
scale: Float, scale: Float,
@@ -132,6 +130,44 @@ private fun Modifier.enterLayoutHeight(
} }
} }
/**
* Scales bubble layout and drawing together during enter so width and height stay
* proportional (avoids tall narrow strips while the list slot grows).
*/
private fun Modifier.enterBubbleScale(
scale: Float,
transformOrigin: TransformOrigin,
active: Boolean,
): Modifier {
if (!active) return this
return layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val layoutScale = scale.coerceAtLeast(0f)
val width = (placeable.width * layoutScale).roundToInt().coerceAtLeast(
if (layoutScale > 0f) 1 else 0,
)
val height = (placeable.height * layoutScale).roundToInt().coerceAtLeast(
if (layoutScale > 0f) 1 else 0,
)
val anchorX = if (transformOrigin.pivotFractionX >= 0.5f) {
width - placeable.width
} else {
0
}
layout(width, height) {
placeable.placeRelativeWithLayer(
x = anchorX,
y = height - placeable.height,
) {
scaleX = layoutScale
scaleY = layoutScale
this.transformOrigin = transformOrigin
alpha = if (layoutScale <= 0.001f) 0f else 1f
}
}
}
}
@Composable @Composable
fun MessageItem( fun MessageItem(
message: Message, message: Message,
@@ -167,6 +203,27 @@ fun MessageItem(
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) { val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
isMessageCorrupted(message) isMessageCorrupted(message)
} }
val primaryIsImageMessage = remember(
message.reply_to,
message.pendingFileUri,
message.pendingFilename,
message.files,
) {
val pendingIsImage = when {
message.pendingFileUri != null &&
DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri) -> true
message.pendingFilename?.isNotBlank() == true ->
isImageFilename(message.pendingFilename)
message.pendingFileUri != null -> isImageFilename(
message.pendingFileUri.substringAfterLast('/').substringBefore('?')
)
else -> false
}
message.reply_to == null && (
pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
}
val formattedTime = remember(message.timestamp) { val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp) formatMessageTimeLocal(message.timestamp)
} }
@@ -264,18 +321,22 @@ fun MessageItem(
"enterFinished=$enterFinished scale=${enterScale.value}", "enterFinished=$enterFinished scale=${enterScale.value}",
) )
} }
LaunchedEffect(enterIdentity, isNewEnterRole) { LaunchedEffect(enterIdentity, isNewEnterRole, primaryIsImageMessage) {
if (isNewEnterRole && !enterStarted) { if (isNewEnterRole && primaryIsImageMessage) {
enterStarted = true
enterFinished = true
enterScale.snapTo(1f)
} else if (isNewEnterRole && !enterStarted) {
enterStarted = true enterStarted = true
enterFinished = false enterFinished = false
enterScale.snapTo(0f) enterScale.snapTo(0f)
} }
} }
val runEnterAnimation = enterStarted && !enterFinished && !isExiting val runEnterAnimation =
val shrinkLayoutForEnter = isNewEnterRole && runEnterAnimation enterStarted && !enterFinished && !isExiting && !primaryIsImageMessage
// Single effect: start the spring as soon as this bubble is marked for enter. // Single effect: start the spring as soon as this bubble is marked for enter.
LaunchedEffect(enterIdentity, runEnterAnimation) { LaunchedEffect(enterIdentity, runEnterAnimation, primaryIsImageMessage) {
if (!runEnterAnimation) return@LaunchedEffect if (primaryIsImageMessage || !runEnterAnimation) return@LaunchedEffect
Logger.d( Logger.d(
"EnterAnim", "EnterAnim",
"spring_start identity=${enterIdentity.take(12)} " + "spring_start identity=${enterIdentity.take(12)} " +
@@ -425,9 +486,9 @@ fun MessageItem(
.then(rowLongPress) .then(rowLongPress)
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.enterLayoutHeight( .enterLayoutHeight(
scale = if (isExiting) layoutCollapse.value else enterScale.value, scale = layoutCollapse.value,
minHeightPx = minEnterHeightPx, minHeightPx = minEnterHeightPx,
active = shrinkLayoutForEnter || isExiting, active = isExiting,
), ),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@@ -508,9 +569,7 @@ fun MessageItem(
} else { } else {
null null
} }
val firstContentIsImage = ( val primaryIsImageContent = message.reply_to == null && (
!showUsername || isAuthor
) && message.reply_to == null && (
pendingIsImage || pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
) )
@@ -547,15 +606,11 @@ fun MessageItem(
Column( Column(
horizontalAlignment = horizontalAlignment =
if (isAuthor) Alignment.End else Alignment.Start, if (isAuthor) Alignment.End else Alignment.Start,
modifier = Modifier.graphicsLayer { modifier = Modifier.enterBubbleScale(
if (runEnterAnimation) { scale = enterScale.value,
val s = enterScale.value transformOrigin = enterTransformOrigin,
scaleX = s active = runEnterAnimation,
scaleY = s ),
transformOrigin = enterTransformOrigin
alpha = if (s <= 0.001f) 0f else 1f
}
},
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -575,8 +630,8 @@ fun MessageItem(
} }
) )
.padding( .padding(
top = if (firstContentIsImage) 0.dp else 8.dp, top = if (primaryIsImageContent) 0.dp else 8.dp,
bottom = 8.dp, bottom = if (primaryIsImageContent && !hasImageCaption) 0.dp else 8.dp,
) )
) { ) {
Column(modifier = Modifier.width(IntrinsicSize.Max)) { Column(modifier = Modifier.width(IntrinsicSize.Max)) {
@@ -816,7 +871,8 @@ fun MessageItem(
onCancelUpload = onCancelUpload, onCancelUpload = onCancelUpload,
messageGroup = group, messageGroup = group,
expandToBubbleWidth = hasImageCaption, expandToBubbleWidth = hasImageCaption,
modifier = if (firstContentIsImage) { freezeLayoutAspect = runEnterAnimation,
modifier = if (primaryIsImageContent) {
Modifier.padding(all = 2.dp) Modifier.padding(all = 2.dp)
} else { } else {
Modifier.padding( Modifier.padding(
@@ -947,11 +1003,8 @@ fun MessageItem(
messageGroup = group, messageGroup = group,
expandToBubbleWidth = expandToBubbleWidth =
isImage && hasImageCaption, isImage && hasImageCaption,
modifier = if ( freezeLayoutAspect = runEnterAnimation,
isFirstImage && modifier = if (isFirstImage && primaryIsImageContent && isImage) {
firstContentIsImage &&
isImage
) {
Modifier.padding(all = 2.dp) Modifier.padding(all = 2.dp)
} else { } else {
Modifier.padding( Modifier.padding(
@@ -1,6 +1,7 @@
package ru.fromchat.ui.chat.utils package ru.fromchat.ui.chat.utils
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
@@ -14,7 +15,9 @@ import ru.fromchat.api.local.db.isPlaceholderAttachmentDimensions
import ru.fromchat.api.local.download.ChatPreviewDecodeSize import ru.fromchat.api.local.download.ChatPreviewDecodeSize
import ru.fromchat.api.local.download.LocalDecodedImageCache import ru.fromchat.api.local.download.LocalDecodedImageCache
import ru.fromchat.ui.chat.MessageGroupInfo import ru.fromchat.ui.chat.MessageGroupInfo
import ru.fromchat.ui.chat.bubbleBottomRadii
import ru.fromchat.ui.chat.bubbleTopRadii import ru.fromchat.ui.chat.bubbleTopRadii
import ru.fromchat.ui.chat.rememberAnimatedBubbleRadii
/** Max attachment preview width in chat bubbles (160dp × 1.3). */ /** Max attachment preview width in chat bubbles (160dp × 1.3). */
internal val ATTACHMENT_TILE_MAX_WIDTH = 208.dp internal val ATTACHMENT_TILE_MAX_WIDTH = 208.dp
@@ -28,9 +31,6 @@ internal val ATTACHMENT_TILE_MIN_SHORT_EDGE = 104.dp
/** Padding between bubble edge and attachment image (must match MessageItem image padding). */ /** Padding between bubble edge and attachment image (must match MessageItem image padding). */
internal val ATTACHMENT_IMAGE_INSET = 2.dp internal val ATTACHMENT_IMAGE_INSET = 2.dp
/** Slight rounding on image preview bottom corners (less than bubble). */
private val IMAGE_BOTTOM_CORNER = 4.dp
/** /**
* Fixed dp tile size from max bounds + aspect ratio. * Fixed dp tile size from max bounds + aspect ratio.
* *
@@ -111,7 +111,22 @@ internal fun Modifier.attachmentTileLayout(
} }
} }
/** Inner clip: top corners follow bubble minus inset; bottom corners lightly rounded. */ private fun attachmentImageCornerShapeFromBubbleRadii(
topStart: Dp,
topEnd: Dp,
bottomStart: Dp,
bottomEnd: Dp,
): RoundedCornerShape {
val inset = ATTACHMENT_IMAGE_INSET
return RoundedCornerShape(
topStart = (topStart - inset).coerceAtLeast(0.dp),
topEnd = (topEnd - inset).coerceAtLeast(0.dp),
bottomStart = (bottomStart - inset).coerceAtLeast(0.dp),
bottomEnd = (bottomEnd - inset).coerceAtLeast(0.dp),
)
}
/** Inner clip: corners follow the bubble minus [ATTACHMENT_IMAGE_INSET]. */
internal fun attachmentImageCornerShape( internal fun attachmentImageCornerShape(
isAuthor: Boolean, isAuthor: Boolean,
group: MessageGroupInfo = MessageGroupInfo( group: MessageGroupInfo = MessageGroupInfo(
@@ -119,13 +134,27 @@ internal fun attachmentImageCornerShape(
hasSameAuthorBelow = false, hasSameAuthorBelow = false,
), ),
): RoundedCornerShape { ): RoundedCornerShape {
val inset = ATTACHMENT_IMAGE_INSET
val (topStart, topEnd) = bubbleTopRadii(isAuthor, group) val (topStart, topEnd) = bubbleTopRadii(isAuthor, group)
return RoundedCornerShape( val (bottomStart, bottomEnd) = bubbleBottomRadii(isAuthor, group)
topStart = (topStart - inset).coerceAtLeast(0.dp), return attachmentImageCornerShapeFromBubbleRadii(
topEnd = (topEnd - inset).coerceAtLeast(0.dp), topStart = topStart,
bottomStart = IMAGE_BOTTOM_CORNER, topEnd = topEnd,
bottomEnd = IMAGE_BOTTOM_CORNER, bottomStart = bottomStart,
bottomEnd = bottomEnd,
)
}
@Composable
internal fun rememberAnimatedAttachmentImageCornerShape(
isAuthor: Boolean,
group: MessageGroupInfo,
): RoundedCornerShape {
val radii = rememberAnimatedBubbleRadii(isAuthor, group)
return attachmentImageCornerShapeFromBubbleRadii(
topStart = radii.topStart,
topEnd = radii.topEnd,
bottomStart = radii.bottomStart,
bottomEnd = radii.bottomEnd,
) )
} }