mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 10:43:05 +00:00
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:
1 parent
7d870c6ff5
commit
2f4a443dda
24 files changed
+941
-33
No files matched your search
+9
@@ -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)
|
||||
|
||||
+2
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+3
-1
@@ -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() })
|
||||
|
||||
+284
@@ -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 }
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-4
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
+25
-8
@@ -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>
|
||||
|
||||
+9
@@ -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)
|
||||
|
||||
+1
-1
@@ -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 ->
|
||||
|
||||
Reference in new issue
Block a user