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 bed2314..0184c39 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 @@ -69,8 +69,10 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.pr0gramm3r101.utils.crypto.Base64 import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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.ChatPreviewDecodeSize 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.schema.messages.dm.DmEnvelope 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.cd_attachment_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.CancellableAttachmentProgressIndicator import ru.fromchat.ui.chat.components.ChatFileAttachmentTile 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.attachmentImageCornerShape 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.chat.utils.rememberAnimatedAttachmentImageCornerShape import ru.fromchat.ui.components.Text import com.pr0gramm3r101.utils.scaleOnPress -import ru.fromchat.ui.chat.MessageGroupInfo 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). */ expandToBubbleWidth: Boolean = false, + /** While true, keep the tile aspect fixed (message enter animation). */ + freezeLayoutAspect: Boolean = false, modifier: Modifier = Modifier, ) { val isImage = when { @@ -202,12 +204,21 @@ fun AttachmentPreview( ) } showImageTile -> { + val imageCornerShape = rememberAnimatedAttachmentImageCornerShape( + isAuthor = isAuthor, + group = messageGroup, + ) var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) { mutableStateOf(false) } var effectiveAspect by remember(messageId, fileIndex, file?.path, pendingFileUri, fileAspectRatio) { mutableStateOf(fileAspectRatio) } + LaunchedEffect(fileAspectRatio) { + fileAspectRatio?.takeIf { it.isFinite() && it > 0f }?.let { aspect -> + effectiveAspect = aspect + } + } Box( modifier = modifier .then( @@ -227,7 +238,7 @@ fun AttachmentPreview( aspectRatio = effectiveAspect, expandToBubbleWidth = expandToBubbleWidth, ) - .clip(attachmentImageCornerShape(isAuthor, messageGroup)) + .clip(imageCornerShape) .then( if (onImageBounds != null && showImageTile) { Modifier.onGloballyPositioned { coords -> @@ -280,10 +291,18 @@ fun AttachmentPreview( onCancelUpload = onCancelUpload, onFullyLoaded = { if (it) isFullyLoaded = true }, messageGroup = messageGroup, + imageCornerShape = imageCornerShape, + freezeLayoutAspect = freezeLayoutAspect, onResolvedAspectRatio = { width, height -> - val resolved = preferDecodedAspectRatio(fileAspectRatio, width, height) - if (resolved != effectiveAspect) { - effectiveAspect = resolved + if (!freezeLayoutAspect) { + val resolved = preferDecodedAspectRatio( + fileAspectRatio, + width, + height, + ) + if (resolved != effectiveAspect) { + effectiveAspect = resolved + } } }, ) @@ -320,23 +339,21 @@ private fun ChatImageTileContent( hasSameAuthorAbove = false, hasSameAuthorBelow = false, ), + imageCornerShape: RoundedCornerShape? = null, + freezeLayoutAspect: Boolean = false, onResolvedAspectRatio: ((width: Int, height: Int) -> Unit)? = null, ) { val scope = rememberCoroutineScope() - val clipShape = attachmentImageCornerShape(isAuthor, messageGroup) + val clipShape = imageCornerShape ?: attachmentImageCornerShape(isAuthor, messageGroup) val cacheClientId = clientMessageId?.trim()?.takeIf { it.isNotEmpty() } 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) { attachmentDecodeCacheKeys(messageId, fileIndex, cacheClientId) } val bitmapStateKey = remember(decodeCacheKeys) { decodeCacheKeys.joinToString("|") } val decryptCacheKey = decodeCacheKeys.first() var tileDecodeSize by remember(bitmapStateKey) { mutableStateOf(null) } - val decodeSize = remember(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) { - coalesceDecodeTarget(tileDecodeSize, fallbackDecodeSize, seedDecodeSize) - } + val tileLoadTarget = remember(bitmapStateKey) { previewSeedDecodeSize(layoutAspect) } var cachedPath by remember(bitmapStateKey) { mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) @@ -346,7 +363,12 @@ private fun ChatImageTileContent( val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri 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) { 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 thumbHazeState = rememberHazeState(blurEnabled = true) LaunchedEffect(messageId, fileIndex, cacheClientId) { DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri -> @@ -443,63 +466,70 @@ private fun ChatImageTileContent( } } + // Keys omit localUri/diskCacheUri: Success patches pendingFileUri and would cancel mid-download. LaunchedEffect( decryptCacheKey, - localUri, - diskCacheUri, serverFile?.path, messageId, isOutboundPending, cacheClientId, loadAttempt, - decodeSize, ) { + val decodeTarget = tileLoadTarget AttachmentMediaLog.tileLoad( "load_start", "key" to decryptCacheKey, "msgId" to messageId, "pending" to isOutboundPending, "localUri" to (localUri?.take(48) ?: "null"), - "diskCache" to (diskCacheUri?.take(48) ?: "null"), - "target" to "${decodeSize.widthPx}x${decodeSize.heightPx}", + "diskCache" to (DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.take(48) ?: "null"), + "target" to "${decodeTarget.widthPx}x${decodeTarget.heightPx}", ) - // Already showing a decoded preview — never flash failed/loading by re-fetching. - val cachedBitmap = fullBitmap ?: peekDecodedAttachmentBitmap(decodeCacheKeys) - if (cachedBitmap != null && (isOutboundPending || isUploading || awaitingServerAck || - isAuthor || hasLocalSource) - ) { - if (fullBitmap == null) { - fullBitmap = cachedBitmap - } + + fun acceptFull(bitmap: ImageBitmap, path: String?) { + fullBitmap = bitmap + if (path != null) cachedPath = path decryptFinished = true AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) onFullyLoaded(true) - AttachmentMediaLog.send( - "tile_keep_local", - "key" to decryptCacheKey, - "msgId" to messageId, - "pending" to isOutboundPending, - "uploading" to isUploading, - ) - return@LaunchedEffect } - val diskUri = diskCacheUri - ?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.also { cachedPath = it } + + val peeked = fullBitmap ?: peekDecodedAttachmentBitmap(decodeCacheKeys) + if (peeked != null) { + val keep = isOutboundPending || isUploading || awaitingServerAck || isAuthor || + isPreviewQuality(peeked) + if (keep) { + acceptFull(peeked, cachedPath ?: localUri) + AttachmentMediaLog.send( + "tile_keep_local", + "key" to decryptCacheKey, + "msgId" to messageId, + "pending" to isOutboundPending, + "uploading" to isUploading, + "quality" to "${peeked.width}x${peeked.height}", + ) + return@LaunchedEffect + } + // Soft in-memory decode: drop so we show the thumb until HQ is ready. + if (fullBitmap != null) fullBitmap = null + LocalDecodedImageCache.evict(decryptCacheKey) + } + + val diskUri = DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId) + ?.also { if (cachedPath != it) cachedPath = it } val localPaths = buildList { localUri?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) } diskUri?.let { cached -> if (none { it == cached }) add(cached) } } if (localPaths.isNotEmpty()) { - val quickDecode = previewSeedDecodeSize(aspectRatio) val loaded = withContext(Dispatchers.Default) { - peekDecodedAttachmentBitmap(decodeCacheKeys) - ?: localPaths.firstNotNullOfOrNull { path -> - LocalDecodedImageCache.loadFull( - decryptCacheKey, - path.removePrefix("file://"), - quickDecode, - ) - } + localPaths.firstNotNullOfOrNull { path -> + LocalDecodedImageCache.loadFull( + decryptCacheKey, + path.removePrefix("file://"), + decodeTarget, + ) + } } if (loaded != null) { AttachmentMediaLog.tileLoad( @@ -508,13 +538,10 @@ private fun ChatImageTileContent( "source" to "disk_or_pending", "bmp" to "${loaded.width}x${loaded.height}", ) - fullBitmap = loaded - 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. + acceptFull(loaded, diskUri ?: localPaths.firstOrNull()) if (isOutboundPending || isAuthor) return@LaunchedEffect + // Inbound: local HQ is enough — don't hit network again. + if (isPreviewQuality(loaded)) return@LaunchedEffect } else { AttachmentMediaLog.tileLoad( "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 (fullBitmap == null && localPaths.isEmpty()) { DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri -> @@ -532,14 +558,11 @@ private fun ChatImageTileContent( LocalDecodedImageCache.loadFull( decryptCacheKey, uri.removePrefix("file://"), - decodeSize, + decodeTarget, ) } if (loaded != null) { - fullBitmap = loaded - decryptFinished = true - AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) - onFullyLoaded(true) + acceptFull(loaded, uri) AttachmentMediaLog.send( "tile_cache_hit", "key" to decryptCacheKey, @@ -574,11 +597,12 @@ private fun ChatImageTileContent( AttachmentMediaLog.tileLoad("load_skip_no_file", "key" to decryptCacheKey) return@LaunchedEffect } - if (diskUri != null) { - cachedPath = diskUri + if (diskUri != null && fullBitmap == null) { val loaded = withContext(Dispatchers.Default) { - LocalDecodedImageCache.loadFull(decryptCacheKey, diskUri.removePrefix("file://"), - decodeSize + LocalDecodedImageCache.loadFull( + decryptCacheKey, + diskUri.removePrefix("file://"), + decodeTarget, ) } if (loaded != null) { @@ -588,9 +612,7 @@ private fun ChatImageTileContent( "uri" to diskUri, "bmp" to "${loaded.width}x${loaded.height}", ) - fullBitmap = loaded - decryptFinished = true - onFullyLoaded(true) + acceptFull(loaded, diskUri) return@LaunchedEffect } AttachmentMediaLog.tileLoad( @@ -626,6 +648,8 @@ private fun ChatImageTileContent( messageLabel = messageLabel, ) } + } catch (error: kotlinx.coroutines.CancellationException) { + throw error } catch (error: Throwable) { AttachmentMediaLog.tileLoad( "load_exception", @@ -637,28 +661,29 @@ private fun ChatImageTileContent( } finally { isAwaitingNetworkFull = false } - cachedPath = uri + if (uri != null) cachedPath = uri if (uri != null) { - fullBitmap = withContext(Dispatchers.Default) { - LocalDecodedImageCache.loadFull(decryptCacheKey, uri.removePrefix("file://"), - decodeSize + val loaded = withContext(Dispatchers.Default) { + LocalDecodedImageCache.loadFull( + decryptCacheKey, + uri.removePrefix("file://"), + decodeTarget, ) } + if (loaded != null) acceptFull(loaded, uri) + else decryptFinished = true + } else { + decryptFinished = true } - decryptFinished = true AttachmentMediaLog.tileLoad( if (fullBitmap != null) "load_done" else "load_failed", "key" to decryptCacheKey, "uri" to uri, "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 current = fullBitmap ?: return@LaunchedEffect if (!LocalDecodedImageCache.needsUpscale(current, target)) return@LaunchedEffect @@ -685,7 +710,8 @@ private fun ChatImageTileContent( if (placeholderBitmap != null) decryptFinished = true } - LaunchedEffect(fullBitmap, placeholderBitmap) { + LaunchedEffect(fullBitmap, placeholderBitmap, freezeLayoutAspect) { + if (freezeLayoutAspect) return@LaunchedEffect val bitmap = fullBitmap ?: placeholderBitmap ?: return@LaunchedEffect onResolvedAspectRatio?.invoke(bitmap.width, bitmap.height) } @@ -703,11 +729,11 @@ private fun ChatImageTileContent( } val suppressNetworkChrome = treatAsOutbound || hasLocalSource || hasDiskCache || - fullBitmap != null || placeholderBitmap != null || (isAuthor && localUri != null) + fullBitmap != null || (isAuthor && localUri != null) val isDownloadingFullImage = !treatAsOutbound && fullBitmap == null && (downloadProgress != null || isAwaitingNetworkFull) && !suppressNetworkChrome val showDownloadProgressOverlay = isDownloadingFullImage && !showOutboundBlurOverlay && - !downloadCancelled && !hasLocalSource + !downloadCancelled && !hasLocalSource && placeholderBitmap == null val showDownloadCancelledOverlay = downloadCancelled && fullBitmap == null && !treatAsOutbound && !suppressNetworkChrome val showLoadFailedOverlay = decryptFailed && fullBitmap == null && !treatAsOutbound && @@ -755,16 +781,22 @@ private fun ChatImageTileContent( } AttachmentTileVisual.Thumb -> { placeholderBitmap?.let { thumb -> - Box( - modifier = Modifier - .fillMaxSize() - .hazeEffect(style = HazeMaterials.thin()), - ) { + Box(modifier = Modifier.fillMaxSize()) { CachedAttachmentImage( bitmap = thumb, contentDescription = serverFile?.name, contentScale = imageContentScale, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .hazeSource(thumbHazeState), + ) + Box( + modifier = Modifier + .fillMaxSize() + .hazeEffect( + state = thumbHazeState, + style = HazeMaterials.thin(), + ), ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt index 12ccde7..3376572 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageBubbleShape.kt @@ -12,11 +12,18 @@ import androidx.compose.ui.unit.dp internal val BUBBLE_RADIUS_LARGE = 20.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 -internal fun rememberAnimatedBubbleShape( +internal fun rememberAnimatedBubbleRadii( isAuthor: Boolean, group: MessageGroupInfo, -): RoundedCornerShape { +): AnimatedBubbleRadii { val large = BUBBLE_RADIUS_LARGE val small = BUBBLE_RADIUS_SMALL @@ -47,7 +54,7 @@ internal fun rememberAnimatedBubbleShape( val bottomStart by animateDpAsState(bottomStartTarget, springSpec, label = "bubbleBottomStart") val bottomEnd by animateDpAsState(bottomEndTarget, springSpec, label = "bubbleBottomEnd") - return RoundedCornerShape( + return AnimatedBubbleRadii( topStart = topStart, topEnd = topEnd, 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( isAuthor: Boolean, group: MessageGroupInfo, @@ -67,3 +88,16 @@ internal fun bubbleTopRadii( (if (group.hasSameAuthorAbove) small else large) to large } } + +internal fun bubbleBottomRadii( + isAuthor: Boolean, + group: MessageGroupInfo, +): Pair { + 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 + } +} 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 5b2781c..9604745 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 @@ -109,9 +109,7 @@ private fun isMessageCorrupted(message: Message): Boolean { } /** - * Scales layout height with the enter animation so the list pushes older - * messages up in real time. Visual scale is applied separately via graphicsLayer - * on the bubble (with a correct transform origin). + * Scales layout height with the exit animation so the list collapses in real time. */ private fun Modifier.enterLayoutHeight( 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 fun MessageItem( message: Message, @@ -167,6 +203,27 @@ fun MessageItem( val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) { 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) { formatMessageTimeLocal(message.timestamp) } @@ -264,18 +321,22 @@ fun MessageItem( "enterFinished=$enterFinished scale=${enterScale.value}", ) } - LaunchedEffect(enterIdentity, isNewEnterRole) { - if (isNewEnterRole && !enterStarted) { + LaunchedEffect(enterIdentity, isNewEnterRole, primaryIsImageMessage) { + if (isNewEnterRole && primaryIsImageMessage) { + enterStarted = true + enterFinished = true + enterScale.snapTo(1f) + } else if (isNewEnterRole && !enterStarted) { enterStarted = true enterFinished = false enterScale.snapTo(0f) } } - val runEnterAnimation = enterStarted && !enterFinished && !isExiting - val shrinkLayoutForEnter = isNewEnterRole && runEnterAnimation + val runEnterAnimation = + enterStarted && !enterFinished && !isExiting && !primaryIsImageMessage // Single effect: start the spring as soon as this bubble is marked for enter. - LaunchedEffect(enterIdentity, runEnterAnimation) { - if (!runEnterAnimation) return@LaunchedEffect + LaunchedEffect(enterIdentity, runEnterAnimation, primaryIsImageMessage) { + if (primaryIsImageMessage || !runEnterAnimation) return@LaunchedEffect Logger.d( "EnterAnim", "spring_start identity=${enterIdentity.take(12)} " + @@ -425,9 +486,9 @@ fun MessageItem( .then(rowLongPress) .padding(horizontal = 8.dp) .enterLayoutHeight( - scale = if (isExiting) layoutCollapse.value else enterScale.value, + scale = layoutCollapse.value, minHeightPx = minEnterHeightPx, - active = shrinkLayoutForEnter || isExiting, + active = isExiting, ), horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, verticalAlignment = Alignment.Bottom, @@ -508,9 +569,7 @@ fun MessageItem( } else { null } - val firstContentIsImage = ( - !showUsername || isAuthor - ) && message.reply_to == null && ( + val primaryIsImageContent = message.reply_to == null && ( pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true ) @@ -547,15 +606,11 @@ fun MessageItem( Column( horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start, - modifier = Modifier.graphicsLayer { - if (runEnterAnimation) { - val s = enterScale.value - scaleX = s - scaleY = s - transformOrigin = enterTransformOrigin - alpha = if (s <= 0.001f) 0f else 1f - } - }, + modifier = Modifier.enterBubbleScale( + scale = enterScale.value, + transformOrigin = enterTransformOrigin, + active = runEnterAnimation, + ), ) { Box( modifier = Modifier @@ -575,8 +630,8 @@ fun MessageItem( } ) .padding( - top = if (firstContentIsImage) 0.dp else 8.dp, - bottom = 8.dp, + top = if (primaryIsImageContent) 0.dp else 8.dp, + bottom = if (primaryIsImageContent && !hasImageCaption) 0.dp else 8.dp, ) ) { Column(modifier = Modifier.width(IntrinsicSize.Max)) { @@ -816,7 +871,8 @@ fun MessageItem( onCancelUpload = onCancelUpload, messageGroup = group, expandToBubbleWidth = hasImageCaption, - modifier = if (firstContentIsImage) { + freezeLayoutAspect = runEnterAnimation, + modifier = if (primaryIsImageContent) { Modifier.padding(all = 2.dp) } else { Modifier.padding( @@ -947,11 +1003,8 @@ fun MessageItem( messageGroup = group, expandToBubbleWidth = isImage && hasImageCaption, - modifier = if ( - isFirstImage && - firstContentIsImage && - isImage - ) { + freezeLayoutAspect = runEnterAnimation, + modifier = if (isFirstImage && primaryIsImageContent && isImage) { Modifier.padding(all = 2.dp) } else { Modifier.padding( 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 775bf92..33c3eb0 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 @@ -1,6 +1,7 @@ package ru.fromchat.ui.chat.utils import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layout 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.LocalDecodedImageCache import ru.fromchat.ui.chat.MessageGroupInfo +import ru.fromchat.ui.chat.bubbleBottomRadii import ru.fromchat.ui.chat.bubbleTopRadii +import ru.fromchat.ui.chat.rememberAnimatedBubbleRadii /** Max attachment preview width in chat bubbles (160dp × 1.3). */ 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). */ 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. * @@ -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( isAuthor: Boolean, group: MessageGroupInfo = MessageGroupInfo( @@ -119,13 +134,27 @@ internal fun attachmentImageCornerShape( hasSameAuthorBelow = false, ), ): RoundedCornerShape { - val inset = ATTACHMENT_IMAGE_INSET val (topStart, topEnd) = bubbleTopRadii(isAuthor, group) - return RoundedCornerShape( - topStart = (topStart - inset).coerceAtLeast(0.dp), - topEnd = (topEnd - inset).coerceAtLeast(0.dp), - bottomStart = IMAGE_BOTTOM_CORNER, - bottomEnd = IMAGE_BOTTOM_CORNER, + val (bottomStart, bottomEnd) = bubbleBottomRadii(isAuthor, group) + return attachmentImageCornerShapeFromBubbleRadii( + topStart = topStart, + topEnd = topEnd, + 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, ) }