mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-20 12:22:23 +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:
co-authored by
Evgeny @ SimpleX Chat
parent
7d870c6ff5
commit
2f4a443dda
@@ -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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
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 */,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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 ->
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user