From 032c5d3a5b56bf78cf896b5121e56112b006afa1 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:29:13 +0700 Subject: [PATCH] android, desktop: blur for media (#4508) * android, desktop: blur for media * change * new option and applied blur to other elements * new line * added to migration * long click handling * hover on desktop * changes * change * showDownloadButton function * file rename * don't blur when menu is visible * rename --- .../common/platform/Modifier.android.kt | 2 + .../chat/simplex/common/model/SimpleXAPI.kt | 7 ++ .../chat/simplex/common/platform/Modifier.kt | 76 ++++++++++++++++++- .../simplex/common/views/chat/ChatView.kt | 8 ++ .../common/views/chat/item/CIImageView.kt | 25 ++++-- .../item/{CIVIdeoView.kt => CIVideoView.kt} | 67 +++++++++++----- .../common/views/chat/item/FramedItemView.kt | 2 +- .../common/views/helpers/LinkPreviews.kt | 19 +++-- .../views/usersettings/PrivacySettings.kt | 30 ++++++++ .../commonMain/resources/MR/base/strings.xml | 5 ++ .../resources/MR/images/ic_blur_on.svg | 1 + .../common/platform/Modifier.desktop.kt | 8 +- 12 files changed, 216 insertions(+), 34 deletions(-) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/{CIVIdeoView.kt => CIVideoView.kt} (88%) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index b103367fe8..2ff2a3e021 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -28,3 +28,5 @@ actual fun Modifier.desktopOnExternalDrag( actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this + +actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 71ea873ddd..cac6a7082d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -116,6 +116,7 @@ class AppPreferences { val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false) val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true) val privacyAskToApproveRelays = mkBoolPreference(SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS, true) + val privacyMediaBlurRadius = mkIntPreference(SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS, 0) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) @@ -328,6 +329,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles" private const val SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS = "PrivacyAskToApproveRelays" + private const val SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS = "PrivacyMediaBlurRadius" const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" @@ -5998,6 +6000,7 @@ data class AppSettings( var privacyShowChatPreviews: Boolean? = null, var privacySaveLastDraft: Boolean? = null, var privacyProtectScreen: Boolean? = null, + var privacyMediaBlurRadius: Int? = null, var notificationMode: AppSettingsNotificationMode? = null, var notificationPreviewMode: AppSettingsNotificationPreviewMode? = null, var webrtcPolicyRelay: Boolean? = null, @@ -6027,6 +6030,7 @@ data class AppSettings( if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews } if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft } if (privacyProtectScreen != def.privacyProtectScreen) { empty.privacyProtectScreen = privacyProtectScreen } + if (privacyMediaBlurRadius != def.privacyMediaBlurRadius) { empty.privacyMediaBlurRadius = privacyMediaBlurRadius } if (notificationMode != def.notificationMode) { empty.notificationMode = notificationMode } if (notificationPreviewMode != def.notificationPreviewMode) { empty.notificationPreviewMode = notificationPreviewMode } if (webrtcPolicyRelay != def.webrtcPolicyRelay) { empty.webrtcPolicyRelay = webrtcPolicyRelay } @@ -6064,6 +6068,7 @@ data class AppSettings( privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } privacyProtectScreen?.let { def.privacyProtectScreen.set(it) } + privacyMediaBlurRadius?.let { def.privacyMediaBlurRadius.set(it) } notificationMode?.let { def.notificationsMode.set(it.toNotificationsMode()) } notificationPreviewMode?.let { def.notificationPreviewMode.set(it.toNotificationPreviewMode().name) } webrtcPolicyRelay?.let { def.webrtcPolicyRelay.set(it) } @@ -6094,6 +6099,7 @@ data class AppSettings( privacyShowChatPreviews = true, privacySaveLastDraft = true, privacyProtectScreen = false, + privacyMediaBlurRadius = 0, notificationMode = AppSettingsNotificationMode.INSTANT, notificationPreviewMode = AppSettingsNotificationPreviewMode.MESSAGE, webrtcPolicyRelay = true, @@ -6125,6 +6131,7 @@ data class AppSettings( privacyShowChatPreviews = def.privacyShowChatPreviews.get(), privacySaveLastDraft = def.privacySaveLastDraft.get(), privacyProtectScreen = def.privacyProtectScreen.get(), + privacyMediaBlurRadius = def.privacyMediaBlurRadius.get(), notificationMode = AppSettingsNotificationMode.from(def.notificationsMode.get()), notificationPreviewMode = AppSettingsNotificationPreviewMode.from(NotificationPreviewMode.valueOf(def.notificationPreviewMode.get()!!)), webrtcPolicyRelay = def.webrtcPolicyRelay.get(), 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 4a10027746..6683ea7d33 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,8 +1,17 @@ package chat.simplex.common.platform -import androidx.compose.runtime.Composable +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.graphics.painter.Painter +import androidx.compose.ui.unit.dp +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 expect fun Modifier.navigationBarsWithImePadding(): Modifier @@ -25,3 +34,68 @@ expect fun Modifier.desktopOnExternalDrag( expect fun Modifier.onRightClick(action: () -> Unit): Modifier expect fun Modifier.desktopPointerHoverIconHand(): Modifier + +expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier + +@Composable +fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState, showMenu: State,): Modifier { + val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state } + if (appPlatform.isDesktop) { + KeyChangeEffect(blurRadius.value) { + blurred.value = enabled && blurRadius.value > 0 + } + } + return if (appPlatform.isDesktop && enabled && 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, + // unhovered action is still received, but we don't need to handle it until menu closes. When it closes, it takes one frame to catch a + // hover action again and if: + // 1. mouse is still on the view, the hover action will cancel this coroutine and the view will stay unblurred + // 2. mouse is not on the view, the view will become blurred after 100 ms + job = launch { + delay(100) + blurred.value = true + } + } + this then Modifier.desktopOnHovered { hovered -> + job.cancel() + blurred.value = !hovered && !showMenu.value + } + } else { + this + } +} + +@Composable +fun Modifier.privacyBlur( + enabled: Boolean, + 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)) + ) + .combinedClickable( + onLongClick = onLongClick, + onClick = { + blurred.value = false + } + ) + } else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) { + LaunchedEffect(Unit) { + snapshotFlow { scrollState.value } + .filter { it } + .filter { !blurred.value } + .collect { blurred.value = true } + } + this + } else { + this + } +} 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 cb563c4807..610d8d95e9 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 @@ -1113,6 +1113,12 @@ fun BoxWithConstraintsScope.ChatItemsList( } } FloatingButtons(chatModel.chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState) + LaunchedEffect(Unit) { + snapshotFlow { listState.isScrollInProgress } + .collect { + chatViewScrollState.value = it + } + } } @Composable @@ -1326,6 +1332,8 @@ private fun TopEndFloatingButton( } } +val chatViewScrollState = MutableStateFlow(false) + private fun bottomEndFloatingButton( unreadCount: Int, showButtonWithCounter: Boolean, 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 59740711fe..e234a73136 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 @@ -1,6 +1,8 @@ package chat.simplex.common.views.chat.item import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.HoverInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -17,8 +19,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH +import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.runBlocking @@ -32,6 +36,7 @@ fun CIImageView( smallView: Boolean, receiveFile: (Long) -> Unit ) { + val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @Composable fun progressIndicator() { CircularProgressIndicator( @@ -105,7 +110,8 @@ fun CIImageView( onLongClick = { showMenu.value = true }, onClick = onClick ) - .onRightClick { showMenu.value = true }, + .onRightClick { showMenu.value = true } + .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } @@ -128,7 +134,8 @@ fun CIImageView( onLongClick = { showMenu.value = true }, onClick = onClick ) - .onRightClick { showMenu.value = true }, + .onRightClick { showMenu.value = true } + .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } else { @@ -138,7 +145,8 @@ fun CIImageView( onLongClick = { showMenu.value = true }, onClick = {} ) - .onRightClick { showMenu.value = true }, + .onRightClick { showMenu.value = true } + .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentAlignment = Alignment.Center ) { imageView(base64ToBitmap(image), onClick = { @@ -174,7 +182,8 @@ fun CIImageView( } Box( - Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), + Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) + .desktopModifyBlurredState(!smallView, blurred, showMenu), contentAlignment = Alignment.TopEnd ) { val res: MutableState?> = remember { @@ -256,9 +265,10 @@ fun CIImageView( } }) } - if (!smallView) { + // Do not show download icon when the view is blurred + if (!smallView && (!showDownloadButton(file?.fileStatus) || !blurred.value)) { loadingIndicator() - } else if (file?.showStatusIconInSmallView == true) { + } else if (smallView && file?.showStatusIconInSmallView == true) { Box(Modifier.align(Alignment.Center)) { loadingIndicator() } @@ -266,6 +276,9 @@ fun CIImageView( } } +private fun showDownloadButton(status: CIFileStatus?): Boolean = + status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted + @Composable expect fun SimpleAndAnimatedImageView( data: ByteArray, 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 similarity index 88% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index dd6e8a28fd..ca93349092 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 @@ -19,7 +19,9 @@ import chat.simplex.res.MR import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.chatViewScrollState import dev.icerock.moko.resources.StringResource import java.io.File import java.net.URI @@ -34,8 +36,10 @@ fun CIVideoView( smallView: Boolean = false, receiveFile: (Long) -> Unit ) { + val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } Box( - Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), + Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) + .desktopModifyBlurredState(!smallView, blurred, showMenu), contentAlignment = Alignment.TopEnd ) { val preview = remember(image) { base64ToBitmap(image) } @@ -68,15 +72,15 @@ fun CIVideoView( if (decrypted != null && smallView) { SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen) } else if (decrypted != null) { - VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, openFullscreen = openFullscreen) + VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen) } else if (smallView) { SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen) } else { - VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, openFullscreen = openFullscreen) + VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen) } } else { Box { - VideoPreviewImageView(preview, onClick = { + VideoPreviewImageView(preview, blurred = blurred, onClick = { if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> @@ -109,14 +113,15 @@ fun CIVideoView( if (file != null && !smallView) { DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } - if (file?.fileStatus is CIFileStatus.RcvInvitation || file?.fileStatus is CIFileStatus.RcvAborted) { + if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } } } } - if (!smallView) { + // Do not show download icon when the view is blurred + if (!smallView && (!showDownloadButton(file?.fileStatus) || !blurred.value)) { fileStatusIcon(file, false) - } else if (file?.showStatusIconInSmallView == true) { + } else if (smallView && file?.showStatusIconInSmallView == true) { Box(Modifier.align(Alignment.Center)) { fileStatusIcon(file, true) } @@ -132,15 +137,16 @@ private fun VideoViewEncrypted( defaultDuration: Long, autoPlay: MutableState, showMenu: MutableState, + blurred: MutableState, openFullscreen: () -> Unit, ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } Box { - VideoPreviewImageView(defaultPreview, smallView = false, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) + VideoPreviewImageView(defaultPreview, smallView = false, blurred = blurred, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(1f, onLongClick = onLongClick) - } else { + } else if (!blurred.value) { PlayButton(false, 1f, onLongClick = onLongClick) { decryptionInProgress = true withBGApi { @@ -170,7 +176,7 @@ private fun SmallVideoViewEncrypted( var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } Box { - VideoPreviewImageView(defaultPreview, smallView = true, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) + VideoPreviewImageView(defaultPreview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) } else if (!file.showStatusIconInSmallView) { @@ -190,7 +196,15 @@ private fun SmallVideoViewEncrypted( } @Composable -private fun SmallVideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, autoPlay: MutableState, sizeMultiplier: Float, openFullscreen: () -> Unit) { +private fun SmallVideoView( + uri: URI, + file: CIFile, + defaultPreview: ImageBitmap, + defaultDuration: Long, + autoPlay: MutableState, + sizeMultiplier: Float, + openFullscreen: () -> Unit +) { val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, defaultDuration, true) } val preview by remember { player.preview } // val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled } @@ -205,7 +219,7 @@ private fun SmallVideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, onLongClick = {}, {} ) - VideoPreviewImageView(preview, smallView = true, openFullscreen, onLongClick = {}) + VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {}) if (!file.showStatusIconInSmallView) { PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) } @@ -216,7 +230,16 @@ private fun SmallVideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, } @Composable -private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, autoPlay: MutableState, showMenu: MutableState, openFullscreen: () -> Unit) { +private fun VideoView( + uri: URI, + file: CIFile, + defaultPreview: ImageBitmap, + defaultDuration: Long, + autoPlay: MutableState, + showMenu: MutableState, + blurred: MutableState, + openFullscreen: () -> Unit +) { val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } val videoPlaying = remember(uri.path) { player.videoPlaying } val progress = remember(uri.path) { player.progress } @@ -257,8 +280,8 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau stop ) if (showPreview.value) { - VideoPreviewImageView(preview, smallView = false, openFullscreen, onLongClick) - if (!autoPlay.value) { + VideoPreviewImageView(preview, smallView = false, blurred = blurred, openFullscreen, onLongClick) + if (!autoPlay.value && !blurred.value) { PlayButton(brokenVideo, onLongClick = onLongClick, onClick = play) } } @@ -365,7 +388,13 @@ private fun DurationProgress(file: CIFile, playing: MutableState, durat } @Composable -fun VideoPreviewImageView(preview: ImageBitmap, smallView: Boolean, onClick: () -> Unit, onLongClick: () -> Unit) { +fun VideoPreviewImageView( + preview: ImageBitmap, + smallView: Boolean, + blurred: MutableState, + onClick: () -> Unit, + onLongClick: () -> Unit +) { val windowWidth = LocalWindowWidth() val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } Image( @@ -377,7 +406,8 @@ fun VideoPreviewImageView(preview: ImageBitmap, smallView: Boolean, onClick: () onLongClick = onLongClick, onClick = onClick ) - .onRightClick(onLongClick), + .onRightClick(onLongClick) + .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = onLongClick), contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } @@ -522,6 +552,9 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { } } +private fun showDownloadButton(status: CIFileStatus?): Boolean = + status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted + private fun fileSizeValid(file: CIFile?): Boolean { if (file != null) { return file.fileSize <= getMaxFileSize(file.fileProtocol) 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 16d2a2dad2..8a579d5289 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 @@ -267,7 +267,7 @@ fun FramedItemView( ciFileView(ci, mc.text) } is MsgContent.MCLink -> { - ChatItemLinkView(mc.preview) + ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy) } 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 20b8f7c09f..cce7cf17a5 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 @@ -1,12 +1,11 @@ package chat.simplex.common.views.helpers import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -15,9 +14,11 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.LinkPreview import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -121,12 +122,16 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel } @Composable -fun ChatItemLinkView(linkPreview: LinkPreview) { +fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State, onLongClick: () -> Unit) { Column(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { + val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } Image( base64ToBitmap(linkPreview.image), stringResource(MR.strings.image_descr_link_preview), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .desktopModifyBlurredState(true, blurred, showMenu) + .privacyBlur(true, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick), contentScale = ContentScale.FillWidth, ) Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) { @@ -179,7 +184,7 @@ private fun normalizeImageUri(u: URL, imageUri: String) = when { @Composable fun PreviewChatItemLinkView() { SimpleXTheme { - ChatItemLinkView(LinkPreview.sampleData) + ChatItemLinkView(LinkPreview.sampleData, remember { mutableStateOf(false) }) {} } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index dc0760193d..88b14b6a66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.res.MR import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* import chat.simplex.common.views.ProfileNameField import chat.simplex.common.views.helpers.* @@ -32,6 +33,8 @@ import chat.simplex.common.views.localauth.SetAppPasscodeView import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import kotlin.math.min +import kotlin.math.roundToInt enum class LAMode { SYSTEM, @@ -95,6 +98,9 @@ fun PrivacySettingsView( withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) } }) SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) + BlurRadiusOptions(remember { appPrefs.privacyMediaBlurRadius.state }) { + appPrefs.privacyMediaBlurRadius.set(it) + } SettingsPreferenceItem(painterResource(MR.images.ic_security), stringResource(MR.strings.protect_ip_address), chatModel.controller.appPrefs.privacyAskToApproveRelays) } SectionCustomFooter { @@ -217,6 +223,30 @@ private fun SimpleXLinkOptions(simplexLinkModeState: State, onS ) } +@Composable +private fun BlurRadiusOptions(state: State, onSelected: (Int) -> Unit) { + val choices = listOf(0, 12, 24, 48) + val pickerValues = choices + if (choices.contains(state.value)) emptyList() else listOf(state.value) + val values = remember { + pickerValues.map { + when (it) { + 0 -> it to generalGetString(MR.strings.privacy_media_blur_radius_off) + 12 -> it to generalGetString(MR.strings.privacy_media_blur_radius_soft) + 24 -> it to generalGetString(MR.strings.privacy_media_blur_radius_medium) + 48 -> it to generalGetString(MR.strings.privacy_media_blur_radius_strong) + else -> it to "$it" + } + } + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.privacy_media_blur_radius), + values, + state, + icon = painterResource(MR.images.ic_blur_on), + onSelected = onSelected + ) +} + @Composable expect fun PrivacyDeviceSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 87c3cecd27..0564d87cd3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1102,6 +1102,11 @@ Disable (keep group overrides) Enable for all groups Disable for all groups + Blur media + Off + Soft + Medium + Strong YOU diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg new file mode 100644 index 0000000000..e8767ccc86 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index 9245f2b950..97f8bc129a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -4,8 +4,7 @@ import androidx.compose.foundation.contextMenuOpenDetector import androidx.compose.runtime.Composable import androidx.compose.ui.* import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.* import java.io.File import java.net.URI @@ -40,3 +39,8 @@ onExternalDrag(enabled) { actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } actual fun Modifier.desktopPointerHoverIconHand(): Modifier = Modifier.pointerHoverIcon(PointerIcon.Hand) + +actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = + this then Modifier + .onPointerEvent(PointerEventType.Enter) { action(true) } + .onPointerEvent(PointerEventType.Exit) { action(false) }