mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 12:53:18 +00:00
Merge branch 'stable' into stable-android
This commit is contained in:
15 files changed
+395
-82
No files matched your search
@@ -2085,7 +2085,7 @@
|
|||||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -2110,7 +2110,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LLVM_LTO = YES_THIN;
|
LLVM_LTO = YES_THIN;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
|
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||||
PRODUCT_NAME = SimpleX;
|
PRODUCT_NAME = SimpleX;
|
||||||
@@ -2135,7 +2135,7 @@
|
|||||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -2160,7 +2160,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LLVM_LTO = YES;
|
LLVM_LTO = YES;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
|
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||||
PRODUCT_NAME = SimpleX;
|
PRODUCT_NAME = SimpleX;
|
||||||
@@ -2177,11 +2177,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2197,11 +2197,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2222,7 +2222,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_OPTIMIZATION_LEVEL = s;
|
GCC_OPTIMIZATION_LEVEL = s;
|
||||||
@@ -2237,7 +2237,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LLVM_LTO = YES;
|
LLVM_LTO = YES;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -2259,7 +2259,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_CODE_COVERAGE = NO;
|
ENABLE_CODE_COVERAGE = NO;
|
||||||
@@ -2274,7 +2274,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LLVM_LTO = YES;
|
LLVM_LTO = YES;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -2296,7 +2296,7 @@
|
|||||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
@@ -2322,7 +2322,7 @@
|
|||||||
"$(PROJECT_DIR)/Libraries/sim",
|
"$(PROJECT_DIR)/Libraries/sim",
|
||||||
);
|
);
|
||||||
LLVM_LTO = YES;
|
LLVM_LTO = YES;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2347,7 +2347,7 @@
|
|||||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEFINES_MODULE = YES;
|
DEFINES_MODULE = YES;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||||
@@ -2374,7 +2374,7 @@
|
|||||||
"$(PROJECT_DIR)/Libraries/sim",
|
"$(PROJECT_DIR)/Libraries/sim",
|
||||||
);
|
);
|
||||||
LLVM_LTO = YES;
|
LLVM_LTO = YES;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2401,7 +2401,7 @@
|
|||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -2416,7 +2416,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -2435,7 +2435,7 @@
|
|||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 359;
|
CURRENT_PROJECT_VERSION = 361;
|
||||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
@@ -2450,7 +2450,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 7.0.3;
|
MARKETING_VERSION = 7.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
+52
-15
@@ -1,18 +1,21 @@
|
|||||||
package chat.simplex.common.platform
|
package chat.simplex.common.platform
|
||||||
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.draw.blur
|
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.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.model.ChatController.appPrefs
|
||||||
import chat.simplex.common.views.helpers.KeyChangeEffect
|
import chat.simplex.common.views.helpers.KeyChangeEffect
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun Modifier.desktopOnExternalDrag(
|
expect fun Modifier.desktopOnExternalDrag(
|
||||||
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
|
|||||||
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
|
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
|
fun Modifier.desktopModifyBlurredState(blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
|
||||||
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
||||||
if (appPlatform.isDesktop) {
|
if (appPlatform.isDesktop) {
|
||||||
KeyChangeEffect(blurRadius.value) {
|
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() }
|
var job: Job = remember { Job() }
|
||||||
LaunchedEffect(Unit) {
|
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,
|
// 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<B
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
|
||||||
|
enabled && blurred.value && remember { appPrefs.privacyMediaBlurRadius.state }.value > 0
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.privacyBlur(
|
fun Modifier.privacyBlur(
|
||||||
enabled: Boolean,
|
fullSize: Boolean,
|
||||||
|
preview: ImageBitmap,
|
||||||
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
|
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
|
||||||
scrollState: State<Boolean>,
|
scrollState: State<Boolean>,
|
||||||
onLongClick: () -> Unit = {}
|
onLongClick: () -> Unit = {}
|
||||||
): Modifier {
|
): Modifier {
|
||||||
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
||||||
return if (enabled && blurred.value) {
|
return if (blurHidesMedia(true, blurred)) {
|
||||||
this then Modifier.blur(
|
this then (if (fullSize) {
|
||||||
radiusX = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
|
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
|
||||||
radiusY = remember { appPrefs.privacyMediaBlurRadius.state }.value.dp,
|
Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
|
||||||
edgeTreatment = BlurredEdgeTreatment(RoundedCornerShape(0.dp))
|
} 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(
|
.combinedClickable(
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onClick = {
|
onClick = {
|
||||||
blurred.value = false
|
blurred.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) {
|
} else if (blurRadius.value > 0 && appPlatform.isAndroid) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.value }
|
snapshotFlow { scrollState.value }
|
||||||
.filter { it }
|
.filter { it }
|
||||||
@@ -90,3 +103,27 @@ fun Modifier.privacyBlur(
|
|||||||
this
|
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
|
||||||
|
}
|
||||||
+1
@@ -2463,6 +2463,7 @@ fun BoxScope.ChatItemsList(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { listState.value.isScrollInProgress }
|
snapshotFlow { listState.value.isScrollInProgress }
|
||||||
|
.onCompletion { chatViewScrollState.value = false }
|
||||||
.collect {
|
.collect {
|
||||||
chatViewScrollState.value = it
|
chatViewScrollState.value = it
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-22
@@ -111,13 +111,13 @@ fun CIImageView(
|
|||||||
onClick = onClick
|
onClick = onClick
|
||||||
)
|
)
|
||||||
.onRightClick { showMenu.value = true }
|
.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,
|
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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:
|
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
|
||||||
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
|
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
|
||||||
// but can display 5000px image. Using even lower value here just to feel safer.
|
// but can display 5000px image. Using even lower value here just to feel safer.
|
||||||
@@ -135,7 +135,7 @@ fun CIImageView(
|
|||||||
onClick = onClick
|
onClick = onClick
|
||||||
)
|
)
|
||||||
.onRightClick { showMenu.value = true }
|
.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,
|
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -146,14 +146,10 @@ fun CIImageView(
|
|||||||
onClick = {}
|
onClick = {}
|
||||||
)
|
)
|
||||||
.onRightClick { showMenu.value = true }
|
.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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
imageView(previewBitmap, onClick = {
|
imageView(previewBitmap, onClick = onClick)
|
||||||
if (fileSource != null) {
|
|
||||||
openFile(fileSource)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(MR.images.ic_open_in_new),
|
painterResource(MR.images.ic_open_in_new),
|
||||||
contentDescription = stringResource(MR.strings.image_descr),
|
contentDescription = stringResource(MR.strings.image_descr),
|
||||||
@@ -188,29 +184,33 @@ fun CIImageView(
|
|||||||
}
|
}
|
||||||
} else Modifier
|
} else Modifier
|
||||||
)
|
)
|
||||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
.desktopModifyBlurredState(blurred, showMenu),
|
||||||
contentAlignment = Alignment.TopEnd
|
contentAlignment = Alignment.TopEnd
|
||||||
) {
|
) {
|
||||||
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
|
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
|
||||||
if (chatModel.connectedToRemote()) {
|
// Hidden media is not worth reading, decoding at full size and caching.
|
||||||
LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) {
|
val revealed = !blurHidesMedia(!smallView, blurred)
|
||||||
withBGApi {
|
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)) {
|
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) {
|
if (loaded != null && file != null) {
|
||||||
val (imageBitmap, data, _) = loaded
|
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 {
|
} else {
|
||||||
imageView(previewBitmap, onClick = {
|
imageView(previewBitmap, onClick = {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
|
|||||||
+10
-8
@@ -55,7 +55,7 @@ fun CIVideoView(
|
|||||||
}
|
}
|
||||||
} else Modifier
|
} else Modifier
|
||||||
)
|
)
|
||||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
.desktopModifyBlurredState(blurred, showMenu),
|
||||||
contentAlignment = Alignment.TopEnd
|
contentAlignment = Alignment.TopEnd
|
||||||
) {
|
) {
|
||||||
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
|
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 uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
|
||||||
val decrypted = uriDecrypted.value
|
val decrypted = uriDecrypted.value
|
||||||
if (decrypted != null && smallView) {
|
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) {
|
} else if (decrypted != null) {
|
||||||
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
||||||
} else if (smallView) {
|
} else if (smallView) {
|
||||||
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen)
|
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
|
||||||
} else {
|
} else {
|
||||||
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
||||||
}
|
}
|
||||||
@@ -185,16 +185,17 @@ private fun SmallVideoViewEncrypted(
|
|||||||
defaultPreview: ImageBitmap,
|
defaultPreview: ImageBitmap,
|
||||||
autoPlay: MutableState<Boolean>,
|
autoPlay: MutableState<Boolean>,
|
||||||
showMenu: MutableState<Boolean>,
|
showMenu: MutableState<Boolean>,
|
||||||
|
blurred: MutableState<Boolean>,
|
||||||
sizeMultiplier: Float,
|
sizeMultiplier: Float,
|
||||||
openFullscreen: () -> Unit,
|
openFullscreen: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
|
var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) }
|
||||||
val onLongClick = { showMenu.value = true }
|
val onLongClick = { showMenu.value = true }
|
||||||
Box {
|
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) {
|
if (decryptionInProgress) {
|
||||||
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
|
VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick)
|
||||||
} else if (!file.showStatusIconInSmallView) {
|
} else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
|
||||||
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
|
PlayButton(false, sizeMultiplier, onLongClick = onLongClick) {
|
||||||
decryptionInProgress = true
|
decryptionInProgress = true
|
||||||
withBGApi {
|
withBGApi {
|
||||||
@@ -217,6 +218,7 @@ private fun SmallVideoView(
|
|||||||
defaultPreview: ImageBitmap,
|
defaultPreview: ImageBitmap,
|
||||||
defaultDuration: Long,
|
defaultDuration: Long,
|
||||||
autoPlay: MutableState<Boolean>,
|
autoPlay: MutableState<Boolean>,
|
||||||
|
blurred: MutableState<Boolean>,
|
||||||
sizeMultiplier: Float,
|
sizeMultiplier: Float,
|
||||||
openFullscreen: () -> Unit
|
openFullscreen: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -234,8 +236,8 @@ private fun SmallVideoView(
|
|||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {})
|
VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
|
||||||
if (!file.showStatusIconInSmallView) {
|
if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
|
||||||
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
|
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,7 +424,7 @@ fun VideoPreviewImageView(
|
|||||||
onClick = onClick
|
onClick = onClick
|
||||||
)
|
)
|
||||||
.onRightClick(onLongClick)
|
.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,
|
contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -135,6 +135,7 @@ fun FramedItemView(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ciQuoteView(qi: CIQuote) {
|
fun ciQuoteView(qi: CIQuote) {
|
||||||
|
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
|
||||||
val sentColor = MaterialTheme.appColors.sentQuote
|
val sentColor = MaterialTheme.appColors.sentQuote
|
||||||
val receivedColor = MaterialTheme.appColors.receivedQuote
|
val receivedColor = MaterialTheme.appColors.receivedQuote
|
||||||
Row(
|
Row(
|
||||||
@@ -152,7 +153,7 @@ fun FramedItemView(
|
|||||||
imageBitmap,
|
imageBitmap,
|
||||||
contentDescription = stringResource(MR.strings.image_descr),
|
contentDescription = stringResource(MR.strings.image_descr),
|
||||||
contentScale = ContentScale.Crop,
|
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 -> {
|
is MsgContent.MCVideo -> {
|
||||||
@@ -164,7 +165,7 @@ fun FramedItemView(
|
|||||||
imageBitmap,
|
imageBitmap,
|
||||||
contentDescription = stringResource(MR.strings.video_descr),
|
contentDescription = stringResource(MR.strings.video_descr),
|
||||||
contentScale = ContentScale.Crop,
|
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 -> {
|
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||||
|
|||||||
+9
-4
@@ -311,26 +311,31 @@ fun ChatPreviewView(
|
|||||||
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
|
mutableStateOf({ providerForGallery(chat.chatItems, ci?.id ?: 0) {} })
|
||||||
}
|
}
|
||||||
val uriHandler = LocalUriHandler.current
|
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) {
|
when (mc) {
|
||||||
is MsgContent.MCLink -> SmallContentPreview {
|
is MsgContent.MCLink -> SmallContentPreview {
|
||||||
|
val image = remember(mc.preview.image) { base64ToBitmap(mc.preview.image) }
|
||||||
|
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
|
||||||
IconButton(
|
IconButton(
|
||||||
{ openBrowserAlert(mc.preview.uri, uriHandler) },
|
{ openBrowserAlert(mc.preview.uri, uriHandler) },
|
||||||
Modifier.desktopPointerHoverIconHand(),
|
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) {
|
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)
|
Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MsgContent.MCImage -> SmallContentPreview {
|
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
|
val user = chatModel.currentUser.value ?: return@CIImageView
|
||||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MsgContent.MCVideo -> SmallContentPreview {
|
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
|
val user = chatModel.currentUser.value ?: return@CIVideoView
|
||||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||||
}
|
}
|
||||||
@@ -421,7 +426,7 @@ fun ChatPreviewView(
|
|||||||
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
|
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
|
||||||
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
|
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
|
||||||
if (ci != null && showContentPreview) {
|
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) {
|
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)) {
|
Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
|
||||||
|
|||||||
+3
-3
@@ -194,7 +194,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
|
fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongClick: () -> Unit) {
|
||||||
val image = base64ToBitmap(linkPreview.image)
|
val image = remember(linkPreview.image) { base64ToBitmap(linkPreview.image) }
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.layoutId(CHAT_IMAGE_LAYOUT_ID)
|
.layoutId(CHAT_IMAGE_LAYOUT_ID)
|
||||||
@@ -206,8 +206,8 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
|
|||||||
stringResource(MR.strings.image_descr_link_preview),
|
stringResource(MR.strings.image_descr_link_preview),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.desktopModifyBlurredState(true, blurred, showMenu)
|
.desktopModifyBlurredState(blurred, showMenu)
|
||||||
.privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
||||||
contentScale = ContentScale.FillWidth,
|
contentScale = ContentScale.FillWidth,
|
||||||
)
|
)
|
||||||
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
|
Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) {
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ android.nonTransitiveRClass=true
|
|||||||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||||
kotlin.jvm.target=11
|
kotlin.jvm.target=11
|
||||||
|
|
||||||
android.version_name=7.0.3
|
android.version_name=7.0.4
|
||||||
android.version_code=381
|
android.version_code=384
|
||||||
|
|
||||||
desktop.version_name=7.0.3
|
desktop.version_name=7.0.4
|
||||||
desktop.version_code=164
|
desktop.version_code=166
|
||||||
|
|
||||||
kotlin.version=2.1.20
|
kotlin.version=2.1.20
|
||||||
gradle.plugin.version=8.7.0
|
gradle.plugin.version=8.7.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "simplex-chat",
|
"name": "simplex-chat",
|
||||||
"version": "7.0.3",
|
"version": "7.0.4",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const path = require('path');
|
|||||||
const extract = require('extract-zip');
|
const extract = require('extract-zip');
|
||||||
|
|
||||||
const GITHUB_REPO = 'simplex-chat/simplex-chat-libs';
|
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();
|
const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase();
|
||||||
|
|
||||||
if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') {
|
if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') {
|
||||||
|
|||||||
@@ -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.
|
post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "7.0.3" # PEP 440 — read by hatchling for wheel metadata
|
__version__ = "7.0.4" # PEP 440 — read by hatchling for wheel metadata
|
||||||
LIBS_VERSION = "7.0.3" # simplex-chat-libs release tag (no 'v' prefix)
|
LIBS_VERSION = "7.0.4" # simplex-chat-libs release tag (no 'v' prefix)
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -38,6 +38,23 @@
|
|||||||
</description>
|
</description>
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="7.0.4" date="2026-10-03">
|
||||||
|
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
|
||||||
|
<description>
|
||||||
|
<p>New in v7.0-v7.0.4.</p>
|
||||||
|
<p>SimpleX public names for channels and businesses (BETA).</p>
|
||||||
|
<p>Better channels:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Promote subscribers to contributors.</li>
|
||||||
|
<li>Publish your channel on your website.</li>
|
||||||
|
<li>Verify security code with contributors.</li>
|
||||||
|
<li>Wider messages, easier to read.</li>
|
||||||
|
<li>Host your own chat relays.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Add a longer description to your profile.</p>
|
||||||
|
<p>Simplified app settings.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="7.0.3" date="2026-09-26">
|
<release version="7.0.3" date="2026-09-26">
|
||||||
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
|
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
|
||||||
<description>
|
<description>
|
||||||
|
|||||||
Reference in new issue
Block a user