From 53e6d435bcbee7d315108150c1c619f5a06cee77 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 3 Oct 2026 07:17:20 +0100 Subject: [PATCH] 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> --- .../chat/simplex/common/platform/Modifier.kt | 34 +++-- .../simplex/common/views/chat/ChatView.kt | 1 + .../common/views/chat/item/CIImageView.kt | 12 +- .../common/views/chat/item/CIVideoView.kt | 16 ++- .../common/views/chat/item/FramedItemView.kt | 5 +- .../common/views/chatlist/ChatPreviewView.kt | 13 +- .../common/views/helpers/LinkPreviews.kt | 2 +- plans/2026-10-02-blur-media-thumbnails.md | 124 ++++++++++++++++++ 8 files changed, 174 insertions(+), 33 deletions(-) create mode 100644 plans/2026-10-02-blur-media-thumbnails.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index 5de9b7a4f4..caaf66811c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -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, showMenu: State,): Modifier { +fun Modifier.desktopModifyBlurredState(blurred: MutableState, showMenu: State,): 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 = @Composable fun Modifier.privacyBlur( - enabled: Boolean, + fullSize: Boolean, preview: ImageBitmap, blurred: MutableState = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }, scrollState: State, 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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 12f13a426f..0ff9c77442 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -2463,6 +2463,7 @@ fun BoxScope.ChatItemsList( LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } + .onCompletion { chatViewScrollState.value = false } .collect { chatViewScrollState.value = it } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 9bb0bc21a6..6b31f7f480 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -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?> = 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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index a6082b827a..db13cd30c0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -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, showMenu: MutableState, + blurred: MutableState, 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, + blurred: MutableState, 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) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index cbd15aca67..3d9ca3e0f4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -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 -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index fbc4c7e336..48253cb685 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -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)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index 0c6fec52f0..8aea99eb4e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -206,7 +206,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, 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, ) diff --git a/plans/2026-10-02-blur-media-thumbnails.md b/plans/2026-10-02-blur-media-thumbnails.md new file mode 100644 index 0000000000..6a2039a3da --- /dev/null +++ b/plans/2026-10-02-blur-media-thumbnails.md @@ -0,0 +1,124 @@ +# Blur media thumbnails in reply quotes and the chat list + +## Problem + +"Blur media" hides images and videos in a chat until they are tapped (on desktop, hovered), but +the same media is shown sharp in two other places: + +- the thumbnail of a quoted image or video in a reply (`FramedItemView.ciQuoteView`, 68 dp), in + every chat, search result, report and support chat; +- the chat list, where the last message's image, video or link preview is drawn as a 36 sp + thumbnail next to the message text (`ChatPreviewView.chatItemContentPreview`). + +The chat list is on screen most of the time, so it shows exactly what the setting promised to +hide. + +## Cause + +Small views were excluded on purpose: every call passed `privacyBlur(enabled = !smallView, ...)` +and `desktopModifyBlurredState(!smallView, ...)`, the quote thumbnail never called them, and the +chat-list link preview was a plain `Image`. The chat-list video views (`SmallVideoView`, +`SmallVideoViewEncrypted`) created their own `remember { mutableStateOf(false) }` blur state, so +they could not be blurred even if the modifier were enabled. + +Enabling the existing blur for small views is not enough on its own, because +`ImageBitmap.blurredBy` (#7483) resamples the preview to `400 / radius` pixels across the whole +image, a width calibrated for media drawn about 360 dp wide. Drawn into a 36 sp thumbnail, 33 px +at Soft is almost sharp. + +## Fix + +`privacyBlur` takes `fullSize` in place of `enabled`. Existing call sites keep passing +`!smallView` or `true`, so in-chat media takes the same code as before: the same `remember`ed +resample, drawn with `drawWithContent`. Small views (`fullSize = false`): + +- are resampled relative to their drawn width: `blurredBy` gets the drawn width, and the pixel + count is `400 * drawnWidth / 360 / radius`, so a thumbnail is blurred as much on screen as + media in a chat. The default width is 360, which gives exactly master's `400 / radius` for + in-chat media; +- are drawn as the centre crop of the image scaled by `ContentScale.Crop`, clipped to the view, + so the blurred thumbnail covers the same region as the revealed one and is blurred evenly in + both directions; a whole image squeezed into a square would be blurred up to 4x less along + its long side; +- use `drawWithCache`, so the blur is computed once per size and not on every frame. + +`enabled` is removed from `desktopModifyBlurredState`: once small views are blurred every caller +passes `true`. It stays on `blurHidesMedia`, which still has a real caller (`CIImageView`). + +Wiring: + +- quote thumbnails get one blur state per quote, the item's menu state, and the same reveal as + chat media: a tap reveals, a second tap scrolls to the quoted message, long-press opens the + item menu; +- the chat-list link preview gets a remembered bitmap and blur state; +- `SmallVideoView` and `SmallVideoViewEncrypted` take the item's real blur state, and their play + buttons, which open the video full screen, are hidden while it is blurred. + +## Leaks closed by the change + +Blurring the chat list exposed three ways for a thumbnail to be shown, or stay shown, without +the user revealing it: + +- the chat list is keyed by chat, not by message, so state remembered for one last message was + reused for the next: a new image arriving in a chat whose previous image was revealed would be + shown revealed. `key(ci.id)` around `chatItemContentPreview` gives each message its own state; +- the chat list passed CIImageView and CIVideoView a throwaway `showMenu` that their long-press + and right-click set to `true` and nothing reset. On desktop `desktopModifyBlurredState` stops + re-blurring on hover-out while `showMenu` is true, so a right-clicked thumbnail stayed sharp. + The chat list now passes `noMenu`, which a `LaunchedEffect` resets as soon as it is set: a + menu that closes at once; +- `chatViewScrollState` is a global written only by the chat view's list, and its collector was + cancelled without a final value when a chat closed. Leaving a chat while its list was still + flinging left it `true`, and every reveal in the chat list was undone at once by the Android + re-blur, until another chat was opened. The collector now writes `false` on completion. + +## Bounds + +The preview dimensions come from the sender. `base64ToBitmap` never returns a bitmap with a zero +side (it falls back to an error bitmap), so the crop scale is finite. A zero-size view gives a +zero crop, which `blurredBy` clamps to one pixel. `blurredBy` keeps its existing bounds: the +first step caps both sides at 512 px and the output height at 400 px; a very wide preview widens +the resample to at most its own width while its height drops to a few pixels. The doubling +ascent still targets 200 px, so a thumbnail holds a bitmap of up to 384 px (about 590 KB) - more +than it needs at 36 sp, harmless. + +## Base + +The change is built on `stable` after #7483 (blur media by resampling), because the small-view +blur extends #7483's resample. On stable, `SimpleAndAnimatedImageView` has no `blurred` +parameter, which came from #7365 (animated images) on master; neither #7483 nor this change +depends on it. + +## Verification + +Read-only so far; nothing on this branch has been built or run. + +- In-chat media: `(400f * 360 / 360 / r).toInt()` equals master's `400 / r` for every positive + radius, and the `fullSize` path is master's code re-indented. +- The centre crop was checked by simulating Compose's `ContentScale.Crop` / `Alignment.Center` + placement against the blur's crop for 125,388 combinations of preview size (1 to 10000 px, + extreme aspects), view size (0 to 600 px) and measurement mode: no pixel differed. +- `Modifier.kt` was compiled with Kotlin 2.1.20 and the Compose 1.8.2 plugin against the 1.8.2 + desktop jars, app references stubbed; the bytecode shows the `drawWithCache` lambda is + remembered on the preview and radius, so recomposition does not re-blur. +- Independent adversarial reviews, on master and again on the stable base, ended with two + consecutive passes finding no defect. + +To do before merging: build desktop and Android, and check with a test profile receiving an +image, a video and a link as a chat's last message, and a reply quoting each, at Soft, Medium and +Strong. + +## Out of scope + +- Keyboard and TalkBack: the outer click of a blurred video opens it full screen without + revealing it first. This already happens in a chat on stable and master; fixing it changes + in-chat behaviour, so it is a separate change. TalkBack also cannot reveal a blurred chat-list + image. +- Turning blur on does not re-blur media already on screen on Android (desktop does); chat-list + rows pick it up when recomposed from scratch. +- Chat-list reveals re-blur when a chat is scrolled, not when the chat list is. +- At 36 sp, Medium and Strong both resample to a single pixel: a flat colour. +- Chat-link cards (contact and group profile images), compose-bar previews of your own media and + links, and the full-screen gallery (which shows neighbouring media sharp when swiped) are + unchanged. +- iOS has the same gaps in quotes and the chat list; it follows as a separate change.