ui: markdown viewer (#7702)

* ui: markdown viewer

* simplify

* fixes

* plan

* ios sheet, center modal on desktop

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
EvgenyandEvgeny @ SimpleX Chat authored and GitHub committed 2026-10-13 14:38:37 +01:00
1 parent 7d870c6ff5
commit 2f4a443dda
24 files changed
+941 -33

No files matched your search

@@ -13,6 +13,7 @@ import androidx.core.graphics.scale
import boofcv.android.ConvertBitmap
import boofcv.struct.image.GrayU8
import chat.simplex.common.R
import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray
import chat.simplex.common.views.helpers.errorBitmap
import chat.simplex.common.views.helpers.getFileName
import java.io.ByteArrayOutputStream
@@ -41,6 +42,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
}
}
actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? =
try {
decodeSampledBitmapFromByteArray(Base64.decode(base64, Base64.DEFAULT), width, width, MAX_IMAGE_DIMENSION).asImageBitmap()
} catch (e: Exception) {
Log.e(TAG, "decodeBase64Image error: $e")
null
}
actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String {
var img = image
var str = compressImageStr(img)
@@ -194,11 +194,12 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>?
}
// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap
private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap {
internal fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int, maxDimension: Int = Int.MAX_VALUE): Bitmap {
// First decode with inJustDecodeBounds=true to check dimensions
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size, this)
if (outWidth > maxDimension || outHeight > maxDimension) throw IOException("Image dimensions exceed limit")
// Calculate inSampleSize
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
// Decode bitmap with inSampleSize set
@@ -415,7 +415,10 @@ fun CenterPartOfScreen() {
}
}
}
else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
else -> {
ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
ModalManager.center.showInView()
}
}
}
@@ -142,6 +142,7 @@ class AppPreferences {
val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
@@ -429,6 +430,7 @@ class AppPreferences {
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"
private const val SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW = "MarkdownFileTextView"
private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName"
private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime"
private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage"
@@ -15,6 +15,7 @@ import java.io.InputStream
import java.net.URI
expect fun base64ToBitmap(base64ImageString: String): ImageBitmap
expect fun decodeBase64Image(base64: String, width: Int): ImageBitmap?
expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String
expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream
expect fun cropToSquare(image: ImageBitmap): ImageBitmap
@@ -106,6 +106,8 @@ fun CIFileView(
showFileErrorAlert(file.fileStatus.sndFileError)
file.fileStatus is CIFileStatus.SndWarning ->
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
isMarkdownFile(file) && file.loaded ->
showMarkdownFile(file)
file.forwardingAllowed() -> {
withLongRunningApi(slow = 600_000) {
var filePath = getLoadedFilePath(file)
@@ -184,7 +186,7 @@ fun CIFileView(
val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) }
val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() }
val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null
val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null && !isMarkdownFile(file)) getLoadedFilePath(file) else null
if (loadedFilePath != null && file?.fileSource != null) {
val encrypted = file.fileSource.cryptoArgs != null
SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() })
@@ -0,0 +1,284 @@
package chat.simplex.common.views.chat.item
import SectionBottomSpacer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.ui.theme.WarningOrange
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.networkAndServers.documentMarkdownStyle
import chat.simplex.res.*
import com.mikepenz.markdown.compose.MarkdownElement
import com.mikepenz.markdown.m2.Markdown
import com.mikepenz.markdown.model.ImageData
import com.mikepenz.markdown.model.ImageTransformer
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.parseMarkdownFlow
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import java.io.ByteArrayInputStream
import java.io.File
import java.net.URI
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
private const val DATA_IMAGE = "data:image/"
private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576
private const val MAX_MARKDOWN_LINE_LENGTH = 15_000
private const val MAX_MARKDOWN_DEPTH = 32
private const val MAX_MARKDOWN_TABLE_ROWS = 200
private const val MAX_MARKDOWN_IMAGE_PIXELS = 2160L * 4320
private val controlChar = Regex("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]")
private val bidiControlChar = Regex("[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]")
private val invisibleCharMarkerStyle = SpanStyle(color = WarningOrange)
private class MarkdownDocument(val lines: List<String>, val formatted: State.Success?, val hasBidiControls: Boolean)
fun isMarkdownFile(file: CIFile): Boolean =
file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && file.fileName.substringAfterLast('.', "").lowercase() in listOf("md", "markdown")
fun showMarkdownFile(file: CIFile) {
withLongRunningApi(slow = 600_000) {
var filePath = getLoadedFilePath(file)
if (chatModel.connectedToRemote() && filePath == null) {
file.loadRemoteFile(true)
filePath = getLoadedFilePath(file)
}
if (filePath == null) {
AlertManager.shared.showAlertMsg(MR.strings.file_not_found)
return@withLongRunningApi
}
val bytes = try {
val cryptoArgs = file.fileSource?.cryptoArgs
if (cryptoArgs != null) readCryptoFile(filePath, cryptoArgs) else File(filePath).readBytes()
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message)
return@withLongRunningApi
}
val text = decodedText(bytes)
val lines = text.lines()
val formatted = if (lines.all { it.length <= MAX_MARKDOWN_LINE_LENGTH }) formattedWithinLimits(text) else null
val document = MarkdownDocument(
lines.flatMap { line -> line.chunked(MAX_MARKDOWN_LINE_LENGTH).ifEmpty { listOf("") } },
formatted,
bidiControlChar.containsMatchIn(text)
)
ModalManager.center.showModal(endButtons = {
if (formatted != null) MarkdownViewSwitch()
MarkdownFileSaveButton(file, bytes)
}) {
MarkdownFileView(file.fileName, document)
}
}
}
private fun decodedText(bytes: ByteArray): String =
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE)
.replaceWith("?")
.decode(ByteBuffer.wrap(bytes))
.toString()
.replace(controlChar, "?")
private suspend fun formattedWithinLimits(text: String): State.Success? =
(parseMarkdownFlow(text).first { it !is State.Loading } as? State.Success)?.takeIf { treeWithinLimits(it.node) }
private fun treeWithinLimits(root: ASTNode): Boolean {
val nodes = ArrayDeque(listOf(root to 1))
while (nodes.isNotEmpty()) {
val (node, depth) = nodes.removeLast()
if (depth > MAX_MARKDOWN_DEPTH) return false
if (node.type == GFMElementTypes.TABLE && node.children.count { it.type == GFMElementTypes.ROW } > MAX_MARKDOWN_TABLE_ROWS) return false
node.children.forEach { nodes.addLast(it to depth + 1) }
}
return true
}
@Composable
private fun MarkdownViewSwitch() {
val textView = remember { appPrefs.markdownFileTextView.state }
IconButton({ appPrefs.markdownFileTextView.set(!textView.value) }) {
Icon(
painterResource(if (textView.value) MR.images.ic_article else MR.images.ic_code),
stringResource(if (textView.value) MR.strings.markdown_formatted_view else MR.strings.markdown_text_view),
tint = MaterialTheme.colors.primary
)
}
}
@Composable
private fun MarkdownFileSaveButton(file: CIFile, bytes: ByteArray) {
val saveFileLauncher = rememberFileChooserLauncher(false, bytes) { to: URI? ->
if (to != null) copyBytesToFile(ByteArrayInputStream(bytes), to) {}
}
IconButton({ withLongRunningApi { saveFileLauncher.launch(file.fileName) } }) {
Icon(painterResource(MR.images.ic_download), stringResource(MR.strings.save_verb), tint = MaterialTheme.colors.primary)
}
}
@Composable
private fun MarkdownFileView(fileName: String, document: MarkdownDocument) {
val textView = remember { appPrefs.markdownFileTextView.state }
val formatted = document.formatted
if (textView.value || formatted == null) {
SelectionContainer {
MarkdownFileColumn(fileName, document.hasBidiControls) {
items(document.lines) { line ->
Text(remember(line) { withInvisibleCharMarkers(line) }, Modifier.padding(horizontal = DEFAULT_PADDING), fontFamily = FontFamily.Monospace)
}
}
}
} else {
FormattedMarkdown(fileName, formatted, document.hasBidiControls)
}
}
private fun withInvisibleCharMarkers(line: String): AnnotatedString = buildAnnotatedString {
var start = 0
invisibleChar.findAll(line).forEach {
append(line.substring(start, it.range.first))
withStyle(invisibleCharMarkerStyle) { append(unicodeMarker(it.value)) }
start = it.range.last + 1
}
append(line.substring(start))
}
@Composable
private fun FormattedMarkdown(fileName: String, formatted: State.Success, hasBidiControls: Boolean) {
val style = documentMarkdownStyle(h1 = MaterialTheme.typography.h2.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold))
val codeSpanStyle = style.typography.inlineCode.toSpanStyle().copy(background = style.colors.inlineCodeBackground)
val annotator = remember(codeSpanStyle) {
markdownAnnotator { content, child ->
when (child.type) {
MarkdownElementTypes.CODE_SPAN -> {
withStyle(codeSpanStyle) { append(" ${child.getTextInNode(content).trim('`')} ") }
true
}
MarkdownElementTypes.IMAGE ->
if (imageLink(content, child)?.startsWith(DATA_IMAGE) == true) {
false
} else {
append(child.getTextInNode(content).toString())
true
}
else -> false
}
}
}
val imageTransformer = remember { DataImageTransformer() }
val parentUriHandler = LocalUriHandler.current
CompositionLocalProvider(LocalUriHandler provides remember { markdownFileUriHandler(parentUriHandler) }) {
Markdown(
formatted,
colors = style.colors,
typography = style.typography,
padding = style.padding,
imageTransformer = imageTransformer,
annotator = annotator,
components = style.components,
success = { state, components, _ ->
MarkdownFileColumn(fileName, hasBidiControls) {
items(state.node.children) { node ->
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
MarkdownElement(node, components, state.content, skipLinkDefinition = state.linksLookedUp)
}
}
}
}
)
}
}
@Composable
private fun MarkdownFileColumn(fileName: String, hasBidiControls: Boolean, content: LazyListScope.() -> Unit) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LazyColumnWithScrollBar(
contentPadding = PaddingValues(
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false)
)
) {
item { AppBarTitle(fileName) }
if (hasBidiControls) {
item {
Text(stringResource(MR.strings.markdown_file_bidi_warning), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING), color = WarningOrange)
}
}
content()
item { SectionBottomSpacer() }
}
}
private fun imageLink(content: String, image: ASTNode): String? =
image.children.firstOrNull { it.type == MarkdownElementTypes.INLINE_LINK }
?.children?.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION }
?.getTextInNode(content)?.toString()
private fun markdownFileUriHandler(parentUriHandler: UriHandler): UriHandler = object: UriHandler {
override fun openUri(uri: String) {
val format = parseToMarkdown(uri)?.singleOrNull()?.format
when {
format is Format.SimplexLink -> parentUriHandler.openVerifiedSimplexUri(format.simplexUri)
format is Format.Uri && (uri.startsWith("https://") || uri.startsWith("http://")) -> openBrowserAlert(uri, parentUriHandler)
else -> showInvalidLinkAlert(uri)
}
}
}
private class DataImageTransformer : ImageTransformer {
private var pixels = MAX_MARKDOWN_IMAGE_PIXELS
private val images = mutableMapOf<String, ImageBitmap?>()
@Composable
override fun transform(link: String): ImageData? {
val width = with(LocalDensity.current) { LocalWindowWidth().roundToPx() }
val bitmap by produceState<ImageBitmap?>(null, link) {
value = withContext(Dispatchers.Default) { decodedImage(link, width) }
}
return remember(bitmap) { bitmap?.let { ImageData(BitmapPainter(it)) } }
}
private fun decodedImage(link: String, width: Int): ImageBitmap? = synchronized(this) {
if (link !in images) images[link] = imageWithinBudget(link, width)
images[link]
}
private fun imageWithinBudget(link: String, width: Int): ImageBitmap? =
link.takeIf { it.startsWith(DATA_IMAGE) }
?.let { decodeBase64Image(it.substringAfter("base64,"), width) }
?.takeIf { it.width.toLong() * it.height <= pixels }
?.also { pixels -= it.width.toLong() * it.height }
}
@@ -586,7 +586,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection
onClick = {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close ->
ModalManager.center.showModalCloseable(settings = true, cardScreen = true) { close ->
ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close)
}
showMenu.value = false
@@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import LocalCardScreen
import chat.simplex.common.ui.theme.*
@@ -142,9 +141,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
modalViews.add(ModalViewHolder(id, anim, data, modal))
_modalCount.value = modalViews.size - toRemove.size
if (placement == ModalPlacement.CENTER) {
ChatModel.chatId.value = null
} else if (placement == ModalPlacement.END) {
if (placement == ModalPlacement.END) {
desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()) + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()))
}
}
@@ -33,10 +33,14 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.res.*
import com.mikepenz.markdown.compose.Markdown
import com.mikepenz.markdown.compose.components.MarkdownComponents
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownHeader
import com.mikepenz.markdown.m2.markdownColor
import com.mikepenz.markdown.m2.markdownTypography
import com.mikepenz.markdown.model.MarkdownColors
import com.mikepenz.markdown.model.MarkdownPadding
import com.mikepenz.markdown.model.MarkdownTypography
import com.mikepenz.markdown.model.markdownPadding
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -771,22 +775,36 @@ expect fun ConditionsBox(modifier: Modifier, scrollState: ScrollState, content:
@Composable
private fun ConditionsMarkdown(text: String) {
val style = documentMarkdownStyle()
Markdown(text,
markdownColor(linkText = MaterialTheme.colors.primary),
style.colors,
style.typography,
Modifier.padding(8.dp),
// using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly
flavour = CommonMarkFlavourDescriptor(),
components = style.components,
padding = style.padding
)
}
class DocumentMarkdownStyle(val colors: MarkdownColors, val typography: MarkdownTypography, val components: MarkdownComponents, val padding: MarkdownPadding)
@Composable
fun documentMarkdownStyle(h1: TextStyle = MaterialTheme.typography.body1): DocumentMarkdownStyle =
DocumentMarkdownStyle(
markdownColor(),
markdownTypography(
h1 = MaterialTheme.typography.body1,
h1 = h1,
h2 = MaterialTheme.typography.h3.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold),
h3 = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold),
h4 = MaterialTheme.typography.h5.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold),
h5 = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold),
link = MaterialTheme.typography.body1.copy(
color = MaterialTheme.colors.primary,
textDecoration = TextDecoration.Underline
)
),
Modifier.padding(8.dp),
// using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly
flavour = CommonMarkFlavourDescriptor(),
components = markdownComponents(
markdownComponents(
heading2 = {
Spacer(Modifier.height(10.dp))
MarkdownHeader(it.content, it.node, it.typography.h2)
@@ -803,9 +821,8 @@ private fun ConditionsMarkdown(text: String) {
Spacer(Modifier.height(4.dp))
},
),
padding = markdownPadding(block = 4.dp)
markdownPadding(block = 4.dp)
)
}
@Composable
private fun ConditionsLinkView(conditionsLink: String) {
@@ -687,6 +687,9 @@
<string name="file_saved">File saved</string>
<string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string>
<string name="markdown_formatted_view">Formatted view</string>
<string name="markdown_text_view">Text view</string>
<string name="markdown_file_bidi_warning">This file has text direction characters. The text can be shown in a different order than it is written.</string>
<string name="loading_remote_file_title">Loading the file </string>
<string name="loading_remote_file_desc">Please, wait while the file is being loaded from the linked mobile</string>
<string name="file_error">File error</string>
@@ -5,6 +5,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import boofcv.io.image.ConvertBufferedImage
import boofcv.struct.image.GrayU8
import chat.simplex.common.views.helpers.decodeBoundedBufferedImage
import chat.simplex.res.*
import org.jetbrains.skia.Image
import java.awt.RenderingHints
@@ -54,6 +55,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap {
}
}
actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? =
try {
decodeBoundedBufferedImage(Base64.getMimeDecoder().decode(base64), width).toComposeImageBitmap()
} catch (e: Throwable) {
Log.e(TAG, "decodeBase64Image error: $e")
null
}
actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String {
var img = image
var str = compressImageStr(img)
@@ -23,7 +23,7 @@ actual fun SimpleAndAnimatedImageView(
// still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps
// composing under one and would otherwise decode where nobody can see it.
val frame = if (smallView) imageBitmap
else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() }
else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() || ModalManager.center.hasModalsOpen() }
ImageView(BitmapPainter(frame)) {
if (getLoadedFilePath(file) != null) {
ModalManager.fullscreen.showCustomModal(animated = false) { close ->