android, desktop: blur media thumbnails in reply quotes and the chat list (#7639)

"Blur media" hid images and videos in a chat but showed the same media
sharp as the thumbnail of a quoted message and as the last message's
image, video or link preview in the chat list.

Small views are now blurred with the same reveal as chat media. They are
resampled relative to the width they are drawn at, so a thumbnail is
blurred as much on screen as media in a chat, and drawn as the centre
crop the thumbnail shows. In-chat media keeps the same blur.

Blurring the chat list also needs: each last message gets its own blur
state, loaded chat-list videos use it and hide their full-screen play
button while blurred, a right-click on a thumbnail no longer leaves it
revealed on desktop, and leaving a chat mid-scroll no longer undoes
reveals in the list on Android.

Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com>
This commit is contained in:
EvgenyandNarasimha-sc authored and GitHub committed 2026-10-03 07:17:20 +01:00
1 parent 205d17e2fa
commit 53e6d435bc
8 files changed
+174 -33

No files matched your search

@@ -3,9 +3,12 @@ package chat.simplex.common.platform
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.KeyChangeEffect
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
@Composable
fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
fun Modifier.desktopModifyBlurredState(blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
if (appPlatform.isDesktop) {
KeyChangeEffect(blurRadius.value) {
blurred.value = enabled && blurRadius.value > 0
blurred.value = blurRadius.value > 0
}
}
return if (appPlatform.isDesktop && enabled && blurRadius.value > 0 && !showMenu.value) {
return if (appPlatform.isDesktop && blurRadius.value > 0 && !showMenu.value) {
var job: Job = remember { Job() }
LaunchedEffect(Unit) {
// The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering,
@@ -64,24 +67,31 @@ fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
@Composable
fun Modifier.privacyBlur(
enabled: Boolean,
fullSize: Boolean,
preview: ImageBitmap,
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
scrollState: State<Boolean>,
onLongClick: () -> Unit = {}
): Modifier {
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
return if (blurHidesMedia(enabled, blurred)) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
this then Modifier
.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
return if (blurHidesMedia(true, blurred)) {
this then (if (fullSize) {
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
} else Modifier.drawWithCache {
val cropScale = maxOf(size.width / preview.width, size.height / preview.height)
val croppedSize = IntSize((preview.width * cropScale).roundToInt(), (preview.height * cropScale).roundToInt())
val blurredCropped = preview.blurredBy(blurRadius.value, croppedSize.width.toDp().value)
val offset = IntOffset(((size.width - croppedSize.width) / 2).roundToInt(), ((size.height - croppedSize.height) / 2).roundToInt())
onDrawWithContent { clipRect { drawImage(blurredCropped, dstOffset = offset, dstSize = croppedSize) } }
})
.combinedClickable(
onLongClick = onLongClick,
onClick = {
blurred.value = false
}
)
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) {
} else if (blurRadius.value > 0 && appPlatform.isAndroid) {
LaunchedEffect(Unit) {
snapshotFlow { scrollState.value }
.filter { it }
@@ -96,12 +106,14 @@ fun Modifier.privacyBlur(
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
private const val BLURRED_MEDIA_WIDTH_DP = 400
// The width in-chat media is assumed to be drawn at; small views are blurred as much on screen.
private const val CHAT_MEDIA_WIDTH_DP = 360
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
private fun ImageBitmap.blurredBy(radius: Int): ImageBitmap {
private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap {
if (width <= 0 || height <= 0) return this
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
val w = (BLURRED_MEDIA_WIDTH_DP * drawnWidthDp / CHAT_MEDIA_WIDTH_DP / radius).toInt().coerceIn(1, width)
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
val longest = maxOf(width, height)
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
@@ -2463,6 +2463,7 @@ fun BoxScope.ChatItemsList(
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
.onCompletion { chatViewScrollState.value = false }
.collect {
chatViewScrollState.value = it
}
@@ -117,7 +117,7 @@ fun CIImageView(
}
@Composable
fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) {
fun ImageView(painter: Painter, image: String, onClick: () -> Unit) {
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
// but can display 5000px image. Using even lower value here just to feel safer.
@@ -149,11 +149,7 @@ fun CIImageView(
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
contentAlignment = Alignment.Center
) {
imageView(previewBitmap, onClick = {
if (fileSource != null) {
openFile(fileSource)
}
})
imageView(previewBitmap, onClick = onClick)
Icon(
painterResource(MR.images.ic_open_in_new),
contentDescription = stringResource(MR.strings.image_descr),
@@ -188,7 +184,7 @@ fun CIImageView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
@@ -214,7 +210,7 @@ fun CIImageView(
val loaded = if (revealed) res.value else null
if (loaded != null && file != null) {
val (imageBitmap, data, _) = loaded
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
} else {
imageView(previewBitmap, onClick = {
if (file != null) {
@@ -55,7 +55,7 @@ fun CIVideoView(
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
.desktopModifyBlurredState(blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
@@ -85,11 +85,11 @@ fun CIVideoView(
val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
val decrypted = uriDecrypted.value
if (decrypted != null && smallView) {
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else if (decrypted != null) {
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
} else if (smallView) {
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen)
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
} else {
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
}
@@ -185,16 +185,17 @@ private fun SmallVideoViewEncrypted(
defaultPreview: ImageBitmap,
autoPlay: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit,
) {
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
val onLongClick = { showMenu.value = true }
Box {
VideoPreviewImageView(defaultPreview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick)
if (decryptionInProgress) {
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
} else if (!file.showStatusIconInSmallView) {
} else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
decryptionInProgress = true
withBGApi {
@@ -217,6 +218,7 @@ private fun SmallVideoView(
defaultPreview: ImageBitmap,
defaultDuration: Long,
autoPlay: MutableState<Boolean>,
blurred: MutableState<Boolean>,
sizeMultiplier: Float,
openFullscreen: () -> Unit
) {
@@ -234,8 +236,8 @@ private fun SmallVideoView(
onLongClick = {},
{}
)
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView) {
VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
}
}
@@ -135,6 +135,7 @@ fun FramedItemView(
@Composable
fun ciQuoteView(qi: CIQuote) {
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
@@ -152,7 +153,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.image_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCVideo -> {
@@ -164,7 +165,7 @@ fun FramedItemView(
imageBitmap,
contentDescription = stringResource(MR.strings.video_descr),
contentScale = ContentScale.Crop,
modifier = Modifier.size(68.dp).clipToBounds()
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
)
}
is MsgContent.MCFile, is MsgContent.MCVoice -> {
@@ -311,26 +311,31 @@ fun ChatPreviewView(
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
}
val uriHandler = LocalUriHandler.current
// Media in the chat list has no menu, so a menu opened from it must close at once, or the media stays revealed.
val noMenu = remember { mutableStateOf(false) }
LaunchedEffect(noMenu.value) { noMenu.value = false }
when (mc) {
is MsgContent.MCLink -> SmallContentPreview {
val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) }
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
IconButton(
{ openBrowserAlert(mc.preview.uri, uriHandler) },
Modifier.desktopPointerHoverIconHand(),
) {
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
Image(image, null, Modifier.desktopModifyBlurredState(blurred, noMenu).privacyBlur(fullSize = false, image, blurred, chatViewScrollState.collectAsState()), contentScale = ContentScale.Crop)
}
Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) {
Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White)
}
}
is MsgContent.MCImage -> SmallContentPreview {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
}
is MsgContent.MCVideo -> SmallContentPreview {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -421,7 +426,7 @@ fun ChatPreviewView(
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
if (ci != null && showContentPreview) {
chatItemContentPreview(chat, ci)
key(ci.id) { chatItemContentPreview(chat, ci) }
}
if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) {
Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
@@ -206,7 +206,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
stringResource(MR.strings.image_descr_link_preview),
modifier = Modifier
.fillMaxWidth()
.desktopModifyBlurredState(true, blurred, showMenu)
.desktopModifyBlurredState(blurred, showMenu)
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
contentScale = ContentScale.FillWidth,
)