mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -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,10 +291,18 @@ 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) {
|
||||||
if (resolved != effectiveAspect) {
|
val resolved = preferDecodedAspectRatio(
|
||||||
effectiveAspect = resolved
|
fileAspectRatio,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
if (resolved != effectiveAspect) {
|
||||||
|
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,63 +466,70 @@ 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)
|
||||||
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 {
|
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://"),
|
decodeTarget,
|
||||||
quickDecode,
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (loaded != null) {
|
if (loaded != null) {
|
||||||
AttachmentMediaLog.tileLoad(
|
AttachmentMediaLog.tileLoad(
|
||||||
@@ -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(
|
||||||
|
|||||||
+39
-10
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user