mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-06 14:18:22 +00:00
Merge branch 'stable'
This commit is contained in:
+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
@@ -2470,6 +2470,7 @@ fun BoxScope.ChatItemsList(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.value.isScrollInProgress }
|
||||
.onCompletion { chatViewScrollState.value = false }
|
||||
.collect {
|
||||
chatViewScrollState.value = it
|
||||
}
|
||||
|
||||
+4
-8
@@ -120,7 +120,7 @@ fun CIImageView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageView(painter: Painter, image: String, fileSource: CryptoFile?, onClick: () -> Unit) {
|
||||
fun ImageView(painter: Painter, image: String, onClick: () -> Unit) {
|
||||
// On my Android device Compose fails to display 6000x6000 px WebP image with exception:
|
||||
// IllegalStateException: Recording currently in progress - missing #endRecording() call?
|
||||
// but can display 5000px image. Using even lower value here just to feel safer.
|
||||
@@ -152,11 +152,7 @@ fun CIImageView(
|
||||
.privacyBlur(!smallView, previewBitmap, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
imageView(previewBitmap, onClick = {
|
||||
if (fileSource != null) {
|
||||
openFile(fileSource)
|
||||
}
|
||||
})
|
||||
imageView(previewBitmap, onClick = onClick)
|
||||
Icon(
|
||||
painterResource(MR.images.ic_open_in_new),
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
@@ -191,7 +187,7 @@ fun CIImageView(
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
||||
.desktopModifyBlurredState(blurred, showMenu),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val res: MutableState<Triple<ImageBitmap, ByteArray, String>?> = remember { mutableStateOf(null) }
|
||||
@@ -217,7 +213,7 @@ fun CIImageView(
|
||||
val loaded = if (revealed) res.value else null
|
||||
if (loaded != null && file != null) {
|
||||
val (imageBitmap, data, _) = loaded
|
||||
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) })
|
||||
SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, onClick) })
|
||||
} else {
|
||||
imageView(previewBitmap, onClick = {
|
||||
if (file != null) {
|
||||
|
||||
+9
-7
@@ -54,7 +54,7 @@ fun CIVideoView(
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.desktopModifyBlurredState(!smallView, blurred, showMenu),
|
||||
.desktopModifyBlurredState(blurred, showMenu),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
|
||||
@@ -84,11 +84,11 @@ fun CIVideoView(
|
||||
val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) }
|
||||
val decrypted = uriDecrypted.value
|
||||
if (decrypted != null && smallView) {
|
||||
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, blurred, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
} else if (decrypted != null) {
|
||||
VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
||||
} else if (smallView) {
|
||||
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, blurred, sizeMultiplier, openFullscreen = openFullscreen)
|
||||
} else {
|
||||
VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, blurred, openFullscreen = openFullscreen)
|
||||
}
|
||||
@@ -184,16 +184,17 @@ private fun SmallVideoViewEncrypted(
|
||||
defaultPreview: ImageBitmap,
|
||||
autoPlay: MutableState<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 {
|
||||
@@ -216,6 +217,7 @@ private fun SmallVideoView(
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
autoPlay: MutableState<Boolean>,
|
||||
blurred: MutableState<Boolean>,
|
||||
sizeMultiplier: Float,
|
||||
openFullscreen: () -> Unit
|
||||
) {
|
||||
@@ -233,8 +235,8 @@ private fun SmallVideoView(
|
||||
onLongClick = {},
|
||||
{}
|
||||
)
|
||||
VideoPreviewImageView(preview, smallView = true, blurred = remember { mutableStateOf(false) }, onClick = openFullscreen, onLongClick = {})
|
||||
if (!file.showStatusIconInSmallView) {
|
||||
VideoPreviewImageView(preview, smallView = true, blurred = blurred, onClick = openFullscreen, onLongClick = {})
|
||||
if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) {
|
||||
PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -153,6 +153,7 @@ fun FramedItemView(
|
||||
|
||||
@Composable
|
||||
fun ciQuoteView(qi: CIQuote) {
|
||||
val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) }
|
||||
val sentColor = MaterialTheme.appColors.sentQuote
|
||||
val receivedColor = MaterialTheme.appColors.receivedQuote
|
||||
Row(
|
||||
@@ -170,7 +171,7 @@ fun FramedItemView(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.image_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
|
||||
)
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
@@ -182,7 +183,7 @@ fun FramedItemView(
|
||||
imageBitmap,
|
||||
contentDescription = stringResource(MR.strings.video_descr),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(68.dp).clipToBounds()
|
||||
modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true })
|
||||
)
|
||||
}
|
||||
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||
|
||||
+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) {
|
||||
CIImageView(image = mc.image, file = ci.file, provider, noMenu, smallView = true) {
|
||||
val user = chatModel.currentUser.value ?: return@CIImageView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> SmallContentPreview {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, noMenu, smallView = true) {
|
||||
val user = chatModel.currentUser.value ?: return@CIVideoView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
@@ -421,7 +426,7 @@ fun ChatPreviewView(
|
||||
val deleted = ci?.isDeletedContent == true || ci?.meta?.itemDeleted != null
|
||||
val showContentPreview = (showChatPreviews && chatModelDraftChatId != chat.id && !deleted) || activeVoicePreview.value != null
|
||||
if (ci != null && showContentPreview) {
|
||||
chatItemContentPreview(chat, ci)
|
||||
key(ci.id) { chatItemContentPreview(chat, ci) }
|
||||
}
|
||||
if (mc !is MsgContent.MCVoice || !showContentPreview || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) {
|
||||
Box(Modifier.offset(x = if (mc is MsgContent.MCFile && ci.meta.itemDeleted == null) -15.sp.toDp() else 0.dp)) {
|
||||
|
||||
+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,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
<releases>
|
||||
<release version="7.0.3" date="2026-09-26">
|
||||
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
|
||||
<description>
|
||||
<p>New in v7.0-v7.0.3.</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.2" date="2026-09-02">
|
||||
<url type="details">https://simplex.chat/blog/20260722-simplex-public-names.html</url>
|
||||
<description>
|
||||
|
||||
Reference in New Issue
Block a user