diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 66b884fc7e..720aa305dd 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -2085,7 +2085,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2110,7 +2110,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2135,7 +2135,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2160,7 +2160,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2177,11 +2177,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2197,11 +2197,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2222,7 +2222,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2237,7 +2237,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2259,7 +2259,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2274,7 +2274,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2296,7 +2296,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2322,7 +2322,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2347,7 +2347,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2374,7 +2374,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2401,7 +2401,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2416,7 +2416,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2435,7 +2435,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 359; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2450,7 +2450,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0.3; + MARKETING_VERSION = 7.0.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; 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 be7022ca80..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 @@ -1,18 +1,21 @@ package chat.simplex.common.platform import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur +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.dp +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 import kotlinx.coroutines.* import kotlinx.coroutines.flow.filter import java.io.File +import kotlin.math.roundToInt @Composable expect fun Modifier.desktopOnExternalDrag( @@ -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, @@ -58,27 +61,37 @@ fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState): Boolean = + enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0 + @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 (enabled && blurred.value) { - this then Modifier.blur( - radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp, - radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp, - edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp)) - ) + 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 } @@ -90,3 +103,27 @@ fun Modifier.privacyBlur( this } } + +// 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, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap { + if (width <= 0 || height <= 0) return this + 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) { + val step = RESAMPLE_MEDIA_FROM_SIDE.toFloat() / longest + scale((width * step).roundToInt().coerceAtLeast(1), (height * step).roundToInt().coerceAtLeast(1)) + } else this + // One bilinear step from a large image undersamples it. + while (image.width / 2 > w) image = image.scale(image.width / 2, (image.height / 2).coerceAtLeast(1)) + image = image.scale(w, h) + // Stretching w px straight to the screen shows their grid; each doubling smooths it, so the last stretch is small. + while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2) + return image +} 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 7ce44475b5..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 @@ -111,13 +111,13 @@ fun CIImageView( onClick = onClick ) .onRightClick { showMenu.value = true } - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), + .privacyBlur(!smallView, imageBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } @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. @@ -135,7 +135,7 @@ fun CIImageView( onClick = onClick ) .onRightClick { showMenu.value = true } - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), + .privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } else { @@ -146,14 +146,10 @@ fun CIImageView( onClick = {} ) .onRightClick { showMenu.value = true } - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), + .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,29 +184,33 @@ fun CIImageView( } } else Modifier ) - .desktopModifyBlurredState(!smallView, blurred, showMenu), + .desktopModifyBlurredState(blurred, showMenu), contentAlignment = Alignment.TopEnd ) { val res: MutableState?> = remember { mutableStateOf(null) } - if (chatModel.connectedToRemote()) { - LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { - withBGApi { + // Hidden media is not worth reading, decoding at full size and caching. + val revealed = !blurHidesMedia(!smallView, blurred) + if (revealed) { + if (chatModel.connectedToRemote()) { + LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { + withBGApi { + if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { + res.value = imageAndFilePath(file) + } + } + } + } else { + LaunchedEffect(file) { if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { - res.value = imageAndFilePath(file) + res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) } } } } - } else { - LaunchedEffect(file) { - if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { - res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) } - } - } } - val loaded = res.value + 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 8ca0add460..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) } } @@ -422,7 +424,7 @@ fun VideoPreviewImageView( onClick = onClick ) .onRightClick(onLongClick) - .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick), + .privacyBlur(!smallView, preview, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } 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 d2a98ae101..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 @@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel @Composable fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongClick: () -> Unit) { - val image = base64ToBitmap(linkPreview.image) + val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) } Column( Modifier .layoutId(CHAT_IMAGE_LAYOUT_ID) @@ -206,8 +206,8 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongC stringResource(MR.strings.image_descr_link_preview), modifier = Modifier .fillMaxWidth() - .desktopModifyBlurredState(true, blurred, showMenu) - .privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick), + .desktopModifyBlurredState(blurred, showMenu) + .privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick), contentScale = ContentScale.FillWidth, ) Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) { diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 2cdcfe90bf..76e2da4719 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.0.3 -android.version_code=381 +android.version_name=7.0.4 +android.version_code=384 -desktop.version_name=7.0.3 -desktop.version_code=164 +desktop.version_name=7.0.4 +desktop.version_code=166 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 9526f080b2..02194ffac2 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.0.3", + "version": "7.0.4", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index cc6b5b4cbd..438451587f 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.0.3'; +const RELEASE_TAG = 'v7.0.4'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 8337c0102f..a816a5d751 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.0.3" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.0.3" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.0.4" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.0.4" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/plans/2026-09-10-blur-media-resample.md b/plans/2026-09-10-blur-media-resample.md new file mode 100644 index 0000000000..0eb3cfe0ab --- /dev/null +++ b/plans/2026-09-10-blur-media-resample.md @@ -0,0 +1,126 @@ +# Blur media by resampling the preview + +## Problem + +"Blur media" is a per-frame effect. Every frame a blurred image or video preview is drawn, a +Gaussian is convolved over an offscreen layer the size of the drawn media - about 500.dp wide, so +roughly 1500x1125 px and a 6 MB render target on a 3x-density phone, for each blurred item on +screen. + +It also does nothing on Android 8.0 to 11: the setting reads Soft, Medium or Strong and the media +is drawn sharp. + +And the media the blur hides is still fetched in full. An image scrolled past is read from disk, +decoded at its original resolution and kept in the image cache, only ever to be drawn as an +unrecognisable smear. + +## Cause + +`Modifier.blur` compiles to `graphicsLayer { renderEffect = BlurEffect(...) }`. In +`androidx.compose.ui:ui-android:1.8.2`, which is what this build resolves: + +| layer implementation | API range | what `setRenderEffect` does | +| -------------------- | --------- | ------------------------------------- | +| `GraphicsLayerV23` | 23-28 | stores the field, never applies it | +| `GraphicsLayerV29` | 29+ | applies only when `SDK_INT >= 31` | +| `GraphicsViewLayer` | fallback | applies only when `SDK_INT >= 31` | + +`minSdk` is 26, so on Android 8.0 to 11 the effect is silently dropped and the media is drawn +unblurred. The codebase already knows this - `SimpleXAPI.kt` has +`deviceSupportsBlur = ... androidApiLevel >= 32` - but that gates the app bar blur and the bar +alpha, never the media blur. + +The radius is in *display* pixels: `BlurKt` converts it with `toPx` on the `GraphicsLayerScope` +before constructing the `BlurEffect`. The convolution therefore runs over the layer, not over the +source bitmap - its cost does not depend on the source resolution, and it is paid again on every +frame the layer is drawn. + +The loading is independent of the blur. `CIImageView` calls `getLoadedImage`, which reads the file +bytes and decodes a 1000 px bitmap, and then hands the same bytes to Coil at `Size.ORIGINAL`. +`imageLoader` overrides neither `memoryCache` nor `memoryCachePolicy`, so Coil 2.6.0 installs its +default memory cache with strong references and retains those full-resolution bitmaps across items. + +## Fix + +A blur and a downscale discard the same thing: detail finer than their radius. So the preview is +resampled to about one pixel per radius, then doubled back up until its longest side is at least +200 px, and drawn stretched to the item with the bilinear filtering `drawImage` uses by default. +This runs once when the item composes rather than once per frame, needs no `RenderEffect`, and so +works on every supported Android version. + +The doubling is what makes it read as a blur rather than as pixels. Stretching a 30 px image +straight to the screen interpolates between its pixels with a tent, and a tent 12 to 48 screen +pixels wide shows the grid it sits on as soft squares and crosses. Each bilinear doubling +convolves that tent with another one, so three or four doublings turn it into a bell close to the +Gaussian that `Modifier.blur` drew, and the last stretch to the screen is short enough that no +grid shows. Compose cannot do this at draw time: on Android `FilterQuality` only toggles +`Paint.isFilterBitmap`, so `High` is still bilinear there, and only desktop maps it to a cubic +resampler. + +`BLURRED_MEDIA_WIDTH_DP` fixes the strength: it is the width the radius is measured against, so +Soft, Medium and Strong resample to 400/12, 400/24 and 400/48, that is 33, 16 and 8 px wide. It is +the one number to move if the result reads too soft or too sharp. It started at 360, the width the +media is drawn at, and was calibrated to 400 by fitting: for each setting the pipeline's output was +matched against Gaussians of every sigma to find the one it resembles most. At 360 the fitted sigma +was 105%, 120% and 175% of the old blur's for Soft, Medium and Strong, the last because 7 px is too +coarse to hold the picture; at 400 it is 105%, 110% and 95% on a 300 px preview and 90%, 95% and +105% on a 100 px one. 440 sits at 80-95% across the board, so 400 is the closest match and 440 the +next step lighter. + +Resampling is also the better direction for a privacy control. A Gaussian is a convolution and is +in principle partly invertible; a 7 px bitmap does not contain the detail to recover. + +While the blur hides the media, the file is left unread - no disk read, no 1000 px decode, no +full-resolution decode, no cache entry. `blurHidesMedia()` is the single definition that both the +modifier drawing the blur and the decision to load the file consult, so the two cannot disagree +about whether the media is on screen. Getting that wrong in either direction is a defect: one way +the app shows what it promised to hide, the other it hides an image it will never load. + +## Bounds + +The media dimensions come from the sender, so the descent is bounded rather than trusted. Its +first step caps both sides at 512 px and the resampled image at 400 px, so no image, whatever its +shape, produces a large intermediate. That first step samples rather than averages - reading every +pixel of a 4K video frame would stall composition - and the halving that follows averages away +most of what it aliases. The ascent stops as soon as the longest side reaches 200 px, so it never +makes an image with a side of 400 px or more; a 4:3 preview ends at 264x192 px, 203 KB. + +The arithmetic was fuzzed over 4840 dimension and radius combinations: no crash, no +non-termination, every dimension at least 1, no side over 400 px after the first step, largest +single intermediate 1.00 MB (the 512 px first step), largest total churn 1.68 MB. A zero radius +cannot reach the function; a negative one - possible from imported settings - yields a 1x1 image +doubled to a flat 256x256, so it fails toward more blur rather than less. + +## Verification + +Built as a desktop AppImage from this branch and run against a profile with images and videos. +Images and video previews render as a smooth blur with no visible blockiness, and the play button +stays suppressed until the media is revealed. The shipped class was checked to contain the new path +and no remaining reference to `BlurKt` or `BlurredEdgeTreatment`. + +The doubling was chosen with a Java2D harness that runs the same arithmetic through the same +bilinear `drawImage` the desktop `scale` uses, on a 300, 100 and 48 px preview at each radius, +next to the Gaussian `Modifier.blur` drew (sigma 0.577r + 0.5, as `BlurEffect` converts it). Without +the ascent the result shows the resample grid at every radius; with it the grid is gone at 1x and +3x density, and RMSE against the Gaussian falls (300 px, Soft: 5.8 to 5.0; 48 px, Soft: 5.3 to +3.5). Doubling past 200 px changes neither the picture nor the figure. + +No before/after CPU figure is included. The only machine available had no GPU, so Skiko fell back +to software rasterisation; a comparison measured there would overstate the gain, because on real +hardware the old blur was largely GPU work. The claim this change rests on is structural - a +constant recomputed every frame is now computed once - not measured. + +The Android 8.0 to 11 no-op is read from the bytecode of the resolved artifact and has not been +confirmed on a device of that vintage. + +## Out of scope + +iOS is unchanged. It has the same per-frame `.blur(radius:)` in `PrivacyBlur`, and `getLoadedImage` +there does no downsampling at all, so the same change applies - but as its own diff. + +`ChatInfoImage.kt` still blurs blocked members' avatars with `Modifier.blur`, so that one remains a +no-op below API 31. It is a different feature and a different call site. + +`CIVideoView` still extracts a full-resolution frame through the video player while the preview is +blurred, the way `CIImageView` used to. The same treatment applies; the player's lifecycle makes it +a larger change than this one. 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 71192f34e2..51bd7b33ae 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.4.

+

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