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 091924e3c7..fc57998f1b 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 @@ -2470,6 +2470,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 5f5c207d08..fc1c76074d 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 @@ -120,7 +120,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. @@ -152,11 +152,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), @@ -191,7 +187,7 @@ fun CIImageView( } } else Modifier ) - .desktopModifyBlurredState(!smallView, blurred, showMenu), + .desktopModifyBlurredState(blurred, showMenu), contentAlignment = Alignment.TopEnd ) { val res: MutableState?> = remember { mutableStateOf(null) } @@ -217,7 +213,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, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) + SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @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 ef600c8443..8b4756c3ad 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 @@ -54,7 +54,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)) } @@ -84,11 +84,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) } @@ -184,16 +184,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 { @@ -216,6 +217,7 @@ private fun SmallVideoView( defaultPreview: ImageBitmap, defaultDuration: Long, autoPlay: MutableState, + blurred: MutableState, sizeMultiplier: Float, openFullscreen: () -> Unit ) { @@ -233,8 +235,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 68aec09456..e2e7f0bc69 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 @@ -153,6 +153,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( @@ -170,7 +171,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 -> { @@ -182,7 +183,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 e6046d6c00..01179f07ee 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) { + CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true) { 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) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true) { 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. diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 0c8396e5db..71192f34e2 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,23 @@ + + https://simplex.chat/blog/20260722-simplex-public-names.html + +

New in v7.0-v7.0.3.

+

SimpleX public names for channels and businesses (BETA).

+

Better channels:

+
    +
  • Promote subscribers to contributors.
  • +
  • Publish your channel on your website.
  • +
  • Verify security code with contributors.
  • +
  • Wider messages, easier to read.
  • +
  • Host your own chat relays.
  • +
+

Add a longer description to your profile.

+

Simplified app settings.

+
+
https://simplex.chat/blog/20260722-simplex-public-names.html