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:
Evgeny
2026-10-13 14:38:37 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat
parent 7d870c6ff5
commit 2f4a443dda
24 changed files with 941 additions and 33 deletions
@@ -118,7 +118,7 @@ struct CIFileView: View {
case .rcvComplete:
logger.debug("CIFileView fileAction - in .rcvComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
@@ -129,12 +129,12 @@ struct CIFileView: View {
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case .sndComplete:
logger.debug("CIFileView fileAction - in .sndComplete")
if let fileSource = getLoadedFileSource(file) {
saveCryptoFile(fileSource)
openFile(file, fileSource)
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
@@ -147,6 +147,14 @@ struct CIFileView: View {
}
}
private func openFile(_ file: CIFile, _ fileSource: CryptoFile) {
if isMarkdownFile(file) {
showMarkdownFile(file)
} else {
saveCryptoFile(fileSource)
}
}
@ViewBuilder private func fileIndicator() -> some View {
if let file = file {
switch file.fileStatus {
@@ -0,0 +1,215 @@
import SwiftUI
import SimpleXChat
import Ink
private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576
private let MAX_MARKDOWN_LINE_LENGTH = 15_000
private let MAX_MARKDOWN_NESTING = 16
private let MAX_MARKDOWN_LINK_NESTING = 4
private let MAX_MARKDOWN_TABLE_ROWS = 200
private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320
private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320
func isMarkdownFile(_ file: CIFile) -> Bool {
let ext = (file.fileName as NSString).pathExtension.lowercased()
return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown")
}
func showMarkdownFile(_ file: CIFile) {
guard let fileSource = getLoadedFileSource(file) else { return }
Task {
do {
let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs)
let text = decodedText(data)
let lines = text.components(separatedBy: "\n")
let html = withinMarkdownLimits(lines)
? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text)
: nil
let shownLines = lines.flatMap(chunkedLine)
let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl)
await MainActor.run {
showAppSheet {
MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls)
}
}
} catch {
await MainActor.run {
showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription)
}
}
}
}
private func decodedText(_ data: Data) -> String {
var text = String.UnicodeScalarView()
var parser = Unicode.UTF8.ForwardParser()
var bytes = data.makeIterator()
while true {
switch parser.parseScalar(from: &bytes) {
case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded)))
case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length))
case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
}
}
}
private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar {
s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s
}
private func withinMarkdownLimits(_ lines: [String]) -> Bool {
var tableRows = 0
var linkNesting = 0
for line in lines {
tableRows = line.contains("|") ? tableRows + 1 : 0
if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) {
return false
}
}
return true
}
private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool {
if line.isEmpty { nesting = 0 }
for c in line {
if c == "[" {
nesting += 1
if nesting > MAX_MARKDOWN_LINK_NESTING { return false }
} else if c == "]" && nesting > 0 {
nesting -= 1
}
}
return true
}
private func nestingLevel(_ line: String) -> Int {
let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" })
let prefix = line[..<content.startIndex]
let quotes = prefix.filter { $0 == ">" }.count
let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false
return quotes + (listItem ? (prefix.count - quotes) / 2 : 0)
}
private func chunkedLine(_ line: String) -> [String] {
let chars = Array(line)
return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map {
String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)])
}
}
private func escapedHTML(_ s: Substring) -> String {
s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
private func imagesWithinBudget() -> Modifier {
var pixels = MAX_MARKDOWN_IMAGE_PIXELS
return Modifier(target: .images) { image in
let src = image.html.dropFirst("<img src=\"".count).prefix(while: { $0 != "\"" })
guard src.hasPrefix("data:image/") else { return escapedHTML(image.markdown) }
let parts = src.split(separator: ",", maxSplits: 1)
guard parts.count == 2,
parts[0].hasSuffix(";base64"),
let data = Data(base64Encoded: String(parts[1])),
let size = UIImage(data: data)?.size,
size.width <= MAX_MARKDOWN_IMAGE_DIMENSION,
size.height <= MAX_MARKDOWN_IMAGE_DIMENSION,
size.width * size.height <= pixels
else { return "" }
pixels -= size.width * size.height
return "<img src=\"\(src)\"/>"
}
}
private func isBidiControl(_ s: Unicode.Scalar) -> Bool {
[0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value)
}
private func withInvisibleCharMarkers(_ line: String) -> Text {
var text = Text(verbatim: "")
var plain = ""
for s in line.unicodeScalars {
if s.properties.generalCategory == .format {
text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange)
plain = ""
} else {
plain.unicodeScalars.append(s)
}
}
return text + Text(verbatim: plain)
}
private func openMarkdownFileLink(_ url: URL) {
let uri = url.absoluteString
let parsed = parseSimpleXMarkdown(uri)
let format = parsed?.count == 1 ? parsed?.first?.format : nil
switch format {
case let .simplexLink(_, _, simplexUri, _)?:
if let simplexUrl = URL(string: simplexUri) {
ChatModel.shared.appOpenUrl = simplexUrl
} else {
showInvalidLinkAlert(uri)
}
case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"):
openBrowserAlert(uri: uri)
default:
showInvalidLinkAlert(uri)
}
}
struct MarkdownFileView: View {
@AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false
let file: CIFile
let fileSource: CryptoFile
let lines: [String]
let html: String?
let hasBidiControls: Bool
var body: some View {
NavigationView {
VStack(spacing: 0) {
if hasBidiControls {
Text("This file has text direction characters. The text can be shown in a different order than it is written.")
.foregroundColor(.orange)
.padding()
}
if let html, !textView {
ConditionsWebView(html: html, openLink: openMarkdownFileLink)
.padding(.horizontal)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(lines.indices, id: \.self) { i in
withInvisibleCharMarkers(lines[i])
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
}
.padding()
}
}
}
.modifier(ThemedBackground())
.navigationTitle(file.fileName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if html != nil {
Button {
textView.toggle()
} label: {
Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right")
}
}
Button {
saveCryptoFile(fileSource)
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}
}
.navigationViewStyle(.stack)
}
}
@@ -12,11 +12,15 @@ import WebKit
struct ConditionsWebView: UIViewRepresentable {
@State var html: String
var openLink: (URL) -> Void = openExternalLink
@EnvironmentObject var theme: AppTheme
@State var pageLoaded = false
func makeUIView(context: Context) -> WKWebView {
let view = WKWebView()
let config = WKWebViewConfiguration()
config.defaultWebpagePreferences.allowsContentJavaScript = false
config.websiteDataStore = .nonPersistent()
let view = WKWebView(frame: .zero, configuration: config)
view.backgroundColor = .clear
view.isOpaque = false
view.navigationDelegate = context.coordinator
@@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable {
}
private func loadPage(_ webView: WKWebView) {
let nonce = UUID().uuidString
let styles = """
<style>
<style nonce="\(nonce)">
body {
color: \(theme.colors.onBackground.toHTMLHex());
font-family: Helvetica;
@@ -48,9 +53,23 @@ struct ConditionsWebView: UIViewRepresentable {
font-family: Menlo;
background: \(theme.colors.secondary.opacity(theme.colors.isLight ? 0.2 : 0.3).toHTMLHex());
}
pre {
overflow-x: auto;
}
table {
border-collapse: collapse;
}
th, td {
border: 1px solid \(theme.colors.secondary.toHTMLHex());
padding: 4px 8px;
}
img {
max-width: 100%;
}
</style>
"""
let head = "<head><meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no'>\(styles)</head>"
let csp = "<meta http-equiv='Content-Security-Policy' content=\"default-src 'none'; style-src 'nonce-\(nonce)'; img-src data:; base-uri 'none'; form-action 'none'\">"
let head = "<head>\(csp)<meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no'>\(styles)</head>"
webView.loadHTMLString(head + html, baseURL: nil)
DispatchQueue.main.async {
pageLoaded = true
@@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable {
}
func makeCoordinator() -> Cordinator {
Cordinator()
Cordinator(openLink: openLink)
}
class Cordinator: NSObject, WKNavigationDelegate {
let openLink: (URL) -> Void
init(openLink: @escaping (URL) -> Void) {
self.openLink = openLink
}
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
@@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable {
switch navigationAction.navigationType {
case .linkActivated:
decisionHandler(.cancel)
openExternalLink(url)
openLink(url)
default:
decisionHandler(.allow)
decisionHandler(url.scheme == "about" ? .allow : .cancel)
}
}
}
@@ -84,6 +84,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage"
let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"
let DEFAULT_CURRENT_THEME = "currentTheme"
let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme"
@@ -145,6 +146,7 @@ let appDefaults: [String: Any] = [
DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true,
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false,
DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue,
DEFAULT_THEME_OVERRIDES: "{}",
@@ -168,6 +168,7 @@
647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
@@ -560,6 +561,7 @@
647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
@@ -1123,6 +1125,7 @@
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */,
648010AA281ADD15009009B9 /* CIFileView.swift */,
6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */,
644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */,
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */,
@@ -1600,6 +1603,7 @@
5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */,
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */,
644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */,
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
+6
View File
@@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura
Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download.
### MarkdownFileView
**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift)
Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it.
### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14)
**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift)
+2 -2
View File
@@ -77,8 +77,8 @@ kotlin {
api("dev.icerock.moko:resources-compose:0.27.1")
// Markdown
implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0")
implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0")
implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0")
implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0")
}
}
val commonTest by getting {
@@ -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 ->
+2 -2
View File
@@ -307,7 +307,7 @@ UserPicker
| | OR (when no chat selected): | GroupProfileView |
| UserPicker (overlay) | "No selected chat" | AddGroupMembersView |
| - profile switcher | | MemberAdmission |
| - quick settings | OR (when modal open): | VerifyCodeView |
| - quick settings | Over them (when modal open): | VerifyCodeView |
| | ModalManager.center content | SettingsView subtabs |
| ModalManager.start | (settings, new chat, etc.) | |
| - secondary modals | | ModalManager.end |
@@ -316,7 +316,7 @@ UserPicker
ModalManager Placement (Desktop):
- ModalManager.start -> left panel overlay (settings subviews)
- ModalManager.center -> center panel (replaces chat, used when chatId is null)
- ModalManager.center -> center panel (drawn over the chat or the placeholder)
- ModalManager.end -> right panel (detail/info views)
- ModalManager.fullscreen -> full window overlay (onboarding, auth, call)
@@ -313,6 +313,7 @@ Key sections: group profile, group link, member list with roles, group preferenc
| `EmojiItemView.kt` | Large emoji-only message |
| `FramedItemView.kt` | Message bubble frame (quoted item, text, media) |
| `ImageFullScreenView.kt` | Fullscreen image gallery |
| `MarkdownFileView.kt` | Markdown file viewer, formatted or as text |
| `IntegrityErrorItemView.kt` | Message integrity error |
| `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message |
| `TextItemView.kt` | Plain text message with markdown |
+3 -3
View File
@@ -178,7 +178,7 @@ fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>)
|---|---|---|
| **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay |
| **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` |
| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) |
| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView with `ModalManager.center` over it; without a chat: `ModalManager.center` or "no selected chat" placeholder) |
| **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) |
### Column Separators
@@ -196,7 +196,7 @@ When the UserPicker is visible or a start modal is open (but no center modal), a
- When `chatId` is null and no center modals: shows "No selected chat" placeholder.
- When `chatId` is null and center modals open: shows `ModalManager.center`.
- When `chatId` is set: shows `ChatView`.
- When `chatId` is set: shows `ChatView`, and `ModalManager.center` over it.
- Automatically closes center modals when a chat is selected.
### StartPartOfScreen
@@ -223,7 +223,7 @@ class ModalManager(private val placement: ModalPlacement?)
| Zone | Android Behavior | Desktop Behavior |
|---|---|---|
| `start` | Shared (same as all others) | Left column overlay, slides from start |
| `center` | Shared | Center column overlay, replaces ChatView |
| `center` | Shared | Center column overlay, drawn over ChatView |
| `end` | Shared | Right column, slides from end |
| `fullscreen` | Shared | Fullscreen overlay |
+319
View File
@@ -0,0 +1,319 @@
# Markdown file viewer: implementation plan
Markdown files from chats are viewed in the Android, desktop and iOS apps, formatted or as text.
## 1. Files that qualify
By name and size only:
```
name file.fileName ends with .md or .markdown, case-insensitive
size file.fileSize <= MAX_MARKDOWN_VIEW_SIZE
```
```
MAX_MARKDOWN_VIEW_SIZE 1_048_576
Kotlin views/chat/item/MarkdownFileView.kt, private
iOS Shared/Views/Chat/ChatItem/MarkdownFileView.swift, private
```
Function, in each viewer file:
```
Kotlin fun isMarkdownFile(file: CIFile): Boolean
iOS func isMarkdownFile(_ file: CIFile) -> Bool
```
Other files, and Markdown files over the limit: tap actions unchanged.
## 2. Tap and long press
Tap on a qualifying file, every platform:
```
file status action
received or sent, file loaded viewer
desktop with a linked mobile loadRemoteFile, then viewer
RcvInvitation, RcvAborted reception started; viewer shown on completion, in the same place
other statuses as now: waiting alerts, error alerts
```
Long press: the existing Save item of the item menu (`ChatItemView.kt`, `ChatView.swift`).
Places:
```
Kotlin CIFileView.kt, onClick and fileAction
iOS CIFileView.swift, fileAction
```
- Android: "Open with" of `SaveOrOpenFileMenu` is not shown for qualifying files; "Share" in the item menu stays.
- Present locally: `getLoadedFilePath(file)` (Kotlin), `getLoadedFileSource(file)` (iOS), as for other files.
## 3. Open on completion
In master (#7700), for images, voice messages, video and files. The viewer adds only the Markdown case to the file tap action.
A tap that starts reception registers the file. On completion the tap action of the received file is performed:
```
image full screen view
voice playback
video playback; locally encrypted: decrypted first; chat list preview: full screen player
file Markdown: viewer
other: share sheet (iOS), open/save menu (Android), save dialog (desktop)
```
Kotlin, chat model:
```
fileToOpen file ID of the last tapped file
inChatList: tap in a chat list preview
openWhenReceived(file, inChatList) sets fileToOpen
receivedFileToOpen(file, inChatList) false unless fileToOpen has file.fileId and inChatList, and the file is loaded
then fileToOpen cleared
chat view: true
chat list preview: true on desktop, or when no chat is open
```
Item views: `OpenWhenReceivedEffect(file, inChatList, open)` (`CIFileView.kt`), on a change of `file.loaded`; in `CIImageView.kt`, `CIVoiceView.kt`, `CIVideoView.kt`, `CIFileView.kt`.
iOS, chat model:
```
fileToOpen file ID of the last tapped file
chat ID at the tap; nil in the chat list
open callback of the tapped view
openWhenReceived(file, open) sets fileToOpen
receivedFileToOpen(file) fileToOpen has file.fileId, and the open chat is the chat at the tap
openReceivedFile(file) receivedFileToOpen rechecked; fileToOpen cleared; open(file)
```
Event `rcvFileComplete` (`SimpleXAPI.swift`): for the active user, when `receivedFileToOpen(file)`: `openReceivedFile(file)` on the main actor.
`fileToOpen` is replaced by a later tap.
## 4. Reading in memory
The file is read and decoded off the main thread, before the viewer is shown.
```
Kotlin withLongRunningApi
encrypted: readCryptoFile(path, cryptoArgs) (CryptoFile.kt:36)
plain: File(path).readBytes()
text: UTF-8 CharsetDecoder, REPLACE with "?"
parsed: parseMarkdownFlow (renderer), once, before the viewer is shown
iOS Task
getFileData(path, cryptoArgs) (SimpleXChat/ImageUtils.swift:41)
text: Unicode.UTF8.ForwardParser, "?" for each invalid byte
html: MarkdownParser with an .html modifier, raw HTML escaped
```
Text decoding, as the core safeDecodeUtf8 (simplexmq Util.hs:383-386):
```
invalid UTF-8 "?" for each invalid byte
C0 controls U+0000-U+001F replaced with "?", except tab, line feed, carriage return
line endings CRLF and CR are line breaks: Kotlin text.lines(); iOS: replaced with LF before parsing
```
Limits, checked before rendering; over a limit: text view only, the switch hidden:
```
line length 15 000 characters
nesting Kotlin: 32 levels of the parsed tree; iOS: 16 levels estimated from line prefixes
link nesting iOS: 4 open square brackets, counted until an empty line
table rows 200
parse error Kotlin: text view only
```
Text view: lines longer than 15 000 characters are split.
- `readCryptoFile`: core `chatReadFile` (`Mobile/File.hs:87`); the file is decrypted by `CF.readFile` into memory.
- Bytes, text and HTML are held only in memory, in the viewer's state.
- Read error: alert with the error text; the viewer is not shown.
- File absent: "File not found" alert (Kotlin); no action (iOS), as for other files.
## 5. Viewer
```
presentation Android: full screen, like the image viewer
desktop: over the conversation
iOS: sheet
toolbar back (Kotlin)
switch between the formatted and the text view
save
title file name
preference view chosen last; default formatted
```
### 5.1. Kotlin
Renderer: `com.mikepenz:multiplatform-markdown-renderer` and `-m2`, 0.27.0 → 0.34.0 (`common/build.gradle.kts:80-81`).
```
0.34.0 dependencies kotlin-stdlib 2.1.20, org.jetbrains:markdown 0.7.3, kotlinx-collections-immutable 0.3.8
compose compileOnly; built with Compose 1.8.0, the app uses 1.8.2
app Kotlin 2.1.20, Compose Multiplatform 1.8.2
added table component, LazyMarkdownSuccess, success lambda of Markdown()
```
New file `views/chat/item/MarkdownFileView.kt`:
```
isMarkdownFile(file)
showMarkdownFile(file) loadRemoteFile if needed, read, ModalManager.center.showModal
MarkdownFileView formatted or text view by appPrefs.markdownFileTextView
```
Formatted view:
```
renderer m2 Markdown(state), GFM flavour, the parsed state from showMarkdownFile
styles documentMarkdownStyle(h1) (OperatorView.kt), shared with the operator conditions
h1: typography h2, 26 sp, bold
lazy success lambda: LazyColumnWithScrollBar, title item, one item per top-level node: MarkdownElement in a Column
links LocalUriHandler: markdownFileUriHandler
code spans annotator: source text between the backticks; inlineCode style, inlineCodeBackground
images data:image URIs: DataImageTransformer, decoded off the main thread by decodeBase64Image
other image links: the annotator appends the source text; nothing is fetched
tables default table component
```
Links, classified by the core markdown parser (`parseToMarkdown`), as message links:
```
SimplexLink openVerifiedSimplexUri
Uri, http or https openBrowserAlert
other showInvalidLinkAlert
```
Displayed link targets (`displayedUri`, Kotlin and iOS; also in message link alerts and the external-link alert):
```
format characters (Cf) [U+XXXX]
non-ASCII in the authority [U+XXXX]
HTML not parsed (parseHtml = false)
```
Images:
```
decode decodeBase64Image(base64, window width): power-of-two subsampling, both sides kept at or above the window width
Android: decodeSampledBitmapFromByteArray, 4320 px per side
desktop: decodeBoundedBufferedImage, 16384 px per side, 4320 × 4320 decoded pixels
budget 2160 × 4320 decoded pixels per document; images over the budget are not shown
cache per document, in the transformer; the desktop base64BitmapCache is not used
```
Text view: `LazyColumnWithScrollBar`, title item, one monospace `Text` per line, inside `SelectionContainer`; format characters shown as `[U+XXXX]` markers.
Bidi controls (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069): warning under the title, both views.
Toolbar end buttons:
```
switch ic_code in the formatted view, ic_article in the text view; sets appPrefs.markdownFileTextView; hidden over the limits
save ic_download; rememberFileChooserLauncher; copyBytesToFile(ByteArrayInputStream(bytes)) (Files.kt:65)
```
`OperatorView.kt`:
```
markdownColor(linkText = primary) markdownColor(); link colour moved to the link text style (linkText deprecated in 0.34.0)
ConditionsMarkdown styles moved to documentMarkdownStyle(h1): DocumentMarkdownStyle (colors, typography, components, padding)
h1: body1 by default, as in the conditions
```
`SimpleXAPI.kt`: `markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)`.
Centre placement on desktop: modals are drawn over the open conversation.
```
ModalView.kt showCustomModal: chatId is not changed
App.kt CenterPartOfScreen: ModalManager.center.showInView() after ChatView
ChatListNavLinkView.kt "Set contact name": back button on desktop
```
`moko-resources/base/strings.xml`:
```
markdown_formatted_view Formatted view
markdown_text_view Text view
markdown_file_bidi_warning This file has text direction characters. The text can be shown in a different order than it is written.
```
### 5.2. iOS
New file `Shared/Views/Chat/ChatItem/MarkdownFileView.swift`, added to the SimpleX target in `project.pbxproj`.
```
isMarkdownFile(file)
showMarkdownFile(file) read, parse, showAppSheet
MarkdownFileView NavigationView; toolbar: switch, save
```
```
formatted ConditionsWebView(html:openLink:), Ink 0.6.0, raw HTML escaped by an .html modifier
text ScrollView, LazyVStack, one monospace Text per line, text selection, format characters as [U+XXXX]
switch @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW); hidden over the limits
save saveCryptoFile(fileSource) (CIFileView.swift)
close swipe down
links openMarkdownFileLink: parseSimpleXMarkdown; simplexLink: appOpenUrl; uri, http or https: openBrowserAlert; other: showInvalidLinkAlert
```
`SettingsView.swift`: `DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"`, default false.
`ConditionsWebView.swift`, shared with the operator conditions:
```
configuration allowsContentJavaScript = false; websiteDataStore = .nonPersistent()
head Content-Security-Policy: default-src 'none'; style-src 'nonce-<UUID per load>'; img-src data:; base-uri 'none'; form-action 'none'
style the app's style block with the nonce; table, th, td borders; pre overflow-x auto; img max-width 100%
navigation linkActivated: openLink (openExternalLink by default)
other navigation: allowed for about: URLs only
```
- Ink 0.6.0 applies `.html` modifiers to block and inline HTML fragments (`HTMLConvertible.swift`); the viewer's modifier escapes `&`, `<`, `>`; code spans and blocks are escaped by Ink itself.
Images, an `.images` modifier (`imagesWithinBudget`):
```
data:image, base64 shown as <img src>, rebuilt from the source only
4320 px per side; 2160 × 4320 pixels per document; size read by UIImage(data:)
over a limit not shown
other data URIs not shown
other image links the source text; nothing is fetched
```
## 6. Size limit
1 MB.
4 MB: formatted views render lazily (Kotlin: one lazy item per top-level node; iOS: WKWebView); text views render one lazy item per line.
A single top-level block (one table, code block or paragraph) is still laid out at once.
## 7. Checks
1. Plain and encrypted files: formatted and text views on Android, desktop and iOS.
2. Desktop linked to a mobile: file loaded from the mobile, then shown.
3. File over 1 MB: tap action as before.
4. File not received: one tap; viewer shown on completion in the same chat; not shown after leaving the chat.
5. Same for images, voice messages, video and other files, each with its tap action.
6. Tap in a chat list preview: opened on completion while the chat list is shown; Android, iOS: not opened after a chat is opened; desktop: opened while a chat is open.
7. Not opened after a reception error, cancel or abort. Kotlin: not opened after the item scrolled out of view.
8. Invalid UTF-8 and control characters: shown as "?".
9. Lines over 15 000 characters, 33 tree levels (Kotlin), 17 nesting levels (iOS), 5 nested square brackets (iOS), 201 table rows: text view only, long lines split.
10. Bidi controls: warning; format characters as [U+XXXX] in the text view.
11. Data images: decoded to the window width (Kotlin); images over 4320 px per side (Android, iOS) or beyond 2160 × 4320 pixels per document not shown.
12. Links: SimpleX links to the connect flow; http(s) links to the "Open web link?" alert; other schemes and relative links: "Invalid link"; targets with invisible or non-ASCII host characters shown with [U+XXXX].
13. View switch kept after closing the viewer and after a restart.
14. Save: the file at the chosen destination equals the original.
15. Link inside backticks: shown as code.
16. Remote image: not loaded, the source text shown; data URI image: shown.
17. iOS inline HTML with `<script>`, `<img src=https:...>`, `<iframe>`, `<style>`, `<meta http-equiv="refresh">`: shown as text.
18. Tables on both platforms.
19. Operator conditions: rendering unchanged, links in the primary colour.
20. File with CRLF line endings: same formatted and text views as with LF.
21. Level 1 heading in the Kotlin formatted view: larger than level 2.
22. Desktop: the viewer covers the conversation only; back or Escape returns to the conversation; opening another chat closes the viewer.
23. Desktop, other centre modals over an open chat (What's New, create profile, "Set contact name", invalid data item): the chat is shown again after back.