mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 13:11:55 +00:00
android, desktop: blur media thumbnails in reply quotes and the chat list (#7639)
"Blur media" hid images and videos in a chat but showed the same media sharp as the thumbnail of a quoted message and as the last message's image, video or link preview in the chat list. Small views are now blurred with the same reveal as chat media. They are resampled relative to the width they are drawn at, so a thumbnail is blurred as much on screen as media in a chat, and drawn as the centre crop the thumbnail shows. In-chat media keeps the same blur. Blurring the chat list also needs: each last message gets its own blur state, loaded chat-list videos use it and hide their full-screen play button while blurred, a right-click on a thumbnail no longer leaves it revealed on desktop, and leaving a chat mid-scroll no longer undoes reveals in the list on Android. Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com>
This commit is contained in:
1 parent
205d17e2fa
commit
53e6d435bc
8 files changed
+174
-33
No files matched your search
+23
-11
@@ -3,9 +3,12 @@ package chat.simplex.common.platform
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.views.helpers.KeyChangeEffect
|
||||
@@ -29,14 +32,14 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier
|
||||
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
|
||||
|
||||
@Composable
|
||||
fun Modifier.desktopModifyBlurredState(enabled: Boolean, blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
|
||||
fun Modifier.desktopModifyBlurredState(blurred: MutableState<Boolean>, showMenu: State<Boolean>,): Modifier {
|
||||
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
||||
if (appPlatform.isDesktop) {
|
||||
KeyChangeEffect(blurRadius.value) {
|
||||
blurred.value = enabled && blurRadius.value > 0
|
||||
blurred.value = blurRadius.value > 0
|
||||
}
|
||||
}
|
||||
return if (appPlatform.isDesktop && enabled && blurRadius.value > 0 && !showMenu.value) {
|
||||
return if (appPlatform.isDesktop && blurRadius.value > 0 && !showMenu.value) {
|
||||
var job: Job = remember { Job() }
|
||||
LaunchedEffect(Unit) {
|
||||
// The approach here is to allow menu to show up and to not blur the view. When menu is shown and mouse is hovering,
|
||||
@@ -64,24 +67,31 @@ fun blurHidesMedia(enabled: Boolean, blurred: State<Boolean>): Boolean =
|
||||
|
||||
@Composable
|
||||
fun Modifier.privacyBlur(
|
||||
enabled: Boolean,
|
||||
fullSize: Boolean,
|
||||
preview: ImageBitmap,
|
||||
blurred: MutableState<Boolean> = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) },
|
||||
scrollState: State<Boolean>,
|
||||
onLongClick: () -> Unit = {}
|
||||
): Modifier {
|
||||
val blurRadius = remember { appPrefs.privacyMediaBlurRadius.state }
|
||||
return if (blurHidesMedia(enabled, blurred)) {
|
||||
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
|
||||
this then Modifier
|
||||
.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
|
||||
return if (blurHidesMedia(true, blurred)) {
|
||||
this then (if (fullSize) {
|
||||
val blurredPreview = remember(preview, blurRadius.value) { preview.blurredBy(blurRadius.value) }
|
||||
Modifier.drawWithContent { drawImage(blurredPreview, dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())) }
|
||||
} else Modifier.drawWithCache {
|
||||
val cropScale = maxOf(size.width / preview.width, size.height / preview.height)
|
||||
val croppedSize = IntSize((preview.width * cropScale).roundToInt(), (preview.height * cropScale).roundToInt())
|
||||
val blurredCropped = preview.blurredBy(blurRadius.value, croppedSize.width.toDp().value)
|
||||
val offset = IntOffset(((size.width - croppedSize.width) / 2).roundToInt(), ((size.height - croppedSize.height) / 2).roundToInt())
|
||||
onDrawWithContent { clipRect { drawImage(blurredCropped, dstOffset = offset, dstSize = croppedSize) } }
|
||||
})
|
||||
.combinedClickable(
|
||||
onLongClick = onLongClick,
|
||||
onClick = {
|
||||
blurred.value = false
|
||||
}
|
||||
)
|
||||
} else if (enabled && blurRadius.value > 0 && appPlatform.isAndroid) {
|
||||
} else if (blurRadius.value > 0 && appPlatform.isAndroid) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.value }
|
||||
.filter { it }
|
||||
@@ -96,12 +106,14 @@ fun Modifier.privacyBlur(
|
||||
|
||||
// Calibrated so the resample blurs as much as Modifier.blur did at each radius; 360 read 5-75% stronger.
|
||||
private const val BLURRED_MEDIA_WIDTH_DP = 400
|
||||
// The width in-chat media is assumed to be drawn at; small views are blurred as much on screen.
|
||||
private const val CHAT_MEDIA_WIDTH_DP = 360
|
||||
// Bounds the first step: nothing bounds a decoded video frame, and reading every pixel of a 4K one would stall.
|
||||
private const val RESAMPLE_MEDIA_FROM_SIDE = 512
|
||||
|
||||
private fun ImageBitmap.blurredBy(radius: Int): ImageBitmap {
|
||||
private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_WIDTH_DP.toFloat()): ImageBitmap {
|
||||
if (width <= 0 || height <= 0) return this
|
||||
val w = (BLURRED_MEDIA_WIDTH_DP / radius).coerceIn(1, width)
|
||||
val w = (BLURRED_MEDIA_WIDTH_DP * drawnWidthDp / CHAT_MEDIA_WIDTH_DP / radius).toInt().coerceIn(1, width)
|
||||
val h = (w * height / width).coerceIn(1, BLURRED_MEDIA_WIDTH_DP)
|
||||
val longest = maxOf(width, height)
|
||||
var image = if (longest > RESAMPLE_MEDIA_FROM_SIDE) {
|
||||
|
||||
+1
@@ -2463,6 +2463,7 @@ fun BoxScope.ChatItemsList(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.value.isScrollInProgress }
|
||||
.onCompletion { chatViewScrollState.value = false }
|
||||
.collect {
|
||||
chatViewScrollState.value = it
|
||||
}
|
||||
|
||||
+4
-8
@@ -117,7 +117,7 @@ fun CIImageView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) {
|
||||
fun ImageView(painter: Painter, image: String, onClick: () -> Unit) {
|
||||
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
|
||||
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
|
||||
// but can display 5000px image. Using even lower value here just to feel safer.
|
||||
@@ -149,11 +149,7 @@ fun CIImageView(
|
||||
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
imageView(previewBitmap, onClick = {
|
||||
if (fileSource != null) {
|
||||
openFile(fileSource)
|
||||
}
|
||||
})
|
||||
imageView(previewBitmap, onClick = onClick)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_open_in_new),
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
@@ -188,7 +184,7 @@ fun CIImageView(
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
||||
.desktopModifyBlurredState(blurred, showMenu),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
|
||||
@@ -214,7 +210,7 @@ fun CIImageView(
|
||||
val loaded = if (revealed) res.value else null
|
||||
if (loaded != null && file != null) {
|
||||
val (imageBitmap, data, _) = loaded
|
||||
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
|
||||
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
|
||||
} else {
|
||||
imageView(previewBitmap, onClick = {
|
||||
if (file != null) {
|
||||
|
||||
+9
-7
@@ -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<Boolean>,
|
||||
showMenu: MutableState<Boolean>,
|
||||
blurred: MutableState<Boolean>,
|
||||
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<Boolean>,
|
||||
blurred: MutableState<Boolean>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 -> {
|
||||
|
||||
+9
-4
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview, showMenu: State<Boolean>, onLongC
|
||||
stringResource(MR.strings.image_descr_link_preview),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.desktopModifyBlurredState(true, blurred, showMenu)
|
||||
.desktopModifyBlurredState(blurred, showMenu)
|
||||
.privacyBlur(true, image, blurred, chatViewScrollState.collectAsState(), onLongClick = onLongClick),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user