From 056346020487d4c78ac538957c6baef9865c884a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 18 Oct 2026 22:44:33 +0100 Subject: [PATCH] core, ui: support block markdown (#7734) * plan: support block markdown * update plans * core, ui: markdown plans and implementation * better * simplify * markdown tests and fixes * implement * fixes * fix test * bold * block markdown rendering * fix selection and rtl * fixes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/FramedItemView.swift | 7 +- .../Views/Chat/ChatItem/MsgContentView.swift | 461 +++++++++- .../Shared/Views/Chat/ChatItemInfoView.swift | 19 +- apps/ios/Shared/Views/Chat/ChatView.swift | 7 +- .../Chat/ComposeMessage/ContextItemView.swift | 2 +- .../Views/Chat/Group/GroupWelcomeView.swift | 7 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/API.swift | 12 +- apps/ios/SimpleXChat/ChatTypes.swift | 119 ++- apps/ios/SimpleXChat/Notifications.swift | 9 +- apps/ios/spec/client/chat-view.md | 2 +- .../chat/simplex/common/model/ChatModel.kt | 98 +- .../simplex/common/platform/NtfManager.kt | 7 +- .../simplex/common/views/chat/ChatInfoView.kt | 6 +- .../common/views/chat/ChatItemInfoView.kt | 12 +- .../common/views/chat/ContextItemView.kt | 2 +- .../common/views/chat/TextSelection.kt | 97 +- .../views/chat/group/WelcomeMessageView.kt | 7 +- .../common/views/chat/item/FramedItemView.kt | 13 +- .../common/views/chat/item/MarkdownBlocks.kt | 248 +++++ .../common/views/chat/item/TextItemView.kt | 183 +++- .../common/views/chatlist/ChatPreviewView.kt | 1 + .../common/views/helpers/TextEditor.kt | 9 +- .../images/vertical_logo@1x.png | Bin 0 -> 1234 bytes .../images/vertical_logo@2x.png | Bin 0 -> 1617 bytes .../images/vertical_logo@3x.png | Bin 0 -> 2010 bytes .../chat/simplex/app/MarkdownBlocksTest.kt | 133 +++ apps/multiplatform/spec/client/chat-view.md | 2 +- .../src/Directory/Listing.hs | 15 +- .../src/Directory/Service.hs | 8 +- bots/api/TYPES.md | 144 +++ bots/src/API/Docs/Types.hs | 12 +- .../types/typescript/src/types.ts | 197 ++++ .../src/simplex_chat/types/_types.py | 142 ++- plans/2026-10-13-block-markdown-rendering.md | 415 +++++++++ plans/2026-10-13-block-markdown.md | 867 ++++++++++++++++++ src/Simplex/Chat/Help.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 14 +- src/Simplex/Chat/Library/Internal.hs | 44 +- src/Simplex/Chat/Library/Subscriber.hs | 5 +- src/Simplex/Chat/Markdown.hs | 598 ++++++++++-- src/Simplex/Chat/Messages.hs | 13 +- src/Simplex/Chat/Mobile.hs | 4 +- src/Simplex/Chat/Store/Messages.hs | 30 +- src/Simplex/Chat/Styled.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- src/Simplex/Chat/Web.hs | 24 +- tests/ChatTests/Groups.hs | 6 + tests/JSONFixtures.hs | 10 +- tests/JSONTests.hs | 26 +- tests/MarkdownTests.hs | 616 ++++++++++++- website/src/directory.html | 51 ++ website/src/js/channel-preview.jsc | 70 +- website/src/js/directory.jsc | 9 +- website/src/js/simplex-lib.jsc | 159 +++- 55 files changed, 4641 insertions(+), 311 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png create mode 100644 apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png create mode 100644 apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt create mode 100644 plans/2026-10-13-block-markdown-rendering.md create mode 100644 plans/2026-10-13-block-markdown.md diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index f822970ea4..b6bac78d46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -334,7 +334,7 @@ struct FramedItemView: View { @inline(__always) private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, markdownBlocks: qi.markdownBlocks, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) .lineLimit(lines) .padding(.bottom, 6) } @@ -364,13 +364,16 @@ struct FramedItemView: View { chat: chat, text: text, formattedText: ft, + markdownBlocks: text == "" ? nil : ci.markdownBlocks, textStyle: .body, meta: ci.meta, mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, prefix: txtPrefix, - stripLink: stripLink + stripLink: stripLink, + blockLayout: true, + cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote ) .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index f95e31369e..10bd784b7e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -32,6 +32,7 @@ struct MsgContentView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil + var markdownBlocks: [MarkdownBlock]? = nil var textStyle: UIFont.TextStyle var sender: String? = nil var meta: CIMeta? = nil @@ -40,6 +41,8 @@ struct MsgContentView: View { var rightToLeft = false var prefix: NSAttributedString? = nil var stripLink: String? = nil + var blockLayout = false + var cardColor: Color? = nil @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? @@ -51,7 +54,7 @@ struct MsgContentView: View { @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true var body: some View { - let v = msgContentView() + let v = contentView() if meta?.isLive == true { v.onAppear { let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) @@ -95,11 +98,67 @@ struct MsgContentView: View { typingIdx = 0 } + @ViewBuilder + private func contentView() -> some View { + let fts = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } + if blockLayout, let fts, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: textStyle, + cardColor: cardColor ?? theme.appColors.receivedQuote, + rightToLeft: rightToLeft, + blockText: { index, range, font, secretStart, last in blockTextView(fts, blocks, index, range, font, secretStart, last) }, + metaLine: { metaLineView() } + ) + } else { + msgContentView() + } + } + + private func blockTextView(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> some View { + let r = messageText( + text, + fts, + markdownBlocks: blocks, + textStyle: textStyle, + sender: index == 0 ? sender : nil, + mentions: mentions, + userMemberId: userMemberId, + showSecrets: showSecrets, + commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, + backgroundColor: containerBackground, + prefix: index == 0 ? prefix : nil, + range: range, + baseFont: font, + secretStart: secretStart + ) + let s = r.string + let t: Text + if last, let mt = meta { + if mt.isLive { + s.append(typingIndicator(mt.recent)) + } + t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) + } else { + t = Text(AttributedString(s)) + } + return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) }) + } + + @ViewBuilder + private func metaLineView() -> some View { + if let mt = meta { + (mt.isLive ? Text(AttributedString(typingIndicator(mt.recent))) : Text(verbatim: "")) + reserveSpaceForMeta(mt, newLine: false) + } + } + @inline(__always) private func msgContentView() -> some View { let r = messageText( text, formattedText, + markdownBlocks: markdownBlocks, textStyle: textStyle, sender: sender, mentions: mentions, @@ -131,8 +190,8 @@ struct MsgContentView: View { } @inline(__always) - private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) + private func reserveSpaceForMeta(_ mt: CIMeta, newLine: Bool = true) -> Text { + (rightToLeft && newLine ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) } } @@ -317,8 +376,11 @@ private struct FullProfileDescriptionView: View { .listRowBackground(Color.clear) Section { - let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background) - msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(description) + MarkdownView( + text: description, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } @@ -338,9 +400,11 @@ func markdownText( showSecrets: Set? = nil, backgroundColor: Color ) -> MsgTextResult { - messageText( + let parsed = parseSimpleXMarkdownText(s) + return messageText( s, - parseSimpleXMarkdown(s), + parsed?.formattedText, + markdownBlocks: parsed?.markdownBlocks, textStyle: textStyle, sender: sender, preview: preview, @@ -396,6 +460,7 @@ func markdownProfileDescription( func messageText( _ text: String, _ formattedText: [FormattedText]?, + markdownBlocks: [MarkdownBlock]? = nil, textStyle: UIFont.TextStyle = .body, sender: String?, preview: Bool = false, @@ -405,21 +470,24 @@ func messageText( commands: Bool = false, backgroundColor: UIColor, prefix: NSAttributedString? = nil, - stripLink: String? = nil + stripLink: String? = nil, + range: Range? = nil, + baseFont: UIFont? = nil, + secretStart: Int = 0 ) -> MsgTextResult { let text = if let stripLink { stripTextLink(text, stripLink) } else { text } let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } let res = NSMutableAttributedString() - let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) - let font = UIFont.preferredFont(forTextStyle: textStyle) + let descr = baseFont?.fontDescriptor ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) + let font = baseFont ?? UIFont.preferredFont(forTextStyle: textStyle) let plain: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: UIColor.label ] let secretColor = backgroundColor.withAlphaComponent(1) - var link: [NSAttributedString.Key: Any]? var hasSecrets = false var handleTaps = false + var secretIdx: Int = secretStart if let sender { if preview { @@ -437,19 +505,57 @@ func messageText( } if let fts = formattedText, fts.count > 0 { + if let range { + let start = min(max(range.lowerBound, 0), fts.count) + appendSegments(fts[start.. (descr: UIFontDescriptor, font: UIFont) { + if !heading { return (descr, font) } + let d = descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]) + return (d, UIFont(descriptor: d, size: d.pointSize)) + } + + func appendSegments(_ fts: ArraySlice, _ heading: Bool) { + let (descr, font) = segmentFonts(heading) + let plain: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.label + ] + var link: [NSAttributedString.Key: Any]? var bold: UIFont? var italic: UIFont? var snippet: UIFont? var small: UIFont? var mention: UIFont? - var secretIdx: Int = 0 for ft in fts { var t = ft.text var attrs = plain switch (ft.format) { - case .bold: - bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) - attrs[.font] = bold + case let .bold(singleStar): + if singleStar && markdownBlocks != nil { + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + } else { + bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) + attrs[.font] = bold + } case .italic: italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) attrs[.font] = italic @@ -496,7 +602,7 @@ func messageText( if let text { t = text } if !preview { attrs[linkAttrKey] = uri - attrs[webLinkAttrKey] = true + if !ft.isEmailLink { attrs[webLinkAttrKey] = true } handleTaps = true } case let .simplexLink(text, linkType, simplexUri, smpHosts): @@ -563,24 +669,319 @@ func messageText( attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text) handleTaps = true } + case .escape: t = "" + case let .markup(token): + if range != nil { t = token.drawnText } else if markdownBlocks != nil { t = token.previewText } case .unknown: () case .none: () } res.append(NSAttributedString(string: t, attributes: attrs)) } - } else { - res.append(NSMutableAttributedString(string: text, attributes: plain)) - } - - return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps) - func linkAttrs() -> [NSAttributedString.Key: Any] { - link = link ?? [ - .font: font, - .foregroundColor: uiLinkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue - ] - return link! + func linkAttrs() -> [NSAttributedString.Key: Any] { + link = link ?? [ + .font: font, + .foregroundColor: uiLinkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return link! + } + } +} + +let swipeExclusionSpace = "swipeExclusion" + +private let maxGridCells = 1000 + +struct SwipeExclusionFrames: PreferenceKey { + static var defaultValue: [CGRect] = [] + + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value += nextValue() + } +} + +struct MarkdownView: View { + var text: String + var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? + var sender: String? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil + var backgroundColor: Color + var cardColor: Color + @Binding var showSecrets: Set + + var body: some View { + if let fts = formattedText, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: .body, + cardColor: cardColor, + rightToLeft: isRightToLeft(text), + blockText: { index, range, font, secretStart, _ in + let r = messageText( + text, fts, markdownBlocks: blocks, sender: index == 0 ? sender : nil, mentions: mentions, userMemberId: userMemberId, + showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor), range: range, baseFont: font, secretStart: secretStart + ) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + }, + metaLine: { EmptyView() } + ) + } else { + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + } + } +} + +private struct BlocksLayout { + let font: UIFont + let starts: [Int] + let secrets: [Int] + let lastIsText: Bool + + init(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ textStyle: UIFont.TextStyle) { + font = UIFont.preferredFont(forTextStyle: textStyle) + starts = blocks.reduce(into: [0]) { $0.append($0[$0.count - 1] + $1.textRanges.count) } + secrets = fts.reduce(into: [0]) { $0.append($0[$0.count - 1] + ($1.isSecret ? 1 : 0)) } + lastIsText = switch blocks.last { + case .paragraph, .heading, .quote, .list: true + default: false + } + } +} + +struct MarkdownBlocksView: View { + @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness + var formattedText: [FormattedText] + var blocks: [MarkdownBlock] + var textStyle: UIFont.TextStyle + var cardColor: Color + var rightToLeft: Bool + @ViewBuilder var blockText: (_ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> BlockText + @ViewBuilder var metaLine: () -> MetaLine + + var body: some View { + let l = BlocksLayout(formattedText, blocks, textStyle) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(blocks.enumerated()), id: \.offset) { k, block in + if k > 0 && paragraphBreak(blocks[k - 1].endIndex, block.startIndex) { + Color.clear.frame(height: l.font.lineHeight / 2) + } + blockView(block, l.starts[k], l) + } + if !l.lastIsText { + metaLine() + } + } + .multilineTextAlignment(.leading) + .environment(\.layoutDirection, rightToLeft ? .rightToLeft : .leftToRight) + } + + private func text(_ index: Int, _ r: SegmentRange, _ font: UIFont, _ l: BlocksLayout) -> BlockText { + blockText(index, r.from.. some View { + switch block { + case let .paragraph(content): + text(start, content, l.font, l) + case let .heading(level, content): + text(start, content, headingFont(l.font, level), l) + case let .quote(content): + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2).fill(Color(uiColor: .label).opacity(0.16)).frame(width: 4) + text(start, content, l.font, l).opacity(0.72) + } + .fixedSize(horizontal: false, vertical: true) + case let .list(items): + ForEach(Array(listEntries(items, 0).enumerated()), id: \.offset) { j, e in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(e.entry.number ?? "•").font(.system(size: l.font.pointSize)) + text(start + j, e.entry.content, l.font, l) + } + .padding(.leading, 20 * CGFloat(e.level)) + } + case let .codeBlock(_, content): + CodeBlockCard(color: cardColor, radius: 12 * roundness, copyText: codeText(content)) { + text(start, content, monospacedFont(l.font), l).fixedSize(horizontal: true, vertical: false) + } + case let .table(columns, header, rows): + tableView(columns, [header] + rows, start, l) + case .rule: + Rectangle().fill(Color(uiColor: .secondaryLabel).opacity(0.35)).frame(height: 1).padding(.vertical, 6) + } + } + + @ViewBuilder private func tableView(_ columns: [TableAlign], _ tableRows: [[SegmentRange]], _ start: Int, _ l: BlocksLayout) -> some View { + let rowStarts = tableRows.reduce(into: [start]) { $0.append($0[$0.count - 1] + $1.count) } + let columnCount = max(columns.count, tableRows.map(\.count).max() ?? 0) + let lineColor = Color(uiColor: .secondaryLabel).opacity(0.35) + let radius = 12 * roundness + if #available(iOS 16, *) { + if columnCount * tableRows.count <= maxGridCells { + HScroll { + Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(tableRows.enumerated()), id: \.offset) { r, row in + GridRow { + ForEach(0.. some View { + let source = tableSource(tableRows) + return CodeBlockCard(color: cardColor, radius: radius, copyText: source) { + Text(verbatim: source).font(.system(size: l.font.pointSize * 0.875, design: .monospaced)).fixedSize(horizontal: true, vertical: false) + } + } + + private func paragraphBreak(_ from: Int, _ to: Int) -> Bool { + let start = min(max(from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max(r.from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max((tableRows.first?.first?.from ?? 1) - 1, 0), formattedText.count) + let end = min((tableRows.last?.last?.to ?? 0) + 1, formattedText.count) + return formattedText[start.. UIFont { + let scale: CGFloat = switch level { + case 1: 1.5 + case 2: 1.3 + case 3: 1.15 + default: 1 + } + return UIFont(descriptor: boldFont(font).fontDescriptor, size: font.pointSize * scale) +} + +private func boldFont(_ font: UIFont) -> UIFont { + UIFont(descriptor: font.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: font.pointSize) +} + +private func monospacedFont(_ font: UIFont) -> UIFont { + UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.875, weight: .regular) +} + +private func cellAlignment(_ align: TableAlign) -> Alignment { + switch align { + case .right: .trailing + case .center: .center + default: .leading + } +} + +private struct CodeBlockCard: View { + @EnvironmentObject var theme: AppTheme + var color: Color + var radius: CGFloat + var copyText: String + @ViewBuilder var content: () -> Content + @State private var showCopy = false + + var body: some View { + HScroll { + content() + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .topTrailing) { + if showCopy { + Button { + UIPasteboard.general.string = copyText + showCopy = false + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(theme.colors.primary) + .frame(width: 28, height: 28) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius * 2 / 3)) + } + .padding(4) + } + } + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .onTapGesture { showCopy.toggle() } + } +} + +private struct HScroll: View { + @ViewBuilder var content: () -> Content + + var body: some View { + if #available(iOS 16, *) { + ViewThatFits(in: .horizontal) { + content() + scrollView() + } + } else { + scrollView() + } + } + + private func scrollView() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + content() + } + .background { + GeometryReader { g in + Color.clear.preference(key: SwipeExclusionFrames.self, value: [g.frame(in: .named(swipeExclusionSpace))]) + } + } } } @@ -608,7 +1009,9 @@ func stripTextLink(_ text: String, _ link: String) -> String { func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? { guard var ft, ft.last?.text == link else { return ft } ft.removeLast() - if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { + if let i = ft.indices.last, case .markup = ft[i].format { + ft.removeLast() + } else if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { ft[i].text = String(ft[i].text.dropLast()) if ft[i].text.isEmpty { ft.removeLast() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index ffcf525300..3fff0e34d1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -267,7 +267,7 @@ struct ChatItemInfoView: View { private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { let backgroundColor = chatItemFrameColor(ci, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor) + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, itemVersion.markdownBlocks, nil, backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(backgroundColor) @@ -295,9 +295,12 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ markdownBlocks: [MarkdownBlock]?, _ sender: String? = nil, backgroundColor: Color) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) + TextBubble( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote + ) } else { Text("no text") .italic() @@ -309,15 +312,19 @@ struct ChatItemInfoView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? var backgroundColor: Color + var cardColor: Color @State private var showSecrets: Set = [] var body: some View { - let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + MarkdownView( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: cardColor, showSecrets: $showSecrets + ) } } @@ -342,7 +349,7 @@ struct ChatItemInfoView: View { private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { let backgroundColor = quotedMsgFrameColor(qi, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor) + textBubble(qi.text, qi.formattedText, qi.markdownBlocks, qi.getSender(nil), backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 29a1be3d65..2a903fcd91 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1735,6 +1735,7 @@ struct ChatView: View { @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil @State private var swipeOffset: CGFloat = 0 + @State private var swipeExclusionFrames: [CGRect] = [] var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -2156,12 +2157,14 @@ struct ChatView: View { .padding(.bottom, 4) } } + .coordinateSpace(name: swipeExclusionSpace) + .onPreferenceChange(SwipeExclusionFrames.self) { swipeExclusionFrames = $0 } .offset(x: swipeOffset) .contentShape(Rectangle()) .simultaneousGesture( - DragGesture(minimumDistance: 10) + DragGesture(minimumDistance: 10, coordinateSpace: .named(swipeExclusionSpace)) .onChanged { value in - guard canReply else { return } + guard canReply, !swipeExclusionFrames.contains(where: { $0.contains(value.startLocation) }) else { return } let x = value.translation.width if x < 0 { swipeOffset = max(x * 0.63, -56) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 1f328b2061..13614692cb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> some View { - let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) + let r = messageText(contextItem.text, contextItem.formattedText, markdownBlocks: contextItem.markdownBlocks, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) let t = attachment() + Text(AttributedString(r.string)) return t.if(r.hasSecrets, transform: hiddenSecretsView) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index f58f2c213d..1bef869a66 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -59,8 +59,11 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(welcomeText) + return MarkdownView( + text: welcomeText, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 8156ea81c7..7f50f09a31 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -299,7 +299,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { let (itemText, itemFormattedText) = chatItemPreviewText(cItem) - let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) + let r = messageText(itemText, itemFormattedText, markdownBlocks: itemFormattedText == nil ? nil : cItem.markdownBlocks, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) { diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 3b860d524b..04e5973085 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -149,12 +149,15 @@ public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTi } public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { + parseSimpleXMarkdownText(s)?.formattedText +} + +public func parseSimpleXMarkdownText(_ s: String) -> ParsedMarkdown? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_markdown(&c) { if let d = dataFromCString(cjson) { do { - let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) - return r.formattedText + return try jsonDecoder.decode(ParsedMarkdown.self, from: d) } catch { logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)") } @@ -176,8 +179,9 @@ public func parseBadgeCode(_ s: String) -> String? { return canonical.isEmpty ? nil : canonical } -struct ParsedMarkdown: Decodable { - var formattedText: [FormattedText]? +public struct ParsedMarkdown: Decodable { + public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? } public func parseServerAddress(_ s: String) -> ServerAddress? { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a4d65a6d10..8bdd952372 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3793,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.markdownBlocks = markdownBlocks self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions @@ -3808,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] @@ -3817,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file + case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -4859,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) @@ -5672,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable { public var linkUri: String? { switch format { case .uri: text - case let .hyperLink(_, linkUri): linkUri + case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri default: nil } } + + public var isEmailLink: Bool { + if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false } + } +} + +public enum BlockToken: Decodable, Hashable { + case lineBreak + case softBreak + case hardBreak + case paragraphBreak(lineBreaks: Int) + case indent + case headingMark(level: Int) + case headingClose + case headingUnderline(level: Int) + case listMark(number: String?) + case quoteMark + case fenceMark(language: String?) + case rowMark + case cellMark + case delimiterRow(columns: [TableAlign]) + case ruleMark + + public var drawnText: String { + switch self { + case .softBreak: " " + case .lineBreak, .hardBreak: "\n" + case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks) + default: "" + } + } + + public var previewText: String { + switch self { + case .softBreak, .hardBreak, .cellMark: " " + case .lineBreak, .paragraphBreak: "\n" + case let .listMark(number): (number ?? "•") + " " + default: "" + } + } +} + +public enum MarkdownBlock: Decodable, Hashable { + case paragraph(content: SegmentRange) + case heading(level: Int, content: SegmentRange) + case codeBlock(language: String?, content: SegmentRange) + case quote(content: SegmentRange) + case list(items: [ListEntry]) + case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]]) + case rule(mark: SegmentRange) +} + +public struct ListEntry: Decodable, Hashable { + public var number: String? + public var content: SegmentRange + public var items: [ListEntry] +} + +public struct SegmentRange: Decodable, Hashable { + public var from: Int + public var to: Int +} + +public enum TableAlign: String, Decodable, Hashable { + case none + case left + case center + case right +} + +public struct BlockTextRange { + public var range: SegmentRange + public var inRow: Bool +} + +extension MarkdownBlock { + public var textRanges: [BlockTextRange] { + switch self { + case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content): + [BlockTextRange(range: content, inRow: false)] + case let .list(items): + listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) } + case let .table(_, header, rows): + ([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } } + case .rule: + [] + } + } +} + +public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] { + items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) } +} + +public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] { + blocks.flatMap { $0.textRanges } +} + +public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] { + var bold = [Bool](repeating: false, count: size) + for case let .heading(_, content) in blocks { + let from = min(max(content.from, 0), size) + for i in from.. String { if let md = cItem.formattedText { var res = "" for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text + switch ft.format { + case .secret: res = res + "..." + case let .markup(token): res = res + token.previewText + case .escape: () + default: res = res + ft.text } } return res diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index b63d336c34..38db89bea0 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar **File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) -Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways. ### DeletedItemView / MarkedDeletedItemView diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index bf856c13af..09c494dc76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -3407,6 +3407,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val markdownBlocks: List? = null, val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, @@ -4412,7 +4413,8 @@ class CIQuote ( val sharedMsgId: String? = null, val sentAt: Instant, val content: MsgContent, - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ): ItemContent { override val text: String by lazy { if (content.text == "" && content is MsgContent.MCVoice) @@ -5164,7 +5166,7 @@ class FormattedText(val text: String, val format: Format? = null) { val linkUri: String? get() = when (format) { is Format.Uri -> text - is Format.HyperLink -> format.linkUri + is Format.HyperLink -> if (format.isEmail) null else format.linkUri else -> null } @@ -5175,7 +5177,7 @@ class FormattedText(val text: String, val format: Format? = null) { @Serializable sealed class Format { - @Serializable @SerialName("bold") class Bold: Format() + @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() @Serializable @SerialName("italic") class Italic: Format() @Serializable @SerialName("strikeThrough") class StrikeThrough: Format() @Serializable @SerialName("snippet") class Snippet: Format() @@ -5183,7 +5185,9 @@ sealed class Format { @Serializable @SerialName("small") class Small: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() + @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() { + val isEmail: Boolean get() = linkUri.startsWith("mailto:") + } @Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() { val simplexLinkText: String get() = "${linkType.description} $viaHosts" @@ -5200,6 +5204,8 @@ sealed class Format { } @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() @Serializable @SerialName("unknown") class Unknown: Format() val style: SpanStyle @Composable get() = when (this) { @@ -5219,6 +5225,8 @@ sealed class Format { is Email -> linkStyle is Phone -> linkStyle is Modal -> linkStyle + is Escape -> SpanStyle() + is Markup -> SpanStyle() is Unknown -> SpanStyle() } @@ -5229,6 +5237,87 @@ sealed class Format { } } +@Serializable +sealed class BlockToken { + @Serializable @SerialName("lineBreak") class LineBreak: BlockToken() + @Serializable @SerialName("softBreak") class SoftBreak: BlockToken() + @Serializable @SerialName("hardBreak") class HardBreak: BlockToken() + @Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken() + @Serializable @SerialName("indent") class Indent: BlockToken() + @Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken() + @Serializable @SerialName("headingClose") class HeadingClose: BlockToken() + @Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken() + @Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken() + @Serializable @SerialName("quoteMark") class QuoteMark: BlockToken() + @Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken() + @Serializable @SerialName("rowMark") class RowMark: BlockToken() + @Serializable @SerialName("cellMark") class CellMark: BlockToken() + @Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List): BlockToken() + @Serializable @SerialName("ruleMark") class RuleMark: BlockToken() + + val drawnText: String get() = when (this) { + is SoftBreak -> " " + is LineBreak, is HardBreak -> "\n" + is ParagraphBreak -> "\n".repeat(lineBreaks) + else -> "" + } + + val previewText: String get() = when (this) { + is SoftBreak, is HardBreak, is CellMark -> " " + is LineBreak, is ParagraphBreak -> "\n" + is ListMark -> (number ?: "•") + " " + else -> "" + } +} + +@Serializable +sealed class MarkdownBlock { + @Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("list") class ItemList(val items: List): MarkdownBlock() + @Serializable @SerialName("table") class Table(val columns: List, val header: List, val rows: List>): MarkdownBlock() + @Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock() +} + +@Serializable +class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) + +@Serializable +class SegmentRange(val from: Int, val to: Int) + +@Serializable +enum class TableAlign { + @SerialName("none") None, + @SerialName("left") Left, + @SerialName("center") Center, + @SerialName("right") Right +} + +class BlockTextRange(val range: SegmentRange, val inRow: Boolean) + +val MarkdownBlock.textRanges: List get() = when (this) { + is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) } + is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } } + is MarkdownBlock.Rule -> emptyList() +} + +fun listEntries(items: List, level: Int): List> = + items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) } + +fun blockTextRanges(blocks: List): List = blocks.flatMap { it.textRanges } + +fun headingSegments(size: Int, blocks: List): BooleanArray { + val bold = BooleanArray(size) + blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true } + return bold +} + @Serializable enum class SimplexLinkType(val linkType: String) { contact("contact"), @@ -5714,6 +5803,7 @@ data class ChatItemVersion( val chatItemVersionId: Long, val msgContent: MsgContent, val formattedText: List?, + val markdownBlocks: List? = null, val itemVersionTs: Instant, val createdAt: Instant, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index ee47403cd3..290d93db71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -136,7 +136,12 @@ abstract class NtfManager { return if (md != null) { var res = "" for (ft in md) { - res += if (ft.format is Format.Secret) "..." else ft.text + res += when (ft.format) { + is Format.Secret -> "..." + is Format.Markup -> ft.format.token.previewText + is Format.Escape -> "" + else -> ft.text + } } res } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 9c019ad535..82575984f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -791,8 +791,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val linkMode = chatModel.simplexLinkMode.value if (descr == null) { if (short != null) { + val parsed = parseToMarkdownText(short) MarkdownText( - short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4, + short, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } @@ -801,8 +802,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val truncated = firstLine.length > 100 val multiline = descr.length > firstLine.length if (short == null && !truncated && !multiline) { + val parsed = parseToMarkdownText(descr) MarkdownText( - descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4, + descr, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index e4f93a0b8a..0c7f1937f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -52,17 +52,19 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools val selection = remember { mutableStateOf(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { + fun TextBubble(text: String, formattedText: List?, markdownBlocks: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( - text, if (text.isEmpty()) emptyList() else formattedText, + text, if (text.isEmpty()) emptyList() else formattedText, markdownBlocks, sender = sender, mentions = mentions, userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, - onLinkLongClick = { showMenu.value = true } + onLinkLongClick = { showMenu.value = true }, + blockLayout = true, + cardColor = if (sent) appColors.sentQuote else appColors.receivedQuote ) } else { Text( @@ -85,7 +87,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + TextBubble(text, ciVersion.formattedText, ciVersion.markdownBlocks, sender = null, showMenu = showMenu, mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null @@ -137,7 +139,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu) + TextBubble(text, qi.formattedText, qi.markdownBlocks, sender = qi.sender(null), showMenu) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index b477f56118..7f92ca0651 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -57,7 +57,7 @@ fun ContextItemView( } } else null MarkdownText( - contextItem.text, contextItem.formattedText, + contextItem.text, contextItem.formattedText, contextItem.markdownBlocks, sender = null, toggleSecrets = false, maxLines = lines, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index 20f5acd3e0..e04f75d799 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.itemPrefixText -import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.chat.item.itemDisplay import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -267,23 +267,22 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple if (sel.first < prefix.length) { sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1)) } - val formattedText = ci.formattedText ?: run { + val parts = itemDisplay(ci, linkMode)?.parts ?: run { val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length) val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length) if (start < end) sb.append(ci.text, start, end) return sb.toString() } var displayOffset = prefix.length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length val overlapStart = maxOf(displayOffset, sel.first) val overlapEnd = minOf(displayEnd, sel.last + 1) if (overlapStart < overlapEnd) { - if (ft.text.length == segDisplay.length) { - sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + if (source.length == segDisplay.length) { + sb.append(source, overlapStart - displayOffset, overlapEnd - displayOffset) } else { - sb.append(ft.text) + sb.append(source) } } displayOffset = displayEnd @@ -293,12 +292,11 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple // Snaps a boundary offset to include full transformed segments. private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { - val formattedText = ci.formattedText ?: return offset + val parts = itemDisplay(ci, linkMode)?.parts ?: return offset var displayOffset = itemPrefixText(ci).length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length - if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + if (offset > displayOffset && offset < displayEnd && source.length != segDisplay.length) { return if (expandRight) displayEnd else displayOffset } displayOffset = displayEnd @@ -425,18 +423,55 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { return idx } -class ItemSelection( +class TextSelection( val highlightRange: IntRange?, val positionModifier: Modifier, - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? + val onTextLayoutResult: (TextLayoutResult) -> Unit ) +class SelectionText(var base: Int) { + var bounds: Rect? = null + var layout: TextLayoutResult? = null +} + +class ItemSelection( + private val highlightRange: IntRange?, + private val texts: MutableMap?, + private val bases: List +) { + fun text(index: Int): TextSelection? { + val texts = texts ?: return null + val base = bases.getOrNull(index) ?: return null + val t = texts.getOrPut(index) { SelectionText(base) } + t.base = base + val highlight = highlightRange?.let { (it.first - base).coerceAtLeast(0) .. (it.last - base) }?.takeUnless { it.isEmpty() } + return TextSelection( + highlight, + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + t.bounds = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + }, + { t.layout = it } + ) + } +} + +private class TextHit(val text: SelectionText, val bounds: Rect, val layout: TextLayoutResult, val offset: Int) + +private fun textAt(texts: Collection, x: Float, y: Float): TextHit? = + texts.mapNotNull { t -> + val b = t.bounds ?: return@mapNotNull null + val l = t.layout?.takeIf { it.layoutInput.text.isNotEmpty() } ?: return@mapNotNull null + Triple(t, b, l) + }.minByOrNull { (_, b, _) -> maxOf(b.top - y, 0f, y - b.bottom) * 4 + maxOf(b.left - x, 0f, x - b.right) } + ?.let { (t, b, l) -> TextHit(t, b, l, l.getOffsetForPosition(Offset(x - b.left, y - b.top))) } + // Sets up selection tracking for a text item: anchor/focus offset resolution, // highlight range computation, and position/layout result capture. @Composable -fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { - val boundsState = remember { mutableStateOf(null) } - val layoutResultState = remember { mutableStateOf(null) } +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean, bases: List): ItemSelection { + val texts = remember { HashMap() } + texts.keys.retainAll { it < bases.size } if (selectionManager != null && selectionIndex >= 0 && !isLive) { val isAnchor = remember(selectionIndex) { @@ -444,12 +479,8 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, } LaunchedEffect(isAnchor.value) { if (!isAnchor.value) return@LaunchedEffect - val bounds = boundsState.value ?: return@LaunchedEffect - val layout = layoutResultState.value ?: return@LaunchedEffect - val offset = layout.getOffsetForPosition( - Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) - ) - selectionManager.setAnchorOffset(offset) + val hit = textAt(texts.values, selectionManager.anchorWindowX, selectionManager.anchorWindowY) ?: return@LaunchedEffect + selectionManager.setAnchorOffset(hit.text.base + hit.offset) } val isFocus = remember(selectionIndex) { @@ -459,10 +490,9 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, LaunchedEffect(Unit) { snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } .collect { (py, px) -> - val bounds = boundsState.value ?: return@collect - val layout = layoutResultState.value ?: return@collect - val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) - val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val hit = textAt(texts.values, px, py) ?: return@collect + val bounds = hit.bounds + val charBox = hit.layout.getBoundingBox(hit.offset.coerceIn(0, hit.layout.layoutInput.text.length - 1)) val ls = selectionManager.listState?.value val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } val charRect = if (ls != null && itemInfo != null) { @@ -474,7 +504,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, bottom = bounds.top + charBox.bottom - itemWindowY ) } else Rect.Zero - selectionManager.updateFocusOffset(offset, charRect) + selectionManager.updateFocusOffset(hit.text.base + hit.offset, charRect) } } } @@ -484,18 +514,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value } else null - val positionModifier = if (selectionManager != null) { - Modifier.onGloballyPositioned { - val pos = it.positionInWindow() - boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) - } - } else Modifier - - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { - { layoutResultState.value = it } - } else null - - return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) + return ItemSelection(highlightRange, if (selectionManager != null) texts else null, bases) } // Sets up full-item selection for emoji items (no character-level tracking). diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index ec8b545647..ef847c93cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -146,13 +146,16 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole val uriHandler = LocalUriHandler.current Column { SelectionContainer(Modifier.fillMaxWidth()) { + val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null MarkdownText( text, - formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null, + formattedText = parsed?.formattedText, + markdownBlocks = parsed?.markdownBlocks, toggleSecrets = false, modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING), linkMode = linkMode, uriHandler = uriHandler, - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp) + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + blockLayout = true ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index fd422b649a..ca370c1b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -61,6 +61,7 @@ fun FramedItemView( MarkdownText( qi.text, qi.formattedText, + qi.markdownBlocks, toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, @@ -457,11 +458,12 @@ fun CIMarkdownText( ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text - val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) } + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases) - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { MarkdownText( - text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, + text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, mentions = ci.mentions, userMemberId = when { @@ -470,8 +472,9 @@ fun CIMarkdownText( }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, stripLink = stripLink, - selectionRange = selection.highlightRange, - onTextLayoutResult = selection.onTextLayoutResult + selection = selection, + blockLayout = true, + cardColor = if (ci.chatDir.sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt new file mode 100644 index 0000000000..75a778e567 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt @@ -0,0 +1,248 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.takeOrElse +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun MarkdownBlocks( + formattedText: List, + blocks: List, + style: TextStyle, + modifier: Modifier, + cardColor: Color, + blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit, + metaLine: @Composable () -> Unit +) { + val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f) + val cardShape = RoundedCornerShape(12.dp * roundness) + val lineColor = MaterialTheme.colors.secondary.copy(alpha = 0.35f) + val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface } + val fontSize = style.fontSize.takeOrElse { 16.sp } + val lineHeight = style.lineHeight.takeOrElse { 22.sp } + val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } } + val lastIsText = when (blocks.lastOrNull()) { + is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true + else -> false + } + @Composable + fun BlockText(index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier = Modifier, softWrap: Boolean = true) { + blockText(index, range, style, modifier, softWrap, lastIsText && index == starts.last() - 1) + } + Column(modifier) { + blocks.forEachIndexed { k, block -> + if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) { + Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) + } + val start = starts[k] + when (block) { + is MarkdownBlock.Paragraph -> BlockText(start, block.content, style) + is MarkdownBlock.Heading -> { + val scale = when (block.level) { + 1 -> 1.5f + 2 -> 1.3f + 3 -> 1.15f + else -> 1f + } + BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold)) + } + is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { + Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f))) + Spacer(Modifier.width(10.dp)) + BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f))) + } + is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) -> + Row(Modifier.padding(start = 20.dp * level)) { + Text(entry.number ?: "•", Modifier.widthIn(min = 14.dp), style = style) + Spacer(Modifier.width(6.dp)) + BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false)) + } + } + is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m -> + BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false) + } + is MarkdownBlock.Table -> { + val tableRows = listOf(block.header) + block.rows + val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size } + Box(Modifier.horizontalScroll(rememberScrollState())) { + TableGrid( + block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor, + Modifier.clip(cardShape).border(1.dp, lineColor, cardShape) + ) { + tableRows.forEachIndexed { r, row -> + row.forEachIndexed { c, cell -> + BlockText( + rowStarts[r] + c, cell, if (r == 0) style.copy(fontWeight = FontWeight.Bold) else style, + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), softWrap = false + ) + } + } + } + } + } + is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor)) + } + } + if (!lastIsText) metaLine() + } +} + +private val MarkdownBlock.start: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.from + else -> textRanges.firstOrNull()?.range?.from ?: 0 +} + +private val MarkdownBlock.end: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.to + else -> textRanges.lastOrNull()?.range?.to ?: 0 +} + +private fun paragraphBreak(fts: List, from: Int, to: Int): Boolean = + (from.coerceIn(0, fts.size) until to.coerceIn(0, fts.size)).any { (fts[it].format as? Format.Markup)?.token is BlockToken.ParagraphBreak } + +private fun codeText(fts: List, r: SegmentRange): String = + (r.from.coerceIn(0, fts.size) until r.to.coerceIn(0, fts.size)).joinToString("") { i -> + when (val f = fts[i].format) { + is Format.Markup -> f.token.drawnText + is Format.Escape -> "" + else -> fts[i].text + } + } + +@Composable +private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) { + var tapped by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val clipboard = LocalClipboardManager.current + Box( + Modifier + .fillMaxWidth() + .clip(shape) + .background(color) + .hoverable(interactionSource) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + if (waitForUpOrCancellation() != null) tapped = !tapped + } + } + ) { + content(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp)) + if (hovered || tapped) { + Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) { + Box( + Modifier + .padding(4.dp) + .size(28.dp) + .clip(RoundedCornerShape(8.dp * roundness)) + .background(color) + .clickable { + clipboard.setText(AnnotatedString(copyText)) + showToast(generalGetString(MR.strings.copied)) + }, + contentAlignment = Alignment.Center + ) { + Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + } + } + } + } +} + +private class TableGridLines { + var xs: List = emptyList() + var ys: List = emptyList() +} + +@Composable +private fun TableGrid( + columns: List, + rowSizes: List, + headerColor: Color, + stripeColor: Color, + lineColor: Color, + modifier: Modifier, + content: @Composable () -> Unit +) { + val lines = remember { TableGridLines() } + Layout( + content, + modifier.drawBehind { + val xs = lines.xs + val ys = lines.ys + if (ys.size < 2) return@drawBehind + drawRect(headerColor, Offset.Zero, Size(size.width, ys[1].toFloat())) + for (r in 2 until ys.size - 1 step 2) { + drawRect(stripeColor, Offset(0f, ys[r].toFloat()), Size(size.width, (ys[r + 1] - ys[r]).toFloat())) + } + val stroke = 1.dp.toPx() + xs.drop(1).dropLast(1).forEach { x -> drawLine(lineColor, Offset(x.toFloat(), 0f), Offset(x.toFloat(), size.height), stroke) } + ys.drop(1).dropLast(1).forEach { y -> drawLine(lineColor, Offset(0f, y.toFloat()), Offset(size.width, y.toFloat()), stroke) } + } + ) { measurables, _ -> + val cells = rowSizes.flatMapIndexed { r, n -> List(n) { c -> r to c } }.take(measurables.size) + val widths = IntArray(maxOf(columns.size, rowSizes.maxOrNull() ?: 0)) + cells.forEachIndexed { k, (_, c) -> widths[c] = maxOf(widths[c], measurables[k].maxIntrinsicWidth(Constraints.Infinity)) } + val placeables = cells.mapIndexed { k, (_, c) -> measurables[k].measure(Constraints(maxWidth = widths[c])) } + val heights = IntArray(rowSizes.size) + placeables.forEachIndexed { k, p -> heights[cells[k].first] = maxOf(heights[cells[k].first], p.height) } + val xs = widths.toList().runningFold(0) { a, w -> a + w } + val ys = heights.toList().runningFold(0) { a, h -> a + h } + lines.xs = xs + lines.ys = ys + layout(xs.last(), ys.last()) { + placeables.forEachIndexed { k, p -> + val (r, c) = cells[k] + val free = widths[c] - p.width + val x = xs[c] + when (columns.getOrNull(c)) { + TableAlign.Right -> free + TableAlign.Center -> free / 2 + else -> 0 + } + p.place(x, ys[r]) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index c198451464..9b3162744d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -12,8 +12,20 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* @@ -22,11 +34,14 @@ import androidx.compose.ui.text.AnnotatedString.Range import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.appColors +import chat.simplex.common.views.chat.ItemSelection import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -53,14 +68,17 @@ private fun showFullProfileDescription(description: String) { ModalManager.end.showModalCloseable { _ -> ColumnWithScrollBar { AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) MarkdownText( description, - parseToMarkdown(description), + parsed?.formattedText, + parsed?.markdownBlocks, toggleSecrets = true, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), uriHandler = LocalUriHandler.current, linkMode = chatModel.simplexLinkMode.value, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + blockLayout = true, ) } } @@ -85,10 +103,10 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) // Display text for a single formatted segment — must be coordinated with MarkdownText. -fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = +fun segmentDisplayText(ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode): String = when (ft.format) { is Format.Mention -> { - val mention = ci.mentions?.get(ft.format.memberName) + val mention = mentions?.get(ft.format.memberName) if (mention?.memberRef != null) { val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" @@ -104,13 +122,37 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin if (t != null) "$t ${ft.format.viaHosts}" else ft.text } is Format.Command -> ft.text + is Format.Escape -> "" + is Format.Markup -> ft.format.token.drawnText else -> ft.text } -// Full display text for a chat item — joins segment display texts. -fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { - val formattedText = ci.formattedText ?: return ci.text - return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +class ItemDisplay(val parts: List>, val bases: List) + +fun itemDisplay(ci: ChatItem, linkMode: SimplexLinkMode): ItemDisplay? { + val fts = ci.formattedText ?: return null + val segment = { ft: FormattedText -> + val display = segmentDisplayText(ft, ci.mentions, linkMode) + display to if (ft.format is Format.Markup) display else ft.text + } + val blocks = ci.markdownBlocks ?: return ItemDisplay(fts.map(segment), listOf(0)) + val parts = mutableListOf>() + var offset = itemPrefixText(ci).length + val bases = blockTextRanges(blocks).mapIndexed { i, t -> + if (i > 0) { + val separator = if (t.inRow) "\t" else "\n" + parts += separator to separator + offset += 1 + } + val base = if (i == 0) 0 else offset + for (j in t.range.from.coerceIn(0, fts.size) until t.range.to.coerceIn(0, fts.size)) { + val part = segment(fts[j]) + parts += part + offset += part.first.length + } + base + } + return ItemDisplay(parts, bases) } // Display-only prefix rendered before ci.text (e.g. "Spam: " for reports). @@ -120,11 +162,12 @@ fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) else -> "" } -// Text transformations in MarkdownText must match itemSegmentDisplayText above +// Text transformations in MarkdownText must match segmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, formattedText: List? = null, + markdownBlocks: List? = null, sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, @@ -145,15 +188,44 @@ fun MarkdownText ( showTimestamp: Boolean = true, prefix: AnnotatedString? = null, stripLink: String? = null, - selectionRange: IntRange? = null, - onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null + selection: ItemSelection? = null, + textIndex: Int = 0, + blockLayout: Boolean = false, + range: IntRange? = null, + softWrap: Boolean = true, + cardColor: Color? = null, + parentDirection: LayoutDirection? = null ) { val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } - val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { + if (blockLayout && formattedText != null && markdownBlocks != null && range == null) { + val direction = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) { + MarkdownBlocks( + formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote, + blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last -> + MarkdownText( + text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL, + mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle, + uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick, + showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null, + selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction + ) + }, + metaLine = { + MarkdownText( + text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style, + linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY + ) + } + ) + } + return + } + val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) { "\n" } else if (meta != null) { reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) @@ -196,6 +268,7 @@ fun MarkdownText ( } ) } + val textSelection = selection?.text(textIndex) if (formattedText == null) { var selectableEnd = 0 val annotatedText = buildAnnotatedString { @@ -210,9 +283,9 @@ fun MarkdownText ( } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = modifier.then(textSelection.positionModifier), maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = textSelection.onTextLayoutResult) } else { Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } @@ -222,29 +295,38 @@ fun MarkdownText ( var hasSecrets = false var hasCommands = false var hasModals = false + val hiddenSecrets = mutableListOf() val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) - for ((i, ft) in formattedText.withIndex()) { + val headings = if (range == null && markdownBlocks != null) headingSegments(formattedText.size, markdownBlocks) else null + fun appendSecret(s: String) { + val start = length + withStyle(hiddenSecretStyle) { append(s) } + hiddenSecrets += start until length + } + for (i in range?.let { it.first.coerceAtLeast(0) .. minOf(it.last, formattedText.lastIndex) } ?: formattedText.indices) { + val ft = formattedText[i] + val heading = headings?.get(i) == true + if (heading) pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) if (ft.format == null) append(ft.text) else when(ft.format) { - is Format.Bold -> withStyle(ft.format.style) { append(ft.text) } + is Format.Bold -> withStyle(if (markdownBlocks != null && ft.format.singleStar) Format.Italic().style else ft.format.style) { append(ft.text) } is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } is Format.Small -> withStyle(ft.format.style) { append(ft.text) } is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } is Format.Secret -> { - val ftStyle = ft.format.style if (toggleSecrets) { hasSecrets = true val key = i.toString() withAnnotation(tag = "SECRET", annotation = key) { - if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } + if (showSecrets[key] == true) append(ft.text) else appendSecret(ft.text) } } else { - withStyle(ftStyle) { append(ft.text) } + appendSecret(ft.text) } } is Format.Mention -> { @@ -290,7 +372,7 @@ fun MarkdownText ( is Format.HyperLink -> { hasLinks = true val ftStyle = Format.linkStyle - withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { + withAnnotation(tag = if (ft.format.isEmail) "OTHER_URL" else "WEB_URL", annotation = ft.format.linkUri) { withStyle(ftStyle) { append(ft.format.showText ?: ft.text) } } } @@ -338,8 +420,15 @@ fun MarkdownText ( withStyle(ftStyle) { append(ft.text) } } } + is Format.Escape -> {} + is Format.Markup -> append( + if (range != null) ft.format.token.drawnText + else if (markdownBlocks != null) ft.format.token.previewText + else ft.text + ) is Format.Unknown -> append(ft.text) } + if (heading) pop() } selectableEnd = this.length if (meta?.isLive == true) { @@ -350,10 +439,21 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + val layoutResult = remember { mutableStateOf(null) } + val onTextLayout: (TextLayoutResult) -> Unit = { r -> + layoutResult.value = r + textSelection?.onTextLayoutResult?.invoke(r) + } + val textModifier = modifier + .then(textSelection?.positionModifier ?: Modifier) + .then(if (hiddenSecrets.isEmpty()) Modifier else Modifier.drawWithContent { + drawContent() + drawSecretPattern(hiddenSecrets, layoutResult.value) + }) if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || hasModals) { val icon = remember { mutableStateOf(PointerIcon.Text) } - ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = textModifier.pointerHoverIcon(icon.value), softWrap = softWrap, maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -410,13 +510,38 @@ fun MarkdownText ( || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset) }, - onTextLayout = { onTextLayoutResult?.invoke(it) } + onTextLayout = onTextLayout ) } else { - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayout) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + Text(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf(), onTextLayout = onTextLayout) + } + } + } + } +} + +private val hiddenSecretStyle = SpanStyle(color = Color.Transparent) + +private val secretTile: ImageBitmap? by lazy { MR.images.vertical_logo.toComposeImageBitmap() } + +private fun ContentDrawScope.drawSecretPattern(ranges: List, layout: TextLayoutResult?) { + val tile = secretTile ?: return + if (layout == null) return + val brush = ShaderBrush(ImageShader(tile, TileMode.Repeated, TileMode.Repeated)) + val tileScale = 8.dp.toPx() / tile.width + val saturation = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.65f) }) + val textLength = layout.layoutInput.text.length + ranges.forEach { r -> + val path = layout.getPathForRange(r.first.coerceIn(0, textLength), (r.last + 1).coerceIn(0, textLength)) + val bounds = path.getBounds() + val side = (bounds.width + bounds.height) / tileScale + clipPath(path) { + rotate(45f, bounds.center) { + scale(tileScale, bounds.center) { + drawRect(brush, Offset(bounds.center.x - side / 2, bounds.center.y - side / 2), Size(side, side), alpha = 0.35f, colorFilter = saturation) } } } @@ -500,6 +625,7 @@ private fun SelectableText( text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier, + softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, selectionRange: IntRange? = null, @@ -511,6 +637,7 @@ private fun SelectableText( text = text, modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)), style = style, + softWrap = softWrap, maxLines = maxLines, overflow = overflow, onTextLayout = { @@ -624,7 +751,9 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + if (i >= 0 && result[i].format is Format.Markup) { + result.removeAt(i) + } else if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 7e11fdfb97..89d2dfb811 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -277,6 +277,7 @@ fun ChatPreviewView( MarkdownText( text, formattedText, + if (formattedText == null) null else ci.markdownBlocks, sender = when { cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName else -> null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index 8ae27ca17f..7d0851e65b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -132,13 +132,16 @@ fun PlainTextEditor( @Serializable data class ParsedFormattedText( - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ) -fun parseToMarkdown(text: String): List? { +fun parseToMarkdown(text: String): List? = parseToMarkdownText(text)?.formattedText + +fun parseToMarkdownText(text: String): ParsedFormattedText? { val formatted = chatParseMarkdown(text) return try { - json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText + json.decodeFromString(ParsedFormattedText.serializer(), formatted) } catch (e: Exception) { Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString()) null diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..f916e43ea99bbda5ae2bba007dc73ac5a77c8ea9 GIT binary patch literal 1234 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I?aN0%imoq%CcqSRjz%EbxddW?Q&?xfOIj~R9FF-xv3?I3Kh9IdBs*0wn|_XRzNmLSYJs2tfVB{Rw=?aK*2e`C{@8s z&p^*W$&O1wLBXadCCw_x#SN+*$g@?-C@Cqh($_C9FV`zK*2^zS*Eh7ZwA42+(l;{F z1**_3uFNY*tkBIXR)!b?Gsh*hIJqdZpd>RtPXT0ZVp4u-iLH_n$Rap^xU(cP4PjGW zG1OZ?59)(t^bPe4^s#A6t;oco4I~562KE=kIvbE-R*^xe#rZj)0j?1ok@jPI{ed6ij7$PAl+HdcDm_guZ{q+zHC5s>IKHUmCc6RUB zq1n;Rw@y#?Pe5G*->#Pr7U`|C^33e=aOAz!5F$1ClFIYf&5!Th-TnC9L7x5lj`X|N z{ASYc^4PO_UcmGnJ_n_QKMML5&JgcLY(2J1Hq5vaU>p5IWx2^6@tu+_ zcWPrcZ>~EK#+{T`*e4-;-N0M=?eiX$azFjxd6TA`nd|b&PibHUB)wIukE-|oopnYXt(t9jmHI>-IvAhdr_44 Zw_a>YvSZQGceg<~)zj6_Wt~$(69AmVcuD{O literal 0 HcmV?d00001 diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bb35878f0c4aec928c3e16b4d10603dfc2a14748 GIT binary patch literal 1617 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{x0%imor0saV%ts)_S>O>_%)r1c48n{Iv*t)JFfg6S42dX-@b$4u&d=3LOvz75 z)vL%Y0O?||sjvbvb5lza6)JLb@`|l0Y?Z(&tblBgu)dN4SV>8?tx|+H?jfSfFg)+ zA4CH}eP~{0i5*M}nlQSq2!uSs8e~Cq4gN)$slb2)yUP&jEMzex^&sC_1!PvF=0vz; z=BDNqgZyF)bQo3%bdApWIr&Aw1&PV2c0hZ;T9Aa$RfDaG#AOYV1XQDqJ}9n{;vSN) zz@osEWyfWs4^O3bT#H{kng>kN$(}BbAr^w6!TYm=14aJ5t6l%|;*l+#nW0kbp6s(d z3km~YEKt}{Cd7VMCWhtvntz;D9VusYWY;OaA zByogiaiKfoYH7f+x{psgA+gg5&h{$>*JJ1i;JQTS)Y`{$NNWO9q7#AFix?`Ztq?9b!9ac`yU z+pocMcC5pb>LRBo@e(tN#kiROSwKaBkUWE^am z#r!NlTeUmd+-yhep)>K8Pmb_T3Ee1D%zAA7ri|}eUa2{!<-ho>>X<(3*bLoE&*EEH zGH*R>xhiOUkn6}h!GpSo%?>>hNuP85Xw_8fKhtzsRCYW6ZC5#x#r^+Oa~x}8wgc0- z-@n=7?C0!$ZL9fE!k&YNb8pM?XFBbXCA-C}YwbVoh&y-c$@$G;=b8%)BGYQ?rUvJ& z@|yqpj;Qpc2HDi9I{F(|@Pz4REsyijO)a~>y;PW?Y{gIGzpU96|F4|c)AblsP!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{#0%imoq)m`tVjYm;EbxddW?XQ2>tmIipR1RclAn~S zSCLx)(#2p?VFhI7rj{fsROII56h?X&sHg;q@=(~U%$M(T(8_%FTW^V-_X+1Qs2Nx-^fT8 zs6w~6GOr}DLN~8i8Da>`9GBGMxocyBTg2d!hJD@#aEl5J>s=?Mo;<5%w0;&~S2ZoS-?$NXYbm!&85Si-7?*y=IAIZWLLl5_CyHc&UTSHYTncpN`MX{qXkP z2ODGS-Q_nHHGDU*HhypOxo&^4gMDQ7owCDz3HSf?D`#)ap8t5IYKSWv&a8aKkp6M|-K7HaH#4u(Ikr-vNKJIl zcecwFDZ)w8Pr}`#!%j}?-kfv!gJJK4jb3XEpS;}u+~rkFSoNAuCeNmLBs9i;Uz_?u z@ASIU4Xxb|H$A&vdq`we1Lx=6Gj;^UNINDA%Oq{ywdT^6KPSJMA64JveTu3 zk@`MvK7E1rrm9bCy!$L%BEE4;+>aLa`N8HF9tLdPak#g(0h_5G)?SslFv)fnHErX>_-IAUE#dE`!H?fy9%a1&mZeR7e z@?MO&*{53#7Y~08dp`S%z~|zg+cpLNi`uq|>z=7w_H2sKX}^zdYYg6G{tR1U^)K=3 zMrOh1NvXY)E^YXEQR#bCqLJTu0ZB3CB|@)0$n}`u#m%d> zXH5Kgi1mc#1$S3Dr6V_IwH!KMJ?+OO2d;;K6N;y)MJ#JvzVi&P=#+jD@mdwlT`onZ zethAz>9R_2zS(9SX8O|cqraPK4A;*Xckz}ok=4b#)h5!9{|HOxHuWyE+j*|-)uCfm z0usgFj$OE6u;_>Ts+o%><#PR>m%UZnR?R(5Aokbl=-QI6_XKWrpBLDEZh=Kr-??W@ zVR=8)tq)ww5w36Cd638Yhw3%M<6M`y%^Wzr&zp;LOJr-eH5F|+&l_w$b@vQuleQh{ z8q=rkWk0kgNy6FW!tvV)O|@&)i*6pPc%*0L8l=FX`80vC^HZAQcdb`jZPHRbzdroi zrx~?zdgZU;S5~11gr4uSH&p$3*fVSCh4KTPMRtCUA0D_|ocB{qn&pst=)AZapc>25 L)z4*}Q$iB}rr7 "},{"text":"q1"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q2"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":">"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"format":{"type":"bold","singleStar":false},"text":"q3"}],"markdownBlocks":[{"type":"quote","content":{"from":1,"to":10}}]}""") + + private val codeBlockAndRule = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"fenceMark","language":"bash"}},"text":"```bash"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"ls -l"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"pwd"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"fenceMark"}},"text":"```"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"ruleMark"}},"text":"---"}],"markdownBlocks":[{"type":"codeBlock","language":"bash","content":{"from":2,"to":6}},{"type":"rule","mark":{"from":9,"to":10}}]}""") + + private val tableAligned = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"a"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"b"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"c"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"d"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"delimiterRow","columns":["none","left","right","center"]}},"text":"|---|:--|--:|:-:|"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"xxx"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"format":{"type":"bold","singleStar":false},"text":"yyy"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"zzz"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"}],"markdownBlocks":[{"type":"table","columns":["none","left","right","center"],"header":[{"from":1,"to":2},{"from":3,"to":4},{"from":5,"to":6},{"from":7,"to":8}],"rows":[[{"from":13,"to":14},{"from":15,"to":16},{"from":17,"to":18}]]}]}""") + + private val gaps = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}},"text":"\n\n"},{"text":"a"},{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":3}},"text":"\n\n\n"},{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"h"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"}],"markdownBlocks":[{"type":"paragraph","content":{"from":1,"to":2}},{"type":"heading","level":1,"content":{"from":4,"to":5}}]}""") + + private val escapes = parsed("""{"formattedText":[{"text":"text "},{"format":{"type":"escape"},"text":"\\"},{"text":"*not"},{"format":{"type":"escape"},"text":"\\"},{"text":"*\nmore"}]}""") + + @Test + fun testDecodeTokens() { + val tokens = (headingAndParagraph.formattedText!! + nestedList.formattedText!! + codeBlockAndRule.formattedText!! + tableAligned.formattedText!! + gaps.formattedText!!) + .mapNotNull { (it.format as? Format.Markup)?.token } + assertEquals(1, tokens.filterIsInstance().first().level) + assertEquals(listOf(null, null, "2."), tokens.filterIsInstance().map { it.number }) + assertEquals(listOf("bash", null), tokens.filterIsInstance().map { it.language }) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), tokens.filterIsInstance().first().columns) + assertEquals(listOf(2, 3), tokens.filterIsInstance().map { it.lineBreaks }) + listOf( + BlockToken.LineBreak::class, BlockToken.SoftBreak::class, BlockToken.HardBreak::class, BlockToken.Indent::class, + BlockToken.RowMark::class, BlockToken.CellMark::class, BlockToken.RuleMark::class + ).forEach { kind -> assert(tokens.any { kind.isInstance(it) }) { "no $kind" } } + assertIs((quote.formattedText!![0].format as Format.Markup).token) + val bold = headingAndParagraph.formattedText!!.mapNotNull { it.format as? Format.Bold } + quote.formattedText!!.mapNotNull { it.format as? Format.Bold } + assertEquals(listOf(true, false), bold.map { it.singleStar }) + assertEquals(listOf("text ", "", "*not", "", "*\nmore"), escapes.formattedText!!.map(::display)) + assertNull(escapes.markdownBlocks) + } + + @Test + fun testDecodeBlocks() { + val heading = assertIs(headingAndParagraph.markdownBlocks!![0]) + assertEquals(1, heading.level) + assertEquals(1 to 3, heading.content.pair) + assertEquals(4 to 9, assertIs(headingAndParagraph.markdownBlocks!![1]).content.pair) + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(null, "2."), list.items.map { it.number }) + assertEquals(7 to 8, list.items[0].items.single().content.pair) + assertEquals(1 to 10, assertIs(quote.markdownBlocks!!.single()).content.pair) + val code = assertIs(codeBlockAndRule.markdownBlocks!![0]) + assertEquals("bash", code.language) + assertEquals(2 to 6, code.content.pair) + assertEquals(9 to 10, assertIs(codeBlockAndRule.markdownBlocks!![1]).mark.pair) + val table = assertIs(tableAligned.markdownBlocks!!.single()) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), table.columns) + assertEquals(listOf(1 to 2, 3 to 4, 5 to 6, 7 to 8), table.header.map { it.pair }) + assertEquals(listOf(listOf(13 to 14, 15 to 16, 17 to 18)), table.rows.map { row -> row.map { it.pair } }) + } + + @Test + fun testBlockTextRanges() { + assertEquals(listOf(1 to 3, 4 to 9), texts(headingAndParagraph).map { it.first }) + assertEquals(listOf(1 to 5, 7 to 8, 10 to 11), texts(nestedList).map { it.first }) + assertEquals(listOf(1 to 10), texts(quote).map { it.first }) + assertEquals(listOf(2 to 6), texts(codeBlockAndRule).map { it.first }) + assertEquals( + listOf(1 to 2 to false, 3 to 4 to true, 5 to 6 to true, 7 to 8 to true, 13 to 14 to false, 15 to 16 to true, 17 to 18 to true), + texts(tableAligned) + ) + } + + @Test + fun testListEntryLevels() { + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(1 to 0, 7 to 1, 10 to 0), listEntries(list.items, 0).map { (entry, level) -> entry.content.from to level }) + } + + @Test + fun testHeadingSegments() { + assertEquals(listOf(1, 2), headingSegments(9, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(4), headingSegments(6, gaps.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(1), headingSegments(2, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + } + + @Test + fun testTokenTexts() { + val tokens = listOf( + BlockToken.SoftBreak(), BlockToken.LineBreak(), BlockToken.HardBreak(), BlockToken.ParagraphBreak(3), BlockToken.Indent(), + BlockToken.HeadingMark(2), BlockToken.ListMark("1."), BlockToken.ListMark(null), BlockToken.QuoteMark(), BlockToken.FenceMark(null), + BlockToken.RowMark(), BlockToken.CellMark(), BlockToken.DelimiterRow(listOf(TableAlign.None)), BlockToken.RuleMark() + ) + assertEquals(listOf(" ", "\n", "\n", "\n\n\n", "", "", "", "", "", "", "", "", "", ""), tokens.map { it.drawnText }) + assertEquals(listOf(" ", "\n", " ", "\n", "", "", "1. ", "• ", "", "", "", " ", "", ""), tokens.map { it.previewText }) + assertEquals("", display(FormattedText("\\", Format.Escape()))) + assertEquals("Title x\nPara a b c", preview(headingAndParagraph)) + assertEquals("• x y\n• z\n2. w", preview(nestedList)) + } + + @Test + fun testItemDisplay() { + val item = ChatItem.getSampleData(text = "> q1\n> q2\n>\n> **q3**").copy(formattedText = quote.formattedText, markdownBlocks = quote.markdownBlocks) + val display = itemDisplay(item, SimplexLinkMode.DESCRIPTION)!! + assertEquals("q1 q2\n\nq3", display.parts.joinToString("") { it.first }) + assertEquals(listOf(0), display.bases) + val table = item.copy(formattedText = tableAligned.formattedText, markdownBlocks = tableAligned.markdownBlocks) + val tableDisplay = itemDisplay(table, SimplexLinkMode.DESCRIPTION)!! + assertEquals("a\tb\tc\td\nxxx\tyyy\tzzz", tableDisplay.parts.joinToString("") { it.first }) + assertEquals(listOf(0, 2, 4, 6, 8, 12, 16), tableDisplay.bases) + val plain = item.copy(formattedText = escapes.formattedText, markdownBlocks = null) + val plainDisplay = itemDisplay(plain, SimplexLinkMode.DESCRIPTION)!! + assertEquals(listOf("text " to "text ", "" to "\\", "*not" to "*not", "" to "\\", "*\nmore" to "*\nmore"), plainDisplay.parts) + assertEquals(listOf(0), plainDisplay.bases) + } + + private val SegmentRange.pair: Pair get() = from to to + + private fun parsed(s: String): ParsedFormattedText = json.decodeFromString(ParsedFormattedText.serializer(), s) + + private fun display(ft: FormattedText): String = segmentDisplayText(ft, null, SimplexLinkMode.DESCRIPTION) + + private fun texts(p: ParsedFormattedText): List, Boolean>> = + blockTextRanges(p.markdownBlocks!!).map { it.range.pair to it.inRow } + + private fun preview(p: ParsedFormattedText): String = + p.formattedText!!.joinToString("") { ft -> (ft.format as? Format.Markup)?.token?.previewText ?: ft.text } +} diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 3bad2e09c4..c7d6f0674f 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -316,7 +316,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `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 | +| `TextItemView.kt` | Plain text message with markdown; blocks of `markdownBlocks` drawn as a column of views by `MarkdownBlocks` (`MarkdownBlocks.kt`) | ### `views/chat/group/` diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index dd7bbb509a..65b47d6c29 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -25,6 +25,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.List (isPrefixOf) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -92,7 +93,9 @@ data DirectoryEntry = DirectoryEntry simplexName :: Maybe Text, groupLink :: PublicLink, shortDescr :: Maybe MarkdownList, + shortDescrBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), imageFile :: Maybe String, activeAt :: Maybe UTCTime, createdAt :: Maybe UTCTime @@ -133,6 +136,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma linkLine = groupLinkLine displayName $ decodeUtf8 linkText in Just $ maybe linkLine (<> "\n\n" <> linkLine) description _ -> description + shortDescr' = toFormattedText <$> shortDescr + welcomeMessage' = toFormattedText <$> description' entry groupLink = let de = DirectoryEntry @@ -140,8 +145,10 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma displayName, simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g, groupLink, - shortDescr = toFormattedText <$> shortDescr, - welcomeMessage = toFormattedText <$> description', + shortDescr = fst <$> shortDescr', + shortDescrBlocks = snd =<< shortDescr', + welcomeMessage = fst <$> welcomeMessage', + welcomeMessageBlocks = snd =<< welcomeMessage', imageFile = fst <$> imgData, activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, createdAt = recentRoundedTime 86400 now createdAt @@ -194,5 +201,5 @@ generateListing dir gs = do where saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst -toFormattedText :: Text -> MarkdownList -toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t +toFormattedText :: Text -> (MarkdownList, Maybe (NonEmpty MarkdownBlock)) +toFormattedText t = let ParsedMarkdown ft blocks = parsedMarkdown t in (fromMaybe [FormattedText Nothing t] ft, blocks) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 76c3f43400..7d4efb0497 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -1157,10 +1157,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." ] - <> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)] - <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name' - enable name filter " | isNothing (rejectNames a)] + <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge " | isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name captcha' - enable both " | isNothing (rejectNames a) || isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " off' - disable filter " | isJust (rejectNames a) || isJust (passCaptcha a)] showCondition = \case Nothing -> "_disabled_" Just PCAll -> "_enabled_" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 6ca0d67ac4..7b3985e132 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -18,6 +18,7 @@ This file is generated automatically. - [BadgeServiceErrorCode](#badgeserviceerrorcode) - [BadgeStatus](#badgestatus) - [BadgeType](#badgetype) +- [BlockToken](#blocktoken) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -131,8 +132,10 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [ListEntry](#listentry) - [LocalBadge](#localbadge) - [LocalProfile](#localprofile) +- [MarkdownBlock](#markdownblock) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) - [MsgContent](#msgcontent) @@ -182,6 +185,7 @@ This file is generated automatically. - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) - [SecurityCode](#securitycode) +- [SegmentRange](#segmentrange) - [SimplePreference](#simplepreference) - [SimplexDomain](#simplexdomain) - [SimplexDomainClaim](#simplexdomainclaim) @@ -201,6 +205,7 @@ This file is generated automatically. - [SubscriptionStatus](#subscriptionstatus) - [SupportGroupPreference](#supportgrouppreference) - [SwitchPhase](#switchphase) +- [TableAlign](#tablealign) - [TimedMessagesGroupPreference](#timedmessagesgrouppreference) - [TimedMessagesPreference](#timedmessagespreference) - [TransportError](#transporterror) @@ -513,6 +518,64 @@ CredentialNotVerified: - "investor" +--- + +## BlockToken + +**Discriminated union type**: + +LineBreak: +- type: "lineBreak" + +SoftBreak: +- type: "softBreak" + +HardBreak: +- type: "hardBreak" + +ParagraphBreak: +- type: "paragraphBreak" +- lineBreaks: int + +Indent: +- type: "indent" + +HeadingMark: +- type: "headingMark" +- level: int + +HeadingClose: +- type: "headingClose" + +HeadingUnderline: +- type: "headingUnderline" +- level: int + +ListMark: +- type: "listMark" +- number: string? + +QuoteMark: +- type: "quoteMark" + +FenceMark: +- type: "fenceMark" +- language: string? + +RowMark: +- type: "rowMark" + +CellMark: +- type: "cellMark" + +DelimiterRow: +- type: "delimiterRow" +- columns: [[TableAlign](#tablealign)] + +RuleMark: +- type: "ruleMark" + + --- ## BlockingInfo @@ -1007,6 +1070,7 @@ GroupLink: - sentAt: UTCTime - content: [MsgContent](#msgcontent) - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? --- @@ -1505,6 +1569,7 @@ ContactConnection: - content: [CIContent](#cicontent) - mentions: {string : [CIMention](#cimention)} - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? - quotedItem: [CIQuote](#ciquote)? - reactions: [[CIReactionCount](#cireactioncount)] - file: [CIFile](#cifile)? @@ -2283,10 +2348,13 @@ NO_FILE: ## Format +The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + **Discriminated union type**: Bold: - type: "bold" +- singleStar: bool Italic: - type: "italic" @@ -2340,6 +2408,13 @@ Email: Phone: - type: "phone" +Escape: +- type: "escape" + +Markup: +- type: "markup" +- token: [BlockToken](#blocktoken) + --- @@ -2877,6 +2952,16 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## ListEntry + +**Record type**: +- number: string? +- content: [SegmentRange](#segmentrange) +- items: [[ListEntry](#listentry)] + + --- ## LocalBadge @@ -2906,6 +2991,45 @@ Unknown: - contactDomainVerified: bool? +--- + +## MarkdownBlock + +**Discriminated union type**: + +Paragraph: +- type: "paragraph" +- content: [SegmentRange](#segmentrange) + +Heading: +- type: "heading" +- level: int +- content: [SegmentRange](#segmentrange) + +CodeBlock: +- type: "codeBlock" +- language: string? +- content: [SegmentRange](#segmentrange) + +Quote: +- type: "quote" +- content: [SegmentRange](#segmentrange) + +List: +- type: "list" +- items: [[ListEntry](#listentry)] + +Table: +- type: "table" +- columns: [[TableAlign](#tablealign)] +- header: [[SegmentRange](#segmentrange)] +- rows: [[[SegmentRange](#segmentrange)]] + +Rule: +- type: "rule" +- mark: [SegmentRange](#segmentrange) + + --- ## MemberCriteria @@ -3787,6 +3911,15 @@ A_SERVICE: - verifiedAt: UTCTime +--- + +## SegmentRange + +**Record type**: +- from: int +- to: int + + --- ## SimplePreference @@ -4408,6 +4541,17 @@ NoSub: - "completed" +--- + +## TableAlign + +**Enum type**: +- "none" +- "left" +- "center" +- "right" + + --- ## TimedMessagesGroupPreference diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 3adcaef783..ab76cc6581 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -224,6 +224,7 @@ chatTypesDocsData = (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), + (sti @BlockToken, STUnion, "", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), (sti @BusinessChatInfo, STRecord, "", [], "", ""), (sti @BusinessChatType, STEnum, "BC", [], "", ""), @@ -285,7 +286,7 @@ chatTypesDocsData = (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""), - (sti @Format, STUnion, "", ["Unknown"], "", ""), + (sti @Format, STUnion, "", ["Unknown"], "", "The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars."), (sti @FormattedText, STRecord, "", [], "", ""), (sti @FullGroupPreferences, STRecord, "", [], "", ""), (sti @FullPreferences, STRecord, "", [], "", ""), @@ -321,8 +322,10 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @ListEntry, STRecord, "", [], "", ""), (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), (sti @MsgContent, STUnion, "MC", [], "", ""), @@ -371,6 +374,7 @@ chatTypesDocsData = (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), + (sti @SegmentRange, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), (sti @SimplexDomain, STRecord, "", [], "", ""), (sti @SimplexDomainClaim, STRecord, "", [], "", ""), @@ -391,6 +395,7 @@ chatTypesDocsData = (sti @SubscriptionStatus, STUnion, "SS", [], "", ""), (sti @SupportGroupPreference, STRecord, "", [], "", ""), (sti @SwitchPhase, STEnum, "SP", [], "", ""), + (sti @TableAlign, STEnum, "TA", [], "", ""), (sti @TimedMessagesGroupPreference, STRecord, "", [], "", ""), (sti @TimedMessagesPreference, STRecord, "", [], "", ""), (sti @TransportError, STUnion, "TE", [], "", ""), @@ -457,6 +462,7 @@ deriving instance Generic BadgeRedeemError deriving instance Generic BadgeServiceErrorCode deriving instance Generic BlockingInfo deriving instance Generic BlockingReason +deriving instance Generic BlockToken deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType @@ -561,8 +567,10 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic ListEntry deriving instance Generic BadgeInfo deriving instance Generic LocalProfile +deriving instance Generic MarkdownBlock deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink deriving instance Generic MsgContent @@ -610,6 +618,7 @@ deriving instance Generic RemoteCtrlSessionState deriving instance Generic RemoteCtrlStopReason deriving instance Generic ReportReason deriving instance Generic SecurityCode +deriving instance Generic SegmentRange deriving instance Generic SimplexDomain deriving instance Generic SimplexDomainClaim deriving instance Generic SimplexDomainError @@ -629,6 +638,7 @@ deriving instance Generic StoreError deriving instance Generic SubscriptionStatus deriving instance Generic SupportGroupPreference deriving instance Generic SwitchPhase +deriving instance Generic TableAlign deriving instance Generic TimedMessagesGroupPreference deriving instance Generic TimedMessagesPreference deriving instance Generic TransportError diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 35711c62cf..cef7f41de5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -343,6 +343,112 @@ export enum BadgeType { Investor = "investor", } +export type BlockToken = + | BlockToken.LineBreak + | BlockToken.SoftBreak + | BlockToken.HardBreak + | BlockToken.ParagraphBreak + | BlockToken.Indent + | BlockToken.HeadingMark + | BlockToken.HeadingClose + | BlockToken.HeadingUnderline + | BlockToken.ListMark + | BlockToken.QuoteMark + | BlockToken.FenceMark + | BlockToken.RowMark + | BlockToken.CellMark + | BlockToken.DelimiterRow + | BlockToken.RuleMark + +export namespace BlockToken { + export type Tag = + | "lineBreak" + | "softBreak" + | "hardBreak" + | "paragraphBreak" + | "indent" + | "headingMark" + | "headingClose" + | "headingUnderline" + | "listMark" + | "quoteMark" + | "fenceMark" + | "rowMark" + | "cellMark" + | "delimiterRow" + | "ruleMark" + + interface Interface { + type: Tag + } + + export interface LineBreak extends Interface { + type: "lineBreak" + } + + export interface SoftBreak extends Interface { + type: "softBreak" + } + + export interface HardBreak extends Interface { + type: "hardBreak" + } + + export interface ParagraphBreak extends Interface { + type: "paragraphBreak" + lineBreaks: number // int + } + + export interface Indent extends Interface { + type: "indent" + } + + export interface HeadingMark extends Interface { + type: "headingMark" + level: number // int + } + + export interface HeadingClose extends Interface { + type: "headingClose" + } + + export interface HeadingUnderline extends Interface { + type: "headingUnderline" + level: number // int + } + + export interface ListMark extends Interface { + type: "listMark" + number?: string + } + + export interface QuoteMark extends Interface { + type: "quoteMark" + } + + export interface FenceMark extends Interface { + type: "fenceMark" + language?: string + } + + export interface RowMark extends Interface { + type: "rowMark" + } + + export interface CellMark extends Interface { + type: "cellMark" + } + + export interface DelimiterRow extends Interface { + type: "delimiterRow" + columns: TableAlign[] + } + + export interface RuleMark extends Interface { + type: "ruleMark" + } +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -998,6 +1104,7 @@ export interface CIQuote { sentAt: string // ISO-8601 timestamp content: MsgContent formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] } export interface CIReaction { @@ -1760,6 +1867,7 @@ export interface ChatItem { content: CIContent mentions: {[key: string]: CIMention} formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] quotedItem?: CIQuote reactions: CIReactionCount[] file?: CIFile @@ -2569,6 +2677,7 @@ export enum FileType { Normal = "normal", Roster = "roster", } +// The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. export type Format = | Format.Bold @@ -2586,6 +2695,8 @@ export type Format = | Format.Mention | Format.Email | Format.Phone + | Format.Escape + | Format.Markup export namespace Format { export type Tag = @@ -2604,6 +2715,8 @@ export namespace Format { | "mention" | "email" | "phone" + | "escape" + | "markup" interface Interface { type: Tag @@ -2611,6 +2724,7 @@ export namespace Format { export interface Bold extends Interface { type: "bold" + singleStar: boolean } export interface Italic extends Interface { @@ -2678,6 +2792,15 @@ export namespace Format { export interface Phone extends Interface { type: "phone" } + + export interface Escape extends Interface { + type: "escape" + } + + export interface Markup extends Interface { + type: "markup" + token: BlockToken + } } export interface FormattedText { @@ -3142,6 +3265,12 @@ export interface LinkPreview { content?: LinkContent } +export interface ListEntry { + number?: string + content: SegmentRange + items: ListEntry[] +} + export interface LocalBadge { badge: BadgeInfo status: BadgeStatus @@ -3163,6 +3292,62 @@ export interface LocalProfile { contactDomainVerified?: boolean } +export type MarkdownBlock = + | MarkdownBlock.Paragraph + | MarkdownBlock.Heading + | MarkdownBlock.CodeBlock + | MarkdownBlock.Quote + | MarkdownBlock.List + | MarkdownBlock.Table + | MarkdownBlock.Rule + +export namespace MarkdownBlock { + export type Tag = "paragraph" | "heading" | "codeBlock" | "quote" | "list" | "table" | "rule" + + interface Interface { + type: Tag + } + + export interface Paragraph extends Interface { + type: "paragraph" + content: SegmentRange + } + + export interface Heading extends Interface { + type: "heading" + level: number // int + content: SegmentRange + } + + export interface CodeBlock extends Interface { + type: "codeBlock" + language?: string + content: SegmentRange + } + + export interface Quote extends Interface { + type: "quote" + content: SegmentRange + } + + export interface List extends Interface { + type: "list" + items: ListEntry[] // non-empty + } + + export interface Table extends Interface { + type: "table" + columns: TableAlign[] // non-empty + header: SegmentRange[] + rows: SegmentRange[][] + } + + export interface Rule extends Interface { + type: "rule" + mark: SegmentRange + } +} + export enum MemberCriteria { All = "all", } @@ -4216,6 +4401,11 @@ export interface SecurityCode { verifiedAt: string // ISO-8601 timestamp } +export interface SegmentRange { + from: number // int + to: number // int +} + export interface SimplePreference { allow: FeatureAllowed } @@ -5131,6 +5321,13 @@ export enum SwitchPhase { Completed = "completed", } +export enum TableAlign { + None = "none", + Left = "left", + Center = "center", + Right = "right", +} + export interface TimedMessagesGroupPreference { enable: GroupFeatureEnabled ttl?: number // int diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index f732d7faf2..373cf58d43 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -230,6 +230,77 @@ BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] +class BlockToken_lineBreak(TypedDict): + type: Literal["lineBreak"] + +class BlockToken_softBreak(TypedDict): + type: Literal["softBreak"] + +class BlockToken_hardBreak(TypedDict): + type: Literal["hardBreak"] + +class BlockToken_paragraphBreak(TypedDict): + type: Literal["paragraphBreak"] + lineBreaks: int # int + +class BlockToken_indent(TypedDict): + type: Literal["indent"] + +class BlockToken_headingMark(TypedDict): + type: Literal["headingMark"] + level: int # int + +class BlockToken_headingClose(TypedDict): + type: Literal["headingClose"] + +class BlockToken_headingUnderline(TypedDict): + type: Literal["headingUnderline"] + level: int # int + +class BlockToken_listMark(TypedDict): + type: Literal["listMark"] + number: NotRequired[str] + +class BlockToken_quoteMark(TypedDict): + type: Literal["quoteMark"] + +class BlockToken_fenceMark(TypedDict): + type: Literal["fenceMark"] + language: NotRequired[str] + +class BlockToken_rowMark(TypedDict): + type: Literal["rowMark"] + +class BlockToken_cellMark(TypedDict): + type: Literal["cellMark"] + +class BlockToken_delimiterRow(TypedDict): + type: Literal["delimiterRow"] + columns: list["TableAlign"] + +class BlockToken_ruleMark(TypedDict): + type: Literal["ruleMark"] + +BlockToken = ( + BlockToken_lineBreak + | BlockToken_softBreak + | BlockToken_hardBreak + | BlockToken_paragraphBreak + | BlockToken_indent + | BlockToken_headingMark + | BlockToken_headingClose + | BlockToken_headingUnderline + | BlockToken_listMark + | BlockToken_quoteMark + | BlockToken_fenceMark + | BlockToken_rowMark + | BlockToken_cellMark + | BlockToken_delimiterRow + | BlockToken_ruleMark +) + +BlockToken_Tag = Literal["lineBreak", "softBreak", "hardBreak", "paragraphBreak", "indent", "headingMark", "headingClose", "headingUnderline", "listMark", "quoteMark", "fenceMark", "rowMark", "cellMark", "delimiterRow", "ruleMark"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -692,6 +763,7 @@ class CIQuote(TypedDict): sentAt: str # ISO-8601 timestamp content: "MsgContent" formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty class CIReaction(TypedDict): chatDir: "CIDirection" @@ -1224,6 +1296,7 @@ class ChatItem(TypedDict): content: "CIContent" mentions: dict[str, "CIMention"] formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty quotedItem: NotRequired["CIQuote"] reactions: list["CIReactionCount"] file: NotRequired["CIFile"] @@ -1795,8 +1868,11 @@ class FileTransferMeta(TypedDict): FileType = Literal["normal", "roster"] +# The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + class Format_bold(TypedDict): type: Literal["bold"] + singleStar: bool class Format_italic(TypedDict): type: Literal["italic"] @@ -1850,6 +1926,13 @@ class Format_email(TypedDict): class Format_phone(TypedDict): type: Literal["phone"] +class Format_escape(TypedDict): + type: Literal["escape"] + +class Format_markup(TypedDict): + type: Literal["markup"] + token: "BlockToken" + Format = ( Format_bold | Format_italic @@ -1866,9 +1949,11 @@ Format = ( | Format_mention | Format_email | Format_phone + | Format_escape + | Format_markup ) -Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"] +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone", "escape", "markup"] class FormattedText(TypedDict): format: NotRequired["Format"] @@ -2191,6 +2276,11 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class ListEntry(TypedDict): + number: NotRequired[str] + content: "SegmentRange" + items: list["ListEntry"] + class LocalBadge(TypedDict): badge: "BadgeInfo" status: "BadgeStatus" @@ -2210,6 +2300,50 @@ class LocalProfile(TypedDict): contactDomain: NotRequired["SimplexDomainClaim"] contactDomainVerified: NotRequired[bool] +class MarkdownBlock_paragraph(TypedDict): + type: Literal["paragraph"] + content: "SegmentRange" + +class MarkdownBlock_heading(TypedDict): + type: Literal["heading"] + level: int # int + content: "SegmentRange" + +class MarkdownBlock_codeBlock(TypedDict): + type: Literal["codeBlock"] + language: NotRequired[str] + content: "SegmentRange" + +class MarkdownBlock_quote(TypedDict): + type: Literal["quote"] + content: "SegmentRange" + +class MarkdownBlock_list(TypedDict): + type: Literal["list"] + items: list["ListEntry"] # non-empty + +class MarkdownBlock_table(TypedDict): + type: Literal["table"] + columns: list["TableAlign"] # non-empty + header: list["SegmentRange"] + rows: list[list["SegmentRange"]] + +class MarkdownBlock_rule(TypedDict): + type: Literal["rule"] + mark: "SegmentRange" + +MarkdownBlock = ( + MarkdownBlock_paragraph + | MarkdownBlock_heading + | MarkdownBlock_codeBlock + | MarkdownBlock_quote + | MarkdownBlock_list + | MarkdownBlock_table + | MarkdownBlock_rule +) + +MarkdownBlock_Tag = Literal["paragraph", "heading", "codeBlock", "quote", "list", "table", "rule"] + MemberCriteria = Literal["all"] # Connection link sent in a message - only short links are allowed. @@ -2937,6 +3071,10 @@ class SecurityCode(TypedDict): securityCode: str verifiedAt: str # ISO-8601 timestamp +class SegmentRange(TypedDict): + from: int # int + to: int # int + class SimplePreference(TypedDict): allow: "FeatureAllowed" @@ -3573,6 +3711,8 @@ class SupportGroupPreference(TypedDict): SwitchPhase = Literal["started", "confirmed", "secured", "completed"] +TableAlign = Literal["none", "left", "center", "right"] + class TimedMessagesGroupPreference(TypedDict): enable: "GroupFeatureEnabled" ttl: NotRequired[int] # int diff --git a/plans/2026-10-13-block-markdown-rendering.md b/plans/2026-10-13-block-markdown-rendering.md new file mode 100644 index 0000000000..fd5a3be739 --- /dev/null +++ b/plans/2026-10-13-block-markdown-rendering.md @@ -0,0 +1,415 @@ +# Block Markdown: rendering in the apps and on the web + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, drawing from markdownBlocks. Revised: 2026-10-18, drawing by index ranges (S5). Revised: 2026-10-18, a column of block views (approved mockup https://claude.ai/artifact/7Twe2qJcdULBbcqhSPuh6k, v3). + +Where a whole message is shown, the apps draw `formattedText` with `markdownBlocks` (`2026-10-13-block-markdown.md`, sections 2 and 4) as a column of block views; each text view is the segment loop of master over the index range of one block, list entry or table cell (section 3). Previews, quotes, the compose context, the profile teaser and notifications stay one text. A message without blocks is drawn as now. On the web, HTML blocks are drawn by the page script. + +References are to master at 382dc7480. + +``` +K/ apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ +I/ apps/ios/Shared/Views/ +S/ apps/ios/SimpleXChat/ +W/ website/src/js/ +``` + +## 1. Scope + +``` +blocks drawn every list drawn by MarkdownText (K/views/chat/item/TextItemView.kt:125) + every list drawn by messageText (I/Chat/ChatItem/MsgContentView.swift:396) + desktop selection (K/views/chat/TextSelection.kt:264-305) + copied text of a selection: as drawn, by the same block drawing + the Copy action of an item: its source text, as now +tokens by kind notification texts (K/platform/NtfManager.kt:134-148, S/Notifications.swift:200-210) +token form compose state: parsedMessage, links, mentions, link sanitizing, as now + K/views/chat/ComposeView.kt:111, 130, 1024-1070 + I/Chat/ComposeMessage/ComposeView.swift:560, 1240, 1990 +HTML blocks renderMarkdown (W/simplex-lib.jsc:57-128) +``` + +- Per text view, as in one text now: + - taps + - long presses + - secrets + - mentions + - commands +- Per message: + - the typing indicator and the meta reserve: at the end of the last view when it is text, otherwise in a line below + - the meta reserve after text: a line break when the text direction differs from the bubble's, as now; spaces in the line below (iOS `reserveSpaceForMeta(mt, newLine: false)`) + - text direction: the column in the text direction, text aligned to its start +- Code blocks and tables scroll sideways; a drag that starts in them does not swipe the item. + +## 2. Types + +Kotlin, `K/model/ChatModel.kt`: + +``` +Format (:5177) @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() (:5178) + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() + style of Escape and Markup: SpanStyle() +BlockToken @Serializable sealed class, next to Format + @SerialName("lineBreak") class LineBreak + @SerialName("softBreak") class SoftBreak + @SerialName("hardBreak") class HardBreak + @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int) + @SerialName("indent") class Indent + @SerialName("headingMark") class HeadingMark(val level: Int) + @SerialName("headingClose") class HeadingClose + @SerialName("headingUnderline") class HeadingUnderline(val level: Int) + @SerialName("listMark") class ListMark(val number: String? = null) + @SerialName("quoteMark") class QuoteMark + @SerialName("fenceMark") class FenceMark(val language: String? = null) + @SerialName("rowMark") class RowMark + @SerialName("cellMark") class CellMark + @SerialName("delimiterRow") class DelimiterRow(val columns: List) + @SerialName("ruleMark") class RuleMark +MarkdownBlock @Serializable sealed class + @SerialName("paragraph") class Paragraph(val content: SegmentRange) + @SerialName("heading") class Heading(val level: Int, val content: SegmentRange) + @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange) + @SerialName("quote") class Quote(val content: SegmentRange) + @SerialName("list") class ItemList(val items: List) + @SerialName("table") class Table(val columns: List, val header: List, val rows: List>) + @SerialName("rule") class Rule(val mark: SegmentRange) +ListEntry @Serializable class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) +SegmentRange @Serializable class SegmentRange(val from: Int, val to: Int) +TableAlign @Serializable enum class: none, left, center, right +BlockToken.drawnText the text of the token in a block view (section 3) +BlockToken.previewText the text of the token in one text (section 3) +BlockTextRange class BlockTextRange(val range: SegmentRange, val inRow: Boolean) +MarkdownBlock.textRanges the ranges of the text views of a block, in source order + Paragraph, Heading, CodeBlock, Quote: content + ItemList: the contents of the entries, a parent before its children + Table: the cells of the header, then of each row; inRow for each cell after the first of a row + Rule: none +listEntries (items, level) -> List>: the entries with their levels, a parent before its children +blockTextRanges (blocks) -> the textRanges of all blocks +headingSegments (size, blocks) -> BooleanArray: the segments in heading contents +segmentDisplayText (ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode) -> String + the body of itemSegmentDisplayText (K/views/chat/item/TextItemView.kt:88-108), with mentions instead of ci + Escape and Markup: the text drawn for the token in a block view + replaces itemSegmentDisplayText +itemDisplay (ci: ChatItem, linkMode: SimplexLinkMode) -> ItemDisplay?, null without formattedText + parts: per segment the displayed and the copied text (Markup: its drawn text both); + between text views a part "\t" (between cells of a row) or "\n" + bases: per text view the offset of its first character in the selection offsets of the item; 0 for the first + without markdownBlocks: the segments, bases [0] +MarkdownBlocks composable: the column of block views (section 3) +``` + +- Kotlin name `ItemList`: `List` is `kotlin.collections.List` in the scope of `MarkdownBlock`; JSON tag "list". + +Kotlin records, field `markdownBlocks: List? = null` after `formattedText`: + +``` +ChatItem K/model/ChatModel.kt:3409 +CIQuote K/model/ChatModel.kt:4415 +ChatItemVersion K/model/ChatModel.kt:5716 +ParsedFormattedText K/views/helpers/TextEditor.kt:134-136 +``` + +iOS, `S/ChatTypes.swift`: + +``` +Format (:5681) case bold(singleStar: Bool) + case escape + case markup(token: BlockToken) +BlockToken enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, associated values as their fields +MarkdownBlock enum: Decodable, Equatable, Hashable + cases as the Kotlin classes, list as `list` +ListEntry struct: Decodable, Equatable, Hashable +SegmentRange struct: Decodable, Equatable, Hashable +TableAlign enum: String, Decodable, Hashable +drawnText, previewText, BlockTextRange, textRanges, listEntries, blockTextRanges, headingSegments: as Kotlin +MarkdownBlocksView SwiftUI view, generic over the block text view, in I/Chat/ChatItem/MsgContentView.swift (section 3) +MarkdownView SwiftUI view: one text, or MarkdownBlocksView when blocks are present +``` + +- The `case .bold:` pattern (I/Chat/ChatItem/MsgContentView.swift:450) binds `singleStar`. + +iOS records, field `markdownBlocks: [MarkdownBlock]?` after `formattedText`: + +``` +ChatItem S/ChatTypes.swift:3810 +CIQuote S/ChatTypes.swift:4861 +ChatItemVersion S/ChatTypes.swift:6400 +ParsedMarkdown S/API.swift:179-181 +``` + +- `FormattedText` is unchanged. +- With an unknown block or token tag, the item is invalid, as with an unknown `Format` tag. + +## 3. Drawing + +Segment loop: + +``` +segment loop master's loop over formattedText + Kotlin: MarkdownText (K/views/chat/item/TextItemView.kt) + Swift: messageText (I/Chat/ChatItem/MsgContentView.swift), the loop in a nested function appendSegments +range a block view: the range of its block, entry or cell; otherwise all indices +font Kotlin: the style passed by the column + Swift: parameter baseFont; the fonts of formats and links derived from it +Bold singleStar italic, when markdownBlocks is present +HyperLink linkUri "mailto:" (e-mail autolink): opened as an e-mail link + Kotlin: annotation OTHER_URL; Swift: without webLinkAttrKey + FormattedText.linkUri: null for it, so no link preview +``` + +Modes of the segment loop (Kotlin `MarkdownText`, Swift `messageText`): + +``` +block view parameter range (a text view of the column) + tokens by drawnText: SoftBreak " ", LineBreak and HardBreak "\n", ParagraphBreak n × "\n", other markup nothing +one text markdownBlocks present, no range (previews, quotes, compose context, profile teaser) + tokens by previewText: SoftBreak, HardBreak and CellMark " "; LineBreak and ParagraphBreak "\n"; + ListMark the number or "•" and " "; other markup nothing + segments in heading contents (headingSegments) bold +no blocks markup drawn as its text; Escape nothing +``` + +The column (Kotlin `MarkdownBlocks`, `blockLayout = true` of `MarkdownText`; Swift `MarkdownBlocksView`, `blockLayout` of `MsgContentView`, and `MarkdownView`): + +``` +order blocks in source order; text view index i = the i-th of blockTextRanges +spacing a ParagraphBreak between two blocks: half a line; otherwise none +Paragraph text view, body font +Heading text view, bold; size × 1.5 (level 1), 1.3 (2), 1.15 (3), 1 (4-6); line height scaled alike +List per entry (listEntries): a row of the marker ("•" or the number) and the text view; indent 20 per level +Quote a 4 wide bar (text colour at 16%), 10 gap, the text view at 70-72% opacity +CodeBlock card in the bubble's quote colour, corners 12 × the bubble roundness setting, the bubble's full width + the text view: monospace, size × 0.875, no wrapping, scrolls sideways + copy icon at the top right: on hover (desktop) or after a tap (mobile); copies the drawn text of the range +Table rounded frame (12 × roundness, 1 line), scrolls sideways + columns as wide as their widest cell; header row bold on the quote colour; alternate body rows tinted + lines between rows and columns; a cell aligned by its column + a missing cell at a row end: an empty cell (GFM 0.29 example 204) + Kotlin: a Layout measuring cells by max intrinsic width, composing only the present cells + iOS 16+: Grid, columns × rows at most 1000 (maxGridCells); otherwise, and on iOS 15: the table as typed in a code card +Rule 1 line, 6 above and below +first view gets the sender and the report prefix +last view gets the meta reserve and the typing indicator when it is text; otherwise a line with them below the column +ranges limited to the length of fts +``` + +- Quote colour: `sentQuote` in a sent bubble, `receivedQuote` otherwise (Kotlin `AppColors`, Swift `theme.appColors`). +- Swipe to reply: Kotlin: the horizontal scroll consumes the drag before `swipeable` of the item. iOS: scrolling blocks report their frames (`SwipeExclusionFrames`) in the item's coordinate space; the item's drag gesture ignores drags that start in them. +- iOS: a horizontal scroll only when the content is wider than the bubble (`ViewThatFits` on iOS 16+). + +Secrets (Kotlin, as iOS `hiddenSecretsView`): a hidden secret is drawn transparent, and its text path is painted with the tile `vertical_logo` (8 dp, rotated 45°, saturation 0.65, alpha 0.35), added to `moko-resources/images` from the iOS asset. + +Desktop selection (K/views/chat/TextSelection.kt): + +``` +setupItemSelection (selectionManager, selectionIndex, isLive, bases) +text views each registers its bounds and layout with its base (ItemSelection.text(index)) +pointer offset the view under the pointer, or the nearest; base + its local offset +highlight per view, the selected offsets of the item within the view, shifted by its base +copy, snap itemDisplay(ci).parts: copied text as before, with "\t" and "\n" between views +``` + +Notifications, `hideSecrets` (K/platform/NtfManager.kt, S/Notifications.swift): Secret "...", markup by previewText, Escape nothing, other segments their text. + +## 4. Call sites + +Kotlin: + +``` +MarkdownText (K/views/chat/item/TextItemView.kt) parameters markdownBlocks, blockLayout, range, softWrap, cardColor, selection, textIndex, parentDirection (the bubble's direction, for the meta reserve of block views); section 3 +CIMarkdownText (K/views/chat/item/FramedItemView.kt:445) ci.markdownBlocks; column; quote colour by direction; selection with itemDisplay bases +ciQuotedMsgTextView (K/views/chat/item/FramedItemView.kt:61) qi.markdownBlocks; one text +item preview (K/views/chatlist/ChatPreviewView.kt:261-265, 277) null where formattedText is null, otherwise ci.markdownBlocks; one text +TextBubble (K/views/chat/ChatItemInfoView.kt:55-57) the markdownBlocks of the item or the version; column +ContextItemView (K/views/chat/ContextItemView.kt:59) contextItem.markdownBlocks; one text +ProfileDescriptionText (K/views/chat/ChatInfoView.kt:794-795, 804-805) blocks of parseToMarkdownText; one text +showFullProfileDescription (K/views/chat/item/TextItemView.kt:56-58) blocks of parseToMarkdownText; column +WelcomeMessageView (K/views/chat/group/WelcomeMessageView.kt:149-151) blocks of parseToMarkdownText; column +desktop selection (K/views/chat/TextSelection.kt:264-305) section 3; itemDisplay +notifications (K/platform/NtfManager.kt:134-148) section 3 +parseToMarkdownText (K/views/helpers/TextEditor.kt) new: (text: String) -> ParsedFormattedText? +parseToMarkdown (K/views/helpers/TextEditor.kt:138-146) the formattedText of parseToMarkdownText +``` + +iOS: + +``` +messageText (I/Chat/ChatItem/MsgContentView.swift) parameters markdownBlocks, range, baseFont, secretStart; section 3 +MsgContentView (I/Chat/ChatItem/MsgContentView.swift) vars markdownBlocks, blockLayout, cardColor; MarkdownBlocksView rightToLeft from its rightToLeft, MarkdownView from isRightToLeft(text) +item content (I/Chat/ChatItem/MsgContentView.swift:624) chatItem.markdownBlocks +quote (I/Chat/ChatItem/FramedItemView.swift:337) qi.markdownBlocks; one text +FramedItemView (I/Chat/ChatItem/FramedItemView.swift:366) the markdownBlocks of the item; column; quote colour by direction +chatItemPreviewText (I/ChatList/ChatPreviewView.swift:305-316, 302) nil where formattedText is nil, otherwise ci.markdownBlocks; one text +TextBubble (I/Chat/ChatItemInfoView.swift:298-319) the markdownBlocks of the item or the version; MarkdownView +ContextItemView (I/Chat/ComposeMessage/ContextItemView.swift:74) contextItem.markdownBlocks; one text +markdownText (I/Chat/ChatItem/MsgContentView.swift:331-352) blocks of parseSimpleXMarkdownText; one text +FullProfileDescriptionView, GroupWelcomeView textPreview MarkdownView with blocks of parseSimpleXMarkdownText +item swipe (I/Chat/ChatView.swift chatItemWithMenu) ignores drags starting in SwipeExclusionFrames +notifications (S/Notifications.swift:200-210) section 3 +parseSimpleXMarkdownText (S/API.swift) new: (_ s: String) -> ParsedMarkdown? +parseSimpleXMarkdown (S/API.swift:151-162) the formattedText of parseSimpleXMarkdownText +``` + +Without blocks: + +``` +K/views/chatlist/ChatPreviewView.kt:233 draft: formattedText null +K/views/chat/ChatInfoView.kt:811-813 teaser with Format.Modal appended +I/Chat/ChatItem/MsgContentView.swift:380-383 teaser with Format.modal appended +``` + +Unchanged: + +``` +K/views/chat/item/FramedItemView.kt:349 short emoji test: formattedText == null +I/Chat/ChatItem/FramedItemView.swift:145 short emoji test: formattedText == nil +K/views/chat/ComposeView.kt:130 edit of a sent item: the token form, for links and mentions +K/views/usersettings/MarkdownHelpView.kt inline formats +I/UserSettings/MarkdownHelp.swift inline formats +``` + +## 5. Web + +`renderMarkdown(fts, blocks)` (`W/simplex-lib.jsc:57-128`): + +``` +blocks absent as now; escape: nothing +blocks invalid as now; escape: nothing + invalid: not an array + an unknown type + a range not of integers with 0 <= from <= to <= fts.length + a range starting before the end of the previous range, in source order + a list deeper than 7 levels +blocks in source order, each block one element below +range each segment drawn as now + SoftBreak: " " + LineBreak, HardBreak:
+ other markup, escape: nothing + Bold, singleStar: +gaps a ParagraphBreak between two blocks:
; otherwise nothing +Paragraph
: the range +Heading
: the range; N: the level 1-3, otherwise 4 +CodeBlock
: the range, then a copy button (class md-copy, icons md-icon-copy and md-icon-done)
+Quote            
: the range +List each entry, in source order:
: "•" or the number; : the range + N: 1.25 × the level below the top, at most 6 levels +Table
: the header row in ' + cells.map((r, c) => cell(tag, c, r)).join('') + Array.from({ length: Math.max(0, width - cells.length) }, (_, k) => cell(tag, cells.length + k, null)).join('') + ''; + gap(b.header.length > 0 ? b.header[0].from : pos); + html += '
, the body rows in + each cell: style="text-align: A;"; A: the column alignment, none as left, any other value as left + rows padded with empty cells to the column count when columns × rows <= 1000 (MAX_TABLE_CELLS) +Rule
+number escaped text +copy handleCodeClick: a click on the button copies the text of the
 (innerText); a click elsewhere in the card toggles the class tapped
+```
+
+Styles, as the apps (section 3), in each page: `.simplex-preview-text .md-*` in W/channel-preview.jsc (colours from its `--sp-*` variables, card `--sp-quote`, corners 10px for its 18px bubbles), `#directory .entry .md-*` in website/src/directory.html (light and `.dark` values, corners 4px as its entries). The flex items around the text get `min-width: 0` (`.simplex-preview-msg-col`, `#directory .entry .text-container`), so that wide blocks scroll inside the message. `copyToClipboard` moved from W/channel-preview.jsc to W/simplex-lib.jsc.
+
+Call sites:
+
+```
+W/channel-preview.jsc:880-887       the welcome message object: markdownBlocks: data.welcomeMessageBlocks
+W/channel-preview.jsc:990           renderMarkdown(shortDescr, data.shortDescriptionBlocks)
+W/channel-preview.jsc:1522          renderMarkdown(msg.formattedText, msg.markdownBlocks)
+W/directory.jsc:232                 shortDescrBlocks, welcomeMessageBlocks from the entry
+W/directory.jsc:251-255             renderMarkdown with the blocks of each list
+W/directory.jsc:254, 260            

kept: set by innerHTML, the block elements become children of the

+``` + +- The meta span after a message with a block at its end (`W/channel-preview.jsc:1522`) is drawn on its own line. +- Click handlers: W/channel-preview.jsc `setupSecretToggles` calls `handleCodeClick`; W/directory.jsc `initDirectory` adds it on `#directory`. + +## 6. Docs + +``` +apps/multiplatform/spec/client/chat-view.md TextItemView.kt: blocks of markdownBlocks drawn as a column (MarkdownBlocks.kt) +apps/ios/spec/client/chat-view.md MsgContentView: the same (MarkdownBlocksView) +``` + +## 7. Tests + +`apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt`: + +``` +decoding each block token in the JSON of the core + each block, a list with nested entries + each TableAlign value + with and without language + with and without number + Bold with and without singleStar; Escape +text ranges blockTextRanges for each block kind: order, inRow of table cells +list entries listEntries: order and levels +headings headingSegments, also with a shortened list +token texts drawnText and previewText of each token kind; Escape; one-text previews of a heading, paragraph and list +selection itemDisplay: displayed and copied texts and bases of a quote and a table; Escape without blocks +``` + +Checks on each platform of the apps: + +1. A sent message with each block kind: drawn by section 3. +2. A received message with each block kind: drawn by section 3. +3. A text without blocks: drawn as before. +4. Inline parts in each block with content: actions as in a paragraph. + +``` +inline parts a link + a mention + a secret + a command +blocks a heading + a list item + a quote + a table cell +``` + +5. A message with blocks in a quote, the chat list preview, the compose context, item info and a notification. +6. Edit of a sent message with blocks: the compose field has the source text. +7. Desktop: selection inside a block, across blocks, across items, and its copy. +8. Live message with an open code fence. +9. Live message with a growing table. +10. Chat link item with text ending with a heading: no empty line at the end of the text. +11. A profile description and a welcome message with a list. +12. iOS: the cases of `MarkdownBlocksTest.kt` drawn equal to Android. + +Web: + +13. A channel page with a message of each block kind. +14. A directory entry with a welcome message with a list. +15. Relay JSON with hostile values: `number` with markup, `columns` with markup, a range out of bounds, a range not of integers, an unknown block type. + +Not automated: + +``` +iOS the Swift views: check 12 only +web script checks 13-15 only +``` + +## Interfaces + +``` +app types Format: Markup, Escape, Bold singleStar + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + BlockTextRange +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, ParsedFormattedText (Kotlin), ParsedMarkdown (Swift) +functions textRanges, blockTextRanges, listEntries, headingSegments + segmentDisplayText, itemDisplay (Kotlin) + parseToMarkdownText (Kotlin), parseSimpleXMarkdownText (Swift) + the segment loop: range; Swift: baseFont, secretStart, nested function appendSegments +views MarkdownBlocks (Kotlin), MarkdownBlocksView and MarkdownView (Swift) +selection ItemSelection per text view (Kotlin) +resources vertical_logo@1x, @2x, @3x.png (Kotlin, from the iOS asset) +files MarkdownBlocksTest.kt, MarkdownBlocks.kt +``` + +## Depends on + +``` +block Markdown plan, "Types" constructors, fields, JSON tags +block Markdown plan, "Pass 2: blocks" ranges +block Markdown plan, "Records and parse sites" fields +release both plans together +``` diff --git a/plans/2026-10-13-block-markdown.md b/plans/2026-10-13-block-markdown.md new file mode 100644 index 0000000000..d5a39caf36 --- /dev/null +++ b/plans/2026-10-13-block-markdown.md @@ -0,0 +1,867 @@ +# Block Markdown: core + +Date: 2026-10-13. Revised: 2026-10-16, for master. Revised: 2026-10-17, two passes over the whole text. Revised: 2026-10-18, CommonMark syntax (S4, S6), soft breaks (S5), block lines then inline content. + +Each message text is parsed in two passes. In pass 1, the lines of the text are grouped into blocks. The content of the lines of a block is joined with "\n" and parsed by the inline parser of master; each "\n" is then replaced by the break token and the syntax tokens of the next line. Pass 2 divides the tokens at the breaks between blocks and builds the block structure. `formattedText` is the joined token list; the text of each token is its source. `markdownBlocks` is the block structure: index ranges into `formattedText`. + +References are to master at 382dc7480. Haskell paths without a directory are in `src/Simplex/Chat/`. + +## 1. Syntax + +### Lines + +``` +line break "\n", with an optional "\r" before it +final one line break at the text end is removed before pass 1, as by T.lines (Markdown.hs:162) +line the text between two line breaks, or between a line break and the text start or end +hard break two or more spaces, or an odd number of backslashes, before a line break, when the next line is a continuation of the block (section 3) + CommonMark 0.31.2 section 6.7, examples 633, 634 +soft break a line break before a line that is a continuation of a paragraph, a list entry or a quote, without a hard break + CommonMark 0.31.2, section "Soft line breaks" +indentation spaces and tabs at a line start + tab stops every 4 columns +blank line white space only +``` + +- Drawing of line breaks in messages with blocks: rendering plan, section 3. + +### Line kinds + +Tested in this order at a line start, at any indentation: + +``` +code line a line inside a code block, until its closing fence +table start a line with "|", when the next line is a delimiter row with the same number of cells +fence 3 or more "`", then an info string without "`" + or 3 or more "~", then an info string + when a line follows it; a fence line at the text end is text +heading 1 to 6 "#", a space, then text with a character other than white space + closing sequence: a space or a tab, one or more "#", then white space only, at the line end +underline "=" only, or "-" only, then white space only, when the open block is a paragraph + CommonMark 0.31.2 section 4.3 (setext heading underline) +rule 3 or more of one of "-", "*", "_", with spaces and tabs between and after them +list item a marker, a space, then text with a character other than white space + marker: "-", "*", "+", or 1 to 9 digits and "." or ")" +quote ">", then text with a character other than white space + or blank quote lines before such a line + after a paragraph line: such a line, or one blank quote line before it +table row a line with "|", while a table is open +blank white space only +paragraph any other line +``` + +- Delimiter row: a line with "|" where every cell, trimmed, is `:?-+:?`. +- The line after a closing fence is tested anew. + +### Blocks + +``` +CodeBlock a fence line, the code lines after it, and the closing fence line + closing fence: at least as many of the character of the opening fence, then white space only, at any indentation + without a closing fence the block is closed at the end of the text + in a code line the white space up to the column of the opening fence is syntax + a tab that crosses that column is syntax + language: the first word of the info string + language is absent for an empty info string +Heading one heading line + level: the number of "#" + the closing sequence is syntax + or: the lines of a paragraph and the underline after them + level: 1 for "=", 2 for "-" +Rule one rule line +List consecutive list items, blank lines between them included +ListEntry a list item line and its continuation lines (section 3) + content: the text after the marker and the space, then the continuation lines without their indentation + number: the digits and the delimiter, as written + number is absent for "-", "*" and "+" +Quote consecutive quote lines, and the paragraph lines that are continuations of them (section 3) + CommonMark 0.31.2 section 5.1, example 247 + text of a quote line: after ">" and one optional space or tab + a text of white space only is syntax + the lines with texts of white space only at its start and at its end are outside its content +Table a table start line, its delimiter row, and the table rows after them + columns: one per delimiter cell + "-" below: one or more "-" + ":-:" TACenter + "-:" TARight + ":-" TALeft + "-" TANone + header: the cells of the table start line + rows: the cells of each table row + cells are divided at each "|" + the "|" at the line start and at the line end are optional + in a row longer than the header, the last cell is the text from the start of its column to the end of the row, without a "|" at the line end + the white space around the text of a cell is syntax +Paragraph consecutive paragraph lines (section 3) + a paragraph line is text as written, indentation included, as now +``` + +List nesting: + +``` +parent the nearest entry before it with a smaller column, in the same List +depth at most 7 (maxListDepth) + an entry below depth 7: added at depth 7 +blank line the List continues +other block the List ends +``` + +Content: + +``` +inline the lines of a paragraph, of a list entry, of a quote: one text (section 3, inlineLines) + the text of a heading line + each cell +code each code line after its syntax: one segment without format +``` + +### Inline formats across line breaks + +Decided 2026-10-17. + +``` +formats Bold, Italic, StrikeThrough, Snippet, Secret, Small, Colored +across a format is continued into the next line when that line is a continuation of the block (section 3) + one segment of the format per line + the line break and the syntax of the next line between them + never across a blank line + "*", "_", "~": across lines when the content does not start or end with white space; otherwise as now, within the line +within headings and cells: within one line + links, SimpleX links, names, mentions, commands, e-mails, phones: within one line +``` + +Bold, decided 2026-10-18: + +``` +*x* Bold, singleStar = True +**x** Bold, singleStar = False +``` + +- `*x*` is drawn as italic in messages with blocks (rendering plan, section 3). + +### Inline syntax of GFM + +Decided 2026-10-18 (S4, S6). In all messages, with and without blocks: + +``` +~~x~~ StrikeThrough GFM example 491 +__x__ Bold, singleStar = False GFM example 391 +`x` Snippet CommonMark section 6.1, GFM example 339 +`` x ` y `` a run of backticks, the content, a run of the same length + one space removed at each end when the content starts and ends with a space and is not spaces only + a space at one end only: kept (CommonMark example 332); spaces only: kept (example 334) + master's check of spaces at the ends (Markdown.hs:231-235): not applied to Snippet + as [x](x) (sowLinkP, Markdown.hs:281-293), its text "" GFM example 603 + x an e-mail address: HyperLink {showText = Just x, linkUri = "mailto:" <> x} +\c c an ASCII punctuation character: Escape "\", then c without format CommonMark examples 12, 14 + not in code blocks and snippets + "\" before a line break: a hard break (section 1, Lines) +``` + +### Text, as now + +``` +raw HTML +images ![alt](uri) +indented code +a list marker inside a quote +a fence inside a quote +a quote marker inside a quote +nested inline formats +``` + +### Incomplete syntax in a live message + +``` +fence line at the text end paragraph line + a code block once a line follows +open code fence code to the end of the text +table header row paragraph line + a table once the delimiter row is complete +rule characters at the text end rule + text once another character is added to the line +block marker before its text text + a block once text is added +open format at the text end text + a format once it is closed +``` + +- Each version of a live message is parsed from its whole text (`Store/Messages.hs:2583`). + +## 2. Types + +`Markdown.hs`: + +```haskell +data Format + = Bold {singleStar :: BoolDef} + | ... -- present constructors, unchanged + | Phone + | Escape + | Markup {token :: BlockToken} + | Unknown {json :: J.Value} + +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + +data ParsedMarkdown = ParsedMarkdown + { formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) + } +``` + +- `FormattedText` is unchanged. +- `MarkdownList` is unchanged: `[FormattedText]`. +- Markup token: a segment with format `Markup`. +- `Escape`: the backslash of a backslash escape; text `"\"`. +- `markdownText` (`Markdown.hs:472-491`): + - a markup token, `Escape`: its text + - `Bold`: `*x*` when `singleStar`, `**x**` otherwise (`Markdown.hs:476`) + - `Snippet`: delimited by a backtick run longer than any backtick run in its text, with a space inside each delimiter when the text starts or ends with a backtick, or starts and ends with a space and is not spaces only (`Markdown.hs:479`) +- `SegmentRange`: `[from, to)` into `formattedText`. +- JSON of `BlockToken` and `MarkdownBlock`: `sumTypeJSON fstToLower`, as `Format` (`Markdown.hs:536`). +- An unknown block tag is decoded as `UnknownBlock`, as in `Format` (`Markdown.hs:538-539`). +- An unknown token tag: the format is decoded as `Unknown`. +- `Bold` without `singleStar` in JSON is decoded with `singleStar = False`: the field type is `BoolDef` (`Types.hs:2313-2318`). +- JSON of `ListEntry`, `SegmentRange` and `ParsedMarkdown`: `defaultJSON`. +- JSON of `TableAlign`: `enumJSON (dropPrefix "TA")`. +- Field names equal to local names in importing modules (`-Werror=name-shadowing`, simplex-chat.cabal:362) are hidden in those imports. + +Values: + +``` +bold {"type":"bold","singleStar":true} +escape {"type":"escape"} +lineBreak {"type":"markup","token":{"type":"lineBreak"}} +softBreak {"type":"markup","token":{"type":"softBreak"}} +hardBreak {"type":"markup","token":{"type":"hardBreak"}} +headingClose {"type":"markup","token":{"type":"headingClose"}} +headingUnderline {"type":"markup","token":{"type":"headingUnderline","level":1}} +paragraphBreak {"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}} +headingMark {"type":"markup","token":{"type":"headingMark","level":2}} +listMark {"type":"markup","token":{"type":"listMark","number":"2."}} +fenceMark {"type":"markup","token":{"type":"fenceMark","language":"bash"}} +delimiterRow {"type":"markup","token":{"type":"delimiterRow","columns":["none","center"]}} +paragraph {"type":"paragraph","content":{"from":1,"to":4}} +codeBlock {"type":"codeBlock","language":"bash","content":{"from":2,"to":5}} +list {"type":"list","items":[{"number":"1.","content":{"from":4,"to":9},"items":[]}]} +table {"type":"table","columns":["none"],"header":[{"from":1,"to":2}],"rows":[[{"from":6,"to":7}]]} +rule {"type":"rule","mark":{"from":2,"to":3}} +``` + +- `language` and `number` are omitted when absent. + +## 3. Pass 1: tokens + +``` +input the text without its final line break +output segments in source order + the concatenation of their texts is the input +``` + +Block parsers, tested in this order at a line start: + +``` +tableP fenceP headingP ruleP listItemP quoteP paragraphP +``` + +- Block start (`blockStartP`): a table start, a fence, a heading line, a rule, a list item line, a quote start. +- Between blocks: `LineBreak`, or `ParagraphBreak` with the blank lines. + +Continuation lines: + +``` +block next line its syntax tokens +paragraph text, not a block start, not an underline none: the indentation is content +list item text, not a block start Indent, when indented +quote a quote line with text QuoteMark + a blank quote line QuoteMark: the whole line + after a line with text: a line with text, none + without ">", not a block start +table a line with "|", not a block start the row tokens +code each line before the closing fence Indent, then the code +``` + +- An unindented line after a list item is a continuation of it (B11; CommonMark 0.31.2 section 5.2, rule 5). +- A paragraph line after a quote line is a continuation of the quote (CommonMark 0.31.2 section 5.1, example 247). + +Inline content of a paragraph, a list item, a quote (`inlineLines`): + +``` +lines the text of each line after its syntax tokens +hard break two or more spaces, or an odd number of "\", at the end of a line with a next line: removed from the line +runs consecutive lines with text; a blank line is a run alone +parse each run joined with "\n", parsed by markdownP; the runs joined with "\n" +breaks each "\n" replaced by the break token and the syntax tokens of the next line + a segment with "\n" is divided there; empty parts without a format are dropped +break HardBreak: after a hard break, or with a blank line before or after it + SoftBreak: otherwise +``` + +- `markdownP` keeps each "\n" of its input in the texts of its segments. + +Texts of the markup tokens: + +``` +LineBreak a line break between two blocks +SoftBreak a soft break (section 1) +HardBreak a hard break (section 1): the spaces or the backslash, and the line break + the line break before and after a blank quote line inside a quote + the line break between the lines of a code block, between the lines of a table, before a setext underline +ParagraphBreak n a line break and the blank lines after it, each with its line break + the blank lines at the text start, with their line breaks + n: the number of "\n" +Indent the indentation of a continuation line of a list item + in a code line: the white space up to the column of the opening fence +HeadingMark the indentation, the "#" run, the space +HeadingClose the closing sequence of a heading line (section 1) +HeadingUnderline the underline line (section 1); level 1 for "=", 2 for "-" +ListMark the indentation, the marker, the space +QuoteMark the indentation, ">", the optional space or tab + in a quote line with a text of white space only: also that text +FenceMark the opening or the closing fence line +RowMark the indentation, the "|" at the line start, the white space before the first cell + empty in a row without them +CellMark after a cell before the last: its white space, the "|", the white space before the next cell + after the last cell: its white space, the "|" at the line end, the white space after it + empty after a last cell without them +DelimiterRow the delimiter row +RuleMark the rule line +``` + +- Inside a code block the lines are divided by `HardBreak` only, blank code lines and spaces at line ends included. +- Spaces at a line end before a line break other than a hard break: content, as now. + +Tokens of a line, in order: + +``` +paragraph line inline content, the indentation included +heading HeadingMark, inline content, HeadingClose when present +underline HeadingUnderline +list item ListMark, inline content +continuation Indent when indented, inline content +quote line QuoteMark, inline content +fence line FenceMark +code line Indent when indented, one segment without format when not empty +table row RowMark, then for each cell: inline content, CellMark +delimiter row DelimiterRow +rule RuleMark +``` + +Changes to `Markdown.hs`: + +``` +parseMaybeMarkdownList (148-167) formattedText of parsedMarkdown +parsedMarkdown new: the command rule (149-157), as now + T.lines (162), intercalate (163): replaced by pass 1 + the join (164-167): joinSegments, markup tokens not joined +documentP, block parsers new (above) +inlineLines new (above) +fragmentP (209) '\n': "\n" without format +'*' case (214) doubleP '*' (Bold False); then formattedP '*' (Bold True) +'_' case (215) doubleP '_' (Bold False); then formattedP '_' Italic +'~' case (216) doubleP '~' StrikeThrough; then formattedP '~' StrikeThrough +'`' case (217) code span (section 1), in place of formattedP '`' Snippet +'<' case new: autolink (section 1); then wordP +'\' case new: escape (section 1); then wordP +formattedP (228-230) content to the delimiter within the line, as now + without the delimiter in the line: across lines (section 1) +boldP (236-241) doubleP: "**", "__", "~~" + white space at the content ends by isSpace +secret (245-249) white space at the content ends by isSpace +styledP (267-272) the space after the style: not "\n" + white space at the content ends by isSpace +sowLinkP (281-293) the format of the link: hyperLinkFormat, shared with autolinks +wordP (320) to a space, "\n", or a backslash before an ASCII punctuation character +secretFallback (251) to a space or "\n" +nameRefP (253) to a space or "\n" +inParens (294) within one line +displayNameTextP_ (512-517) a quoted name: within one line +parseMarkdown (176-177) unchanged: the inline parser, used by msgPlain (View.hs:2474) +``` + +## 4. Pass 2: blocks + +``` +blocks the tokens divided at LineBreak and ParagraphBreak; empty parts dropped +kind by the first token of a block + HeadingMark Heading + ListMark list entry: the column of its indentation, its number + QuoteMark Quote + FenceMark CodeBlock + RowMark Table + RuleMark Rule: mark, the range of the RuleMark token + other Paragraph; Heading when the last token is HeadingUnderline +lists consecutive list entries: one List, nested by column (section 1) +``` + +Ranges: + +``` +content from the first segment without Markup to the last + the breaks and the markup between them included +code from the token after the line break of the opening fence to the line break before the closing fence, excluded + without a closing fence: to the end +cell from the token after its RowMark or CellMark to its CellMark + empty for an empty cell +row from a RowMark to the next RowMark +columns the DelimiterRow +``` + +### Texts without blocks + +Decided 2026-10-17. + +``` +condition pass 1 yields no markup other than LineBreak, SoftBreak, HardBreak and ParagraphBreak + or more than 2000 markup tokens (maxMarkupTokens; decided 2026-10-18) +result each markup token a segment without format, joined by joinSegments + Nothing when no segment has a format, as now + markdownBlocks: Nothing +``` + +- `Escape` is an inline format: kept in texts without blocks. + +### Example + +Text: + +``` +# Plan +- buy *milk* + and eggs + - nested + +> a +> b +``` + +`formattedText`: + +``` +0 headingMark 1 "# " +1 "Plan" +2 lineBreak "\n" +3 listMark "- " +4 "buy " +5 bold "milk" +6 softBreak "\n" +7 indent " " +8 "and eggs" +9 lineBreak "\n" +10 listMark " - " +11 "nested" +12 paragraphBreak 2 "\n\n" +13 quoteMark "> " +14 "a" +15 softBreak "\n" +16 quoteMark "> " +17 "b" +``` + +`markdownBlocks`: + +``` +Heading 1 [1, 2) +List + ListEntry [4, 9) + ListEntry [11, 12) +Quote [14, 18) +``` + +Format across a line break, `"> *a\n> b*"`: + +``` +0 quoteMark "> " +1 bold "a" +2 softBreak "\n" +3 quoteMark "> " +4 bold "b" +Quote [1, 5) +``` + +## 5. Records and parse sites + +`Markdown.hs`: + +```haskell +parsedMarkdown :: Text -> ParsedMarkdown +parseMaybeMarkdownList :: Text -> Maybe MarkdownList -- formattedText of parsedMarkdown +``` + +Field `markdownBlocks :: Maybe (NonEmpty MarkdownBlock)` after `formattedText`: + +``` +ChatItem Messages.hs:265 +CIQuote Messages.hs:614 +ChatItemVersion Messages.hs:1362 +ParsedMarkdown Markdown.hs:143 +WebMessage Web.hs:103 +``` + +Other new fields, of the same type: + +``` +WebChannelPreview Web.hs:114-115 shortDescriptionBlocks, welcomeMessageBlocks +DirectoryEntry Directory/Listing.hs:94-95 shortDescrBlocks, welcomeMessageBlocks +``` + +`(Text, Maybe MarkdownList)` becomes `(Text, ParsedMarkdown)`: + +``` +Library/Internal.hs:365, 368 msgContentTexts, ciContentTexts +Library/Internal.hs:2993 itemTexts +Library/Internal.hs:3030, 3034, 3037 saveRcvChatItem, ciContentNoParse, saveRcvChatItem' +Library/Internal.hs:3088 mkChatItem_ +Library/Internal.hs:3406, 3416 createLocalChatItems +Library/Commands.hs:5155, 5263 ComposedMessageReq and its list +``` + +- `ciContentNoParse` (`Library/Internal.hs:3034-3035`): `ParsedMarkdown Nothing Nothing`. + +Parse sites: + +``` +Store/Messages.hs:693, 2305 CIQuote: parsedMarkdown of the quoted text +Store/Messages.hs:1122, 2334, 2426, 2583 ChatItem: parsedMarkdown of the item text +Store/Messages.hs:3373 ChatItemVersion +Store/Messages.hs:1284, 1630, 1870, 2864, 2936 markdownBlocks = Nothing, with formattedText = Nothing +Library/Internal.hs:226 CIQuote: markdownBlocks of the quoted item +Library/Commands.hs:4931 CIQuote: markdownBlocks of the quoted item +Messages.hs:1369-1379 mkItemVersion: markdownBlocks of the item +Web.hs:252-253, 325 parsedMarkdown of the description texts; its two fields set the two sibling fields of the record + toFormattedText (426-431) removed: equal to parseMaybeMarkdownList +Directory/Listing.hs:143-144, 197-198 toFormattedText returns the list and the blocks; they set the two sibling fields of the record +Store/Messages.hs:693, 2305 mkCIQuote (Messages.hs): CIQuote with the parsed text of its content +Mobile.hs:396 chatParseMarkdown: J.encode . parsedMarkdown . safeDecodeUtf8 +``` + +- In `updatedMentionNames` (`Library/Internal.hs:291-322`), segments are mapped one to one, so the ranges of `markdownBlocks` are valid for its result. + +## 6. Consumers + +Unchanged: + +``` +mentionedNames (Markdown.hs:71-76) a mention in code is text +hasLinks (Markdown.hs:192-193) a link in code is text +styleMarkdownList, styleFormat (Styled.hs:38-47) markup: its text without style (sgr, Styled.hs:71-79) +updatedMentionNames (Library/Internal.hs:291-322) segments mapped one to one +prohibitedSimplexLinks (Library/Internal.hs:426-429) +removeSimplexLink (Library/Internal.hs:1369-1380) the kept text includes the markup texts +hasObfuscatedSimplexLink (Markdown.hs:195-203) a SimpleX link in code is found in the whole text +msgText (Terminal/Output.hs:231-237) +Directory/Listing.hs:52-60 +Directory/Service.hs:599-600 +Directory/Events.hs:106 +msgPlain (View.hs:2474) parseMarkdownList per line, as now +``` + +`Bold {singleStar}` sites: + +``` +sgr (Styled.hs:73) Bold {}: bold, as now +bold' (View.hs:2172) styled (Bold False) +Help.hs:312 markdown (Bold True) "bold text" +``` + +## 6a. Directory service + +``` +spam filter reply (Directory/Service.hs:1160-1163) each command line ends with two spaces: hard breaks between the command lines +tests/Bots/DirectoryTests.hs unchanged: the terminal output of the tests has no trailing spaces +``` + +## 7. Docs and libraries + +`bots/src/API/Docs/Types.hs`: + +``` +chatTypesDocsData, next to :288-289 (sti @BlockToken, STUnion, "", [], "", "") + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", "") + (sti @ListEntry, STRecord, "", [], "", "") + (sti @SegmentRange, STRecord, "", [], "", "") + (sti @TableAlign, STEnum, "TA", [], "", "") +the Format entry (:288) description: markup tokens, their text as source syntax; Escape; Bold singleStar +next to :521-522 deriving instance Generic for BlockToken, MarkdownBlock, ListEntry, SegmentRange, TableAlign +``` + +Written by the "Bot API docs" test (`tests/APIDocs.hs`): + +``` +bots/api/TYPES.md +the type files of the TypeScript library +the type files of the Python library +``` + +## 8. Compatibility + +- Protocol: unchanged. +- Storage: unchanged. +- A peer with an older core shows the source text with inline formats. +- Items stored earlier are parsed when read (`Store/Messages.hs:1122, 2334, 2426`). Texts drawn as blocks after the change: + +``` +text now after +# of users: 5 text heading +8) nice text list item "8)" +- a text list item ++ a text list item +* a text list item +> cmd text quote +--- text rule +*** text rule +___ text rule +a line with "|" above "-|-" text table +*a text bold, across lines +b* +Title text heading 2 +--- +Title text heading 1 +=== +~~~ fences text code block +\# a text "# a", the "\" not drawn +~~x~~ text strike-through +__x__ text bold +``x`` text code +` a ` text code "a" + text link +``` + +- A quote continued by a line without ">": the line is drawn in the quote. + +- In texts with blocks, after the change: `*x*` drawn as italic; single line breaks in paragraphs, list items and quotes drawn as spaces (rendering plan, section 3). +- `has_link` of earlier items is unchanged. +- A link in code is text. +- A SimpleX link in code is found by `hasObfuscatedSimplexLink` (`Library/Internal.hs:429, 1375, 1380`). +- A SimpleX link in code of a compose field: not found by the app checks of the segments (`ComposeView.kt:435-445`, `ShareListView.kt:81-84`, `ChatUtils.swift:104-113`); the core rejects the message where SimpleX links are prohibited (`Library/Internal.hs:429`), accepted. +- A group link in code of a directory group description: not found by `descriptionContainsLink` (`Directory/Listing.hs:60`); the link line is added to the listing (`Directory/Listing.hs:129-134`), accepted. +- A format across lines, rewritten by `updatedMentionNames`: delimiters on each line, `"> *a\n> b*"` written as `"> *a*\n> *b*"`. + +Costs: + +``` +JSON each line break and each block syntax: one markup segment, in texts with blocks + markdownBlocks: the ranges of the blocks +parsing one parse of the whole text, on each read of an item +``` + +Consumers of earlier releases: + +``` +web pages a markup token: its text, by the default case (website/src/js/simplex-lib.jsc:119-120) + new fields ignored +bots the text of each segment, as now + the Format union has one more type; records have new fields +apps remote desktop only: minRemoteCtrlVersion and minRemoteHostVersion (Remote.hs:77-82) are the version of the release, set at its version change +``` + +## 9. Tests + +`tests/MarkdownTests.hs`: + +``` +operators ==>> (:48-49), <<== (:51-52), <<==>> (:54-55): unchanged + multilineMarkdownList (:463-481): unchanged +new operator ===> for parsedMarkdown: formattedText and markdownBlocks +new spec blockMarkdown +``` + +`blockMarkdown`, the tokens and the blocks of each text: + +``` +heading each level + "#", "# ", "#x", "#\tx#": no blocks + closing sequence: "# a #", "# a ## "; "# a#": "a#" as content +setext "a\n===": heading 1; "a\n---": heading 2; "a\nb\n=": heading 1 of two lines + "- - -" under a text line: rule + "===" under a list item, a quote, a heading, at the text start: text +list each bullet marker + numbers as written, with each delimiter + nesting by 2, 3 and 4 columns + tab indentation + depth above 7 + an indented continuation line + an unindented continuation line + a blank line between items: one list + a paragraph after a list + a list item after a heading +quote ">" with and without a space after it + ">" alone: no blocks + "> a\n>\n> b": one quote with an empty line + ">\n> a\n>": the empty lines outside the content + "> > b": "> b" is text + "> a\nb": one quote, SoftBreak + "> a\nb\n> c": one quote + "> a\n\nb": a quote and a paragraph +code closed + "~~~" fences; "```" not closed by "~~~"; an info string with "`" after "~~~" + open at the end of the text + an empty code line, blank code lines at its end + an info string of two words + indentation of the fence + a tab across the column of the fence + a closing fence with white space after it + inline syntax in code: text + "```", "```\n", "a\n```": no blocks + "```\n```", "```\n\n```", "```\n \n```", "```\n```\nx": code blocks +rule each character + "- - -" + "---" under a text line +table each alignment + a row shorter than the header + a row longer than the header, with and without "|" at its end + an empty cell, a cell of white space only, "||" between cells + rows without "|" at the line start: empty RowMark + indented rows + "a | b\n- | -": a table + a header row without a delimiter row: no blocks + a table ended by a line without "|" +paragraph a paragraph line with indentation: Indent as text + blank lines before the first block and after the last block +across bold, italic, strike-through, snippet, secret, colored across a line break: in a paragraph, a quote, a list item + not across a blank line, a heading line, a cell + an unclosed format: the delimiter and the rest of the line as text; formats on the next lines kept + a delimiter with white space at the content end across lines: as now, within the line +breaks ParagraphBreak counts + CR before each line break, in each block kind + final line break removed + hard break: two and three spaces, and a backslash, before a line break in a paragraph, a list item, a quote + one space before a line break: SoftBreak, the space as content + two spaces or a backslash before a blank line, at the text end, in code: content + SoftBreak in a paragraph, a list entry, a quote; HardBreak between table rows and code lines; LineBreak between blocks +escapes "C:\dir\file.txt": text, one word +bold "*x*": singleStar True + "**x**", "__x__": singleStar False +gfm "~~x~~": StrikeThrough + "`` x ` y ``": Snippet "x ` y"; "```a```" in a line: Snippet "a"; "`` a`": text + "` a `": Snippet "a"; "` a`": Snippet " a"; "` `": Snippet " "; "` a `": Snippet " a " + "": HyperLink, text ""; "": HyperLink to "mailto:a@b.c"; "": text +escapes "\*a*": Escape, "*a*" as text; "\# a" at a line start: no heading + "a\_b": Escape inside a word; "\a": text + in code and snippets: text + in a text without blocks: kept +command "/link 1": as now +live "```", then "```\nls": no blocks, then a code block + "---", then "---x" + an opening fence, then three backticks in the same line + a delimiter row, then more text in it +``` + +Properties, for each text above: + +``` +source T.concat (map markdownText formattedText) is the text without its final line break, except: + colour names written as digits, as now (Markdown.hs:295-309, 494-503) + formats across lines, delimited on each line; an empty part at a line break written as empty delimiters + ~~x~~ written ~x~ + __x__ written **x** + code spans written with the delimiters of markdownText (section 2) +ranges within formattedText, in source order, not overlapping +content each segment without markup is in one content range or one cell +``` + +Consumers: + +``` +mentionedNames a mention in a list item, a quote, a heading, a table cell is found + a mention in code and in a snippet is not found +hasLinks a URI in a list item and a table cell is found; in code not found +SimpleX links in a list item and a table cell: a SimplexLink segment + in code: no segment; found by hasObfuscatedSimplexLink + the text without SimpleX link segments, as removeSimplexLink (Library/Internal.hs:1369-1380) +markdownText each markup token +``` + +- The consumers above are tested on parsed text in `tests/MarkdownTests.hs`; chat tests are not added. + +Other modules: + +``` +tests/ChatTests/Groups.hs:8427 testUpdatedMentionNames + text: a list, a table, a code block + a renamed mention outside code + the same mention in code + the new text: the source with the new name outside code +tests/JSONFixtures.hs a message with a heading, a list and a bold segment, in both encodings +tests/JSONTests.hs the fixtures, for owsf2tagged + a round trip of each block and each markup token + an unknown block tag is UnknownBlock +``` + +Linear time: + +``` +phone numbers the scan ends after 22 characters (master: to the end of the digit groups) +link text "[x]" the scan ends at "[" or a line break +link target "(x)" the scan ends at white space +autolink "" the scan ends at "<" or white space +segment joining each run of equal formats joined once (T.concat) +block starts a table start: two lines; a fence: one line and a line break +table cells one pass over the row and cell marks +``` + +- Tests: the bounded scans keep master's results; texts of 4000 repetitions of each adversarial pattern are parsed; 1000 list items have blocks, 1001 have none. + +## Interfaces + +``` +types Format: Markup, Escape, Bold {singleStar} + BlockToken + MarkdownBlock + ListEntry + SegmentRange + TableAlign + ParsedMarkdown: markdownBlocks +records markdownBlocks: ChatItem, CIQuote, ChatItemVersion, WebMessage + shortDescriptionBlocks, welcomeMessageBlocks: WebChannelPreview + shortDescrBlocks, welcomeMessageBlocks: DirectoryEntry +functions parsedMarkdown + parseMaybeMarkdownList: formattedText of parsedMarkdown +constants maxListDepth 7 + maxMarkupTokens 2000 +``` + +`2026-10-13-block-markdown-rendering.md`: + +``` +the Kotlin types +the iOS types +the web page script +the drawing of the blocks +``` diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 56ed65fb4b..5b0b5b1b3a 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -22,7 +22,7 @@ import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Markdown import Simplex.Chat.Styled -import Simplex.Chat.Types (LocalProfile (..), User (..)) +import Simplex.Chat.Types (BoolDef (..), LocalProfile (..), User (..)) import System.Console.ANSI.Types highlight :: Text -> Markdown @@ -309,7 +309,7 @@ markdownInfo = map styleMarkdown [ green "Markdown:", - indent <> highlight "*bold* " <> " - " <> markdown Bold "bold text", + indent <> highlight "*bold* " <> " - " <> markdown (Bold $ BoolDef True) "bold text", indent <> highlight "_italic_ " <> " - " <> markdown Italic "italic text" <> " (shown as underlined)", indent <> highlight "~strikethrough~" <> " - " <> markdown StrikeThrough "strikethrough text" <> " (shown as inverse)", indent <> highlight "`code snippet` " <> " - " <> markdown Snippet "a + b // no *markdown* here", diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 07839dd032..788b29237d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -834,7 +834,7 @@ processChatCommand cxt nm = \case CTGroup -> withGroupLock "updateChatItem" chatId $ do g@(GIK gInfo@GroupInfo {groupId, membership} _) <- withFastStore $ \db -> getGroupInfoKeys db cxt user chatId when (isNothing scope) $ assertUserGroupRole gInfo GRAuthor - let (_, ft_) = msgContentTexts mc + let (_, ParsedMarkdown ft_ _) = msgContentTexts mc if prohibitedSimplexLinks gInfo membership mc ft_ then throwCmdError ("feature not allowed " <> T.unpack (groupFeatureNameText GFSimplexLinks)) else do @@ -4923,12 +4923,12 @@ processChatCommand cxt nm = \case fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just qiId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, file} <- getDirectChatItem db user contactId qiId (origQmc, qd, sent) <- quoteData qci let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText, markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote pure (mc' {file = fInv_, ttl = ttl' <$> timed_, live = justTrue live}, quotedItem_) @@ -4970,7 +4970,7 @@ processChatCommand cxt nm = \case findProhibited :: [ComposedMessageReq] -> Maybe GroupFeature findProhibited = foldr' - (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ft), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) + (\(ComposedMessage {fileSource, msgContent = mc}, _, (_, ParsedMarkdown ft _), _) acc -> prohibitedGroupContent gInfo membership chatScopeInfo mc ft fileSource True <|> acc) Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do @@ -5152,7 +5152,7 @@ processChatCommand cxt nm = \case prepareLocalItemsData :: NonEmpty ComposedMessageReq -> NonEmpty (Maybe (CIFile 'MDSnd)) -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) prepareLocalItemsData = L.zipWith $ \(ComposedMessage {msgContent = mc}, itemForwarded, ts, _) f -> (CISndMsgContent mc, f, itemForwarded, ts) @@ -5260,7 +5260,7 @@ updatedRelays relays UserOperatorServers {operator, smpServers, xftpServers, cha disableRelay relay@UserChatRelay {preset} = AUCR SDBStored $ if preset then relay {enabled = False} else relay {deleted = True} -type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) +type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, ParsedMarkdown), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage composedMessage f mc = ComposedMessage {fileSource = f, quotedItemId = Nothing, msgContent = mc, mentions = M.empty} @@ -5270,7 +5270,7 @@ composedMessageReq cm@ComposedMessage {msgContent = mc} = (cm, Nothing, msgConte composedMessageReqMentions :: DB.Connection -> User -> GroupInfo -> ComposedMessage -> ExceptT StoreError IO ComposedMessageReq composedMessageReqMentions db user g cm@ComposedMessage {msgContent = mc, mentions} = do - let ts@(_, ft_) = msgContentTexts mc + let ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc (cm,Nothing,ts,) <$> getCIMentions db user g ft_ mentions data ChangedProfileContact = ChangedProfileContact diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ced5c8c676..d4e9893bd9 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -217,13 +217,13 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc m fl_ <- liftIO $ ciffForwardLink db ciff pure (mcForward fl_ mc, Nothing) (Just quotedItemId, Nothing) -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, markdownBlocks, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId (origQmc, qd, sent, member_) <- quoteData qci membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = memberId' <$> member_} qmc = quoteContent mc origQmc file (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft', markdownBlocks} pure (mcQuote QuotedMsg {msgRef, content = qmc'} mc, Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote let mc'' = mc' {mentions = MsgMentions mentions, file = fInv_, ttl = ttl' <$> timed_, live = justTrue live, scope = msgScope, asGroup = justTrue showGroupAsSender} @@ -362,11 +362,11 @@ getMessageMentions db user gId msg = case parseMaybeMarkdownList msg of fmap (name,) . eitherToMaybe <$> runExceptT (getGroupMemberIdByName db user gId name) -msgContentTexts :: MsgContent -> (Text, Maybe MarkdownList) -msgContentTexts mc = let t = msgContentText mc in (t, parseMaybeMarkdownList t) +msgContentTexts :: MsgContent -> (Text, ParsedMarkdown) +msgContentTexts mc = let t = msgContentText mc in (t, parsedMarkdown t) -ciContentTexts :: CIContent d -> (Text, Maybe MarkdownList) -ciContentTexts content = let t = ciContentToText content in (t, parseMaybeMarkdownList t) +ciContentTexts :: CIContent d -> (Text, ParsedMarkdown) +ciContentTexts content = let t = ciContentToText content in (t, parsedMarkdown t) quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent mc qmc ciFile_ @@ -2993,7 +2993,7 @@ saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed l data NewSndChatItemData c = NewSndChatItemData { msg :: SndMessage, content :: CIContent 'MDSnd, - itemTexts :: (Text, Maybe MarkdownList), + itemTexts :: (Text, ParsedMarkdown), itemMentions :: Map MemberName CIMention, ciFile :: Maybe (CIFile 'MDSnd), quotedItem :: Maybe (CIQuote c), @@ -3018,8 +3018,8 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) - createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do - let hasLink_ = ciContentHasLink content (snd itemTexts) + createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts = itemTexts@(_, ParsedMarkdown ft_ _), itemMentions, ciFile, quotedItem, itemForwarded} = do + let hasLink_ = ciContentHasLink content ft_ ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing (toMsgVerified (signMessagesRequired cd) (MSSVerified <$ signedMsg_)) createdAt @@ -3030,15 +3030,15 @@ saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty -ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -ciContentNoParse content = (content, (ciContentToText content, Nothing)) +ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) +ciContentNoParse content = (content, (ciContentToText content, ParsedMarkdown Nothing Nothing)) -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) -saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, ParsedMarkdown)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) +saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMember} sharedMsgId_ brokerTs (content, ts@(_, ParsedMarkdown ft_ _)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime cxt <- chatStoreCxt withStore' $ \db -> do @@ -3054,7 +3054,7 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem itemForwarded <- rcvForwardedFrom db user cd msg (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemForwarded itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt + let ci = mkChatItem_ cd showAsGroup ciId content ts ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of GroupChat g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci @@ -3084,15 +3084,15 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem -- TODO [mentions] optimize by avoiding unnecessary parsing mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgVerified currentTs = - let ts@(_, ft_) = ciContentTexts content + let ts@(_, ParsedMarkdown ft_ _) = ciContentTexts content hasLink_ = ciContentHasLink content ft_ in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs -mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d -mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = +mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, ParsedMarkdown) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgVerified -> UTCTime -> ChatItem c d +mkChatItem_ cd showGroupAsSender ciId content (itemText, ParsedMarkdown formattedText markdownBlocks) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgVerified currentTs = let itemStatus = ciCreateStatus content meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) userMention hasLink_ currentTs itemTs forwardedByMember showGroupAsSender msgVerified currentTs currentTs - in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, quotedItem, reactions = [], file} + in ChatItem {chatDir = toCIDirection cd, meta, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem, reactions = [], file} ciContentHasLink :: CIContent d -> Maybe MarkdownList -> Bool ciContentHasLink content ft_ = case ciMsgContent content of @@ -3406,7 +3406,7 @@ memberAttentionChange unread brokerTs_ rcvMem_ = \case createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> - NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> + NonEmpty (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do @@ -3416,8 +3416,8 @@ createLocalChatItems user cd itemsData createdAt = do unless (null errs) $ toView $ CEvtChatErrors errs pure items where - createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, Maybe MarkdownList)) -> IO (ChatItem 'CTLocal 'MDSnd) - createItem db (content, ciFile, itemForwarded, ts@(_, ft_)) = do + createItem :: DB.Connection -> (CIContent 'MDSnd, Maybe (CIFile 'MDSnd), Maybe CIForwardedFrom, (Text, ParsedMarkdown)) -> IO (ChatItem 'CTLocal 'MDSnd) + createItem db (content, ciFile, itemForwarded, ts@(_, ParsedMarkdown ft_ _)) = do let hasLink_ = ciContentHasLink content ft_ ciId <- createNewChatItem_ db user cd False Nothing Nothing content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False False hasLink_ createdAt Nothing Nothing Nothing Nothing createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index ead232b3be..dfcd1005ab 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -52,6 +52,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Files (getChatTempDirectory, safeFileNameStr) import Simplex.Chat.Library.Internal +import Simplex.Chat.Markdown (ParsedMarkdown (..)) import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) @@ -2245,7 +2246,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag | otherwise = (\GroupMember {memberId} -> encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))) <$> m_ GroupInfo {groupProfile = GroupProfile {publicGroup}} = gInfo sentAsGroup = asGroup_ == Just True - ts@(_, ft_) = msgContentTexts content + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts content -- m' is Maybe GroupMember saveRcvCI gInfo' m' scopeInfo = let itemMember_ = if sentAsGroup then Nothing else m' @@ -2334,7 +2335,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender where content = CIRcvMsgContent mc - ts@(_, ft_) = msgContentTexts mc + ts@(_, ParsedMarkdown ft_ _) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do (cci, scopeInfo) <- withStore $ \db -> do diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index c90cd54fab..996a9449cd 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -19,17 +19,19 @@ import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as AB +import Data.Attoparsec.Combinator (lookAhead) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace) -import Data.Either (fromRight) +import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace, isSymbol) +import Data.Either (fromRight, isLeft, lefts) import Data.Functor (($>)) -import Data.List (foldl', intercalate) -import Data.List.NonEmpty (NonEmpty) +import Data.List (intercalate) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L -import Data.Maybe (fromMaybe, isNothing, mapMaybe) +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) import Data.Semigroup (sconcat) import Data.String import Data.Text (Text) @@ -49,7 +51,7 @@ data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown deriving (Eq, Show) data Format - = Bold + = Bold {singleStar :: BoolDef} | Italic | StrikeThrough | Snippet @@ -65,9 +67,49 @@ data Format | Mention {memberName :: Text} | Email | Phone + | Escape + | Markup {token :: BlockToken} | Unknown {json :: J.Value} deriving (Eq, Show) +data BlockToken + = LineBreak + | SoftBreak + | HardBreak + | ParagraphBreak {lineBreaks :: Int} + | Indent + | HeadingMark {level :: Int} + | HeadingClose + | HeadingUnderline {level :: Int} + | ListMark {number :: Maybe Text} + | QuoteMark + | FenceMark {language :: Maybe Text} + | RowMark + | CellMark + | DelimiterRow {columns :: [TableAlign]} + | RuleMark + deriving (Eq, Show) + +data MarkdownBlock + = Paragraph {content :: SegmentRange} + | Heading {level :: Int, content :: SegmentRange} + | CodeBlock {language :: Maybe Text, content :: SegmentRange} + | Quote {content :: SegmentRange} + | List {items :: NonEmpty ListEntry} + | Table {columns :: NonEmpty TableAlign, header :: [SegmentRange], rows :: [[SegmentRange]]} + | Rule {mark :: SegmentRange} + | UnknownBlock {json :: J.Value} + deriving (Eq, Show) + +data ListEntry = ListEntry {number :: Maybe Text, content :: SegmentRange, items :: [ListEntry]} + deriving (Eq, Show) + +data SegmentRange = SegmentRange {from :: Int, to :: Int} + deriving (Eq, Show) + +data TableAlign = TANone | TALeft | TACenter | TARight + deriving (Eq, Show) + mentionedNames :: MarkdownList -> [Text] mentionedNames = mapMaybe (\(FormattedText f _) -> mentionedName =<< f) where @@ -140,31 +182,120 @@ instance IsString FormattedText where type MarkdownList = [FormattedText] -data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList} +data ParsedMarkdown = ParsedMarkdown {formattedText :: Maybe MarkdownList, markdownBlocks :: Maybe (NonEmpty MarkdownBlock)} unmarked :: Text -> Markdown unmarked = Markdown Nothing parseMaybeMarkdownList :: Text -> Maybe MarkdownList -parseMaybeMarkdownList s = case ls of - [] -> Nothing +parseMaybeMarkdownList s = let ParsedMarkdown {formattedText} = parsedMarkdown s in formattedText + +parsedMarkdown :: Text -> ParsedMarkdown +parsedMarkdown s = case T.lines s of + [] -> ParsedMarkdown Nothing Nothing [l] - | T.null cmd -> Nothing - | isCmd -> Just [FormattedText (Just $ Command cmd) l] + | T.null cmd -> ParsedMarkdown Nothing Nothing + | isCmd -> ParsedMarkdown (Just [FormattedText (Just $ Command cmd) l]) Nothing where (isCmd, cmd) = case T.uncons $ T.dropWhile (== ' ') l of Just (c, rest) -> (c == '/', rest) Nothing -> (False, "") _ - | all (isNothing . format) ml -> Nothing - | otherwise -> Just . reverse $ foldl' acc [] ml + | any isBlockSyntax tokens && length [f | FormattedText f _ <- tokens, isMarkup f] <= maxMarkupTokens -> ParsedMarkdown (Just tokens) (L.nonEmpty $ markdownBlocksOf tokens) + | all (isNothing . format) inline -> ParsedMarkdown Nothing Nothing + | otherwise -> ParsedMarkdown (Just inline) Nothing where - ls = T.lines s - ml = intercalate ["\n"] $ map (markdownToList . parseMarkdown) ls - acc [] m = [m] - acc ms@(FormattedText f t : ms') ft@(FormattedText f' t') - | f == f' = FormattedText f (t <> t') : ms' - | otherwise = ft : ms + s' = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + tokens = joinSegments $ fromRight [FormattedText Nothing s'] $ A.parseOnly (documentP <* A.endOfInput) s' + inline = joinSegments $ map breakAsText tokens + breakAsText ft@(FormattedText f t) = if isMarkup f then FormattedText Nothing t else ft + +isBlockSyntax :: FormattedText -> Bool +isBlockSyntax = \case + FormattedText (Just (Markup t)) _ -> case t of + LineBreak -> False + SoftBreak -> False + HardBreak -> False + ParagraphBreak _ -> False + _ -> True + _ -> False + +joinSegments :: MarkdownList -> MarkdownList +joinSegments = map joinGroup . L.groupBy (\(FormattedText f _) (FormattedText f' _) -> f == f' && not (isMarkup f)) + +joinGroup :: NonEmpty FormattedText -> FormattedText +joinGroup g@(FormattedText f _ :| _) = FormattedText f $ T.concat [t | FormattedText _ t <- L.toList g] + +isMarkup :: Maybe Format -> Bool +isMarkup = \case + Just (Markup _) -> True + _ -> False + +markdownBlocksOf :: MarkdownList -> [MarkdownBlock] +markdownBlocksOf = groupLists . mapMaybe (blockOf <=< L.nonEmpty) . splitBlocks . zip [0 ..] + where + splitBlocks ts = case break (isBlockBreak . snd) ts of + (b, _ : rest) -> b : splitBlocks rest + (b, []) -> [b] + isBlockBreak = \case + FormattedText (Just (Markup LineBreak)) _ -> True + FormattedText (Just (Markup (ParagraphBreak _))) _ -> True + _ -> False + +blockOf :: NonEmpty (Int, FormattedText) -> Maybe (Either (Int, Maybe Text, SegmentRange) MarkdownBlock) +blockOf ts@((i, FormattedText f t) :| _) = case f of + Just (Markup tkn) -> case tkn of + HeadingMark lvl -> Right . Heading lvl <$> contentRange + ListMark num -> (\r -> Left (indentColumn $ T.takeWhile isIndentChar t, num, r)) <$> contentRange + QuoteMark -> Right . Quote <$> contentRange + FenceMark lang -> Just $ Right $ CodeBlock lang $ SegmentRange (i + 2) (max (i + 2) codeEnd) + RowMark -> Right <$> tableBlock (L.toList ts) + RuleMark -> Just $ Right $ Rule $ SegmentRange i (i + 1) + _ -> paragraph + _ -> paragraph + where + contentRange = (\is -> SegmentRange (L.head is) (L.last is + 1)) <$> L.nonEmpty [j | (j, FormattedText f' _) <- L.toList ts, not (isMarkup f')] + codeEnd = case L.last ts of + (j, FormattedText (Just (Markup (FenceMark _))) _) -> j - 1 + (j, _) -> j + 1 + paragraph = case L.last ts of + (_, FormattedText (Just (Markup (HeadingUnderline lvl))) _) -> Right . Heading lvl <$> contentRange + _ -> Right . Paragraph <$> contentRange + +tableBlock :: [(Int, FormattedText)] -> Maybe MarkdownBlock +tableBlock ts = do + cols <- L.nonEmpty $ concat [aligns | (_, FormattedText (Just (Markup (DelimiterRow aligns))) _) <- ts] + headerRow : bodyRows <- pure $ tableRows [(i, tkn) | (i, FormattedText (Just (Markup tkn)) _) <- ts, tkn == RowMark || tkn == CellMark] + pure $ Table cols headerRow bodyRows + where + tableRows = \case + (i, RowMark) : marks -> + let (cellMarks, marks') = span ((== CellMark) . snd) marks + js = map fst cellMarks + in zipWith (\a b -> SegmentRange (a + 1) b) (i : js) js : tableRows marks' + _ : marks -> tableRows marks + [] -> [] + +groupLists :: [Either (Int, Maybe Text, SegmentRange) MarkdownBlock] -> [MarkdownBlock] +groupLists = \case + Right b : bs -> b : groupLists bs + bs@(Left _ : _) -> + let (entries, rest) = span isLeft bs + in maybe id ((:) . List) (L.nonEmpty $ nestEntries 1 $ lefts entries) (groupLists rest) + [] -> [] + +nestEntries :: Int -> [(Int, Maybe Text, SegmentRange)] -> [ListEntry] +nestEntries depth = \case + (column, num, r) : rest -> + let (children, siblings) = if depth < maxListDepth then span (\(c, _, _) -> c > column) rest else ([], rest) + in ListEntry num r (nestEntries (depth + 1) children) : nestEntries depth siblings + [] -> [] + +maxListDepth :: Int +maxListDepth = 7 + +maxMarkupTokens :: Int +maxMarkupTokens = 2000 parseMarkdownList :: Text -> MarkdownList parseMarkdownList = markdownToList . parseMarkdown @@ -173,6 +304,13 @@ markdownToList :: Markdown -> MarkdownList markdownToList (Markdown f s) = [FormattedText f s] markdownToList (m1 :|: m2) = markdownToList m1 <> markdownToList m2 +joinMarkdown :: [Markdown] -> Markdown +joinMarkdown ms = case map joinGroup $ L.groupBy sameFormat $ filter (\(FormattedText _ t) -> not $ T.null t) $ concatMap markdownToList ms of + [] -> mempty + fts -> foldr1 (:|:) [Markdown f t | FormattedText f t <- fts] + where + sameFormat (FormattedText f _) (FormattedText f' _) = f == f' + parseMarkdown :: Text -> Markdown parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInput) s @@ -203,54 +341,76 @@ hasObfuscatedSimplexLink t = <|> pure False markdownP :: Parser Markdown -markdownP = mconcat <$> A.many' fragmentP +markdownP = joinMarkdown <$> A.many' fragmentP where fragmentP :: Parser Markdown fragmentP = A.peekChar >>= \case Just c -> case c of ' ' -> unmarked <$> A.takeWhile (== ' ') + '\n' -> unmarked <$> A.string "\n" '+' -> phoneP <|> wordP - '*' -> boldP <|> formattedP '*' Bold - '_' -> formattedP '_' Italic - '~' -> formattedP '~' StrikeThrough - '`' -> formattedP '`' Snippet + '*' -> doubleP '*' (Bold $ BoolDef False) <|> formattedP '*' (Bold $ BoolDef True) + '_' -> doubleP '_' (Bold $ BoolDef False) <|> formattedP '_' Italic + '~' -> doubleP '~' StrikeThrough <|> formattedP '~' StrikeThrough + '`' -> codeSpanP '#' -> A.char '#' *> (secretP <|> nameRefP '#' <|> secretFallback) '!' -> styledP <|> wordP '@' -> (A.char '@' *> nameRefP '@') <|> mentionP <|> wordP '/' -> commandP <|> wordP '[' -> sowLinkP <|> wordP + '<' -> autolinkP <|> wordP + '\\' -> escapeP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP Nothing -> fail "" formattedP :: Char -> Format -> Parser Markdown formattedP c f = do - s <- A.char c *> A.takeTill (== c) - (A.char c $> md c f s) <|> noFormat (c `T.cons` s) + s <- A.char c *> A.takeTill (\x -> x == c || x == '\n') + (A.char c $> md c f s) <|> (markdown f <$> acrossLinesP s) <|> noFormat (c `T.cons` s) + where + acrossLinesP s = do + s' <- ((s <> "\n") <>) <$> (A.char '\n' *> A.takeTill (== c) <* A.char c) + if T.null s || isSpace (T.head s) || isSpace (T.last s') then fail "not formatted" else pure s' md :: Char -> Format -> Text -> Markdown md c f s | T.null s || T.head s == ' ' || T.last s == ' ' = unmarked $ c `T.cons` s `T.snoc` c | otherwise = markdown f s - boldP :: Parser Markdown - boldP = do - s <- A.string "**" *> A.takeTill (== '*') <* A.string "**" - if T.null s || T.head s == ' ' || T.last s == ' ' - then fail "not bold" - else pure $ markdown Bold s + doubleP :: Char -> Format -> Parser Markdown + doubleP c f = do + s <- A.string cc *> A.takeTill (== c) <* A.string cc + if T.null s || isSpace (T.head s) || isSpace (T.last s) + then fail "not formatted" + else pure $ markdown f s + where + cc = T.pack [c, c] + codeSpanP :: Parser Markdown + codeSpanP = do + ticks <- A.takeWhile1 (== '`') + let pieceP = A.takeWhile1 (/= '`') <|> (A.takeWhile1 (== '`') >>= \r -> r <$ guard (r /= ticks)) + (markdown Snippet . stripSpaces . T.concat <$> A.many' pieceP <* A.string ticks) <|> pure (unmarked ticks) + where + stripSpaces s + | T.isPrefixOf " " s && T.isSuffixOf " " s && T.any (/= ' ') s = T.drop 1 $ T.dropEnd 1 s + | otherwise = s + escapeP :: Parser Markdown + escapeP = do + c <- A.char '\\' *> A.satisfy (\x -> isAscii x && (isPunctuation x || isSymbol x)) + pure $ markdown Escape "\\" <> unmarked (T.singleton c) secretP :: Parser Markdown secretP = secret <$?> ((,,) <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile1 (== '#')) secret :: (Text, Text, Text) -> Either String Markdown secret (b, s, a) - | T.null s || T.head s == ' ' || T.last s == ' ' = Left "not secret" + | T.null s || isSpace (T.head s) || isSpace (T.last s) = Left "not secret" | otherwise = Right $ markdown Secret $ T.init ss where ss = b <> s <> a secretFallback :: Parser Markdown - secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (== ' ') + secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (\c -> c == ' ' || c == '\n') nameRefP :: Char -> Parser Markdown - nameRefP pfx = nameRef <$?> A.takeTill (== ' ') + nameRefP pfx = nameRef <$?> A.takeTill (\c -> c == ' ' || c == '\n') where nameRef word | pfx == '@' && T.all (/= '.') name = Left "not a name" @@ -265,9 +425,9 @@ markdownP = mconcat <$> A.many' fragmentP md' = markdown (SimplexName ni) full styledP :: Parser Markdown styledP = do - f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.space - s <- ((<>) <$> A.takeWhile1 (\c -> c /= ' ' && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' - if T.null s || T.last s == ' ' + f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.satisfy (\c -> isSpace c && c /= '\n') + s <- ((<>) <$> A.takeWhile1 (\c -> not (isSpace c) && c /= '!') <*> A.takeTill (== '!')) <* A.char '!' + if T.null s || isSpace (T.last s) then fail "not styled" else pure $ markdown f s mentionP = prefixedStringP '@' displayNameTextP_ Mention @@ -279,19 +439,24 @@ markdownP = mconcat <$> A.many' fragmentP res = markdown (format str) (pfx `T.cons` origStr) pure $ if T.null punct then res else res :|: unmarked punct sowLinkP = do - t <- '[' `inParens` ']' - l <- '(' `inParens` ')' + t <- enclosedP '[' ']' (\c -> c == '[' || c == '\n') + l <- enclosedP '(' ')' isSpace let hasPunct = T.any (\c -> isPunctuation c && c /= '-' && c /= '_') t when (hasPunct && t /= l && ("https://" <> t) /= l) $ fail "punctuation in hyperlink text" - f <- case strDecode $ encodeUtf8 l of - Right lnk@(ACL _ cLink) -> case cLink of - CLShort _ -> pure $ simplexUriFormat (Just t) lnk - CLFull _ -> fail "full SimpleX link in hyperlink" - Left _ -> case parseUri $ encodeUtf8 l of - Right _ -> pure $ HyperLink (Just t) l - Left e -> fail $ "not uri: " <> T.unpack e + f <- hyperLinkFormat t l pure $ markdown f $ T.concat ["[", t, "](", l, ")"] - inParens open close = A.char open *> A.takeWhile1 (/= close) <* A.char close + autolinkP = do + l <- enclosedP '<' '>' (\c -> c == '<' || isSpace c) + f <-if isEmail l then pure (HyperLink (Just l) ("mailto:" <> l)) else hyperLinkFormat l l + pure $ markdown f $ "<" <> l <> ">" + hyperLinkFormat t l = case strDecode $ encodeUtf8 l of + Right lnk@(ACL _ cLink) -> case cLink of + CLShort _ -> pure $ simplexUriFormat (Just t) lnk + CLFull _ -> fail "full SimpleX link in hyperlink" + Left _ -> case parseUri $ encodeUtf8 l of + Right _ -> pure $ HyperLink (Just t) l + Left e -> fail $ "not uri: " <> T.unpack e + enclosedP open close stop = A.char open *> A.takeWhile1 (\c -> c /= close && not (stop c)) <* A.char close colorP = A.anyChar >>= \case 'r' -> optional "ed" $> Red @@ -310,14 +475,16 @@ markdownP = mconcat <$> A.many' fragmentP phoneP = do country <- optional $ T.cons <$> A.char '+' <*> A.takeWhile1 isDigit code <- optional $ conc4 <$> phoneSep <*> "(" <*> A.takeWhile1 isDigit <*> ")" - segments <- mconcat <$> A.many' ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) - let s = fromMaybe "" country <> fromMaybe "" code <> segments - len = T.length s + s <- segmentsP $ fromMaybe "" country <> fromMaybe "" code + let len = T.length s if 7 <= len && len <= 22 then pure $ markdown Phone s else fail "not phone" + segmentsP s + | T.length s > 22 = fail "not phone" + | otherwise = optional ((<>) <$> phoneSep <*> A.takeWhile1 isDigit) >>= maybe (pure s) (segmentsP . (s <>)) conc4 s1 s2 s3 s4 = s1 <> s2 <> s3 <> s4 phoneSep = " " <|> "-" <|> "." <|> "" wordP :: Parser Markdown - wordP = wordMD <$> A.takeTill (== ' ') + wordP = wordMD . T.concat <$> A.many' (A.takeWhile1 (\c -> c /= ' ' && c /= '\n' && c /= '\\') <|> (notP escapeP *> A.string "\\")) wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s @@ -377,6 +544,307 @@ markdownP = mconcat <$> A.many' fragmentP strEncodeText :: StrEncoding a => a -> Text strEncodeText = safeDecodeUtf8 . strEncode +documentP :: Parser MarkdownList +documentP = do + blanks <- blankLinesP + ([paragraphBreak blanks | not (T.null blanks)] <>) <$> blocksP + where + blocksP = (<>) <$> blockP <*> A.option [] ((:) <$> blockBreakP <*> blocksP) + +blockBreakP :: Parser FormattedText +blockBreakP = do + br <- lineEndP + blanks <- blankLinesP + pure $ if T.null blanks then markupText LineBreak br else paragraphBreak (br <> blanks) + +blankLinesP :: Parser Text +blankLinesP = (<>) <$> (T.concat <$> A.many' ((<>) <$> A.takeWhile isIndentChar <*> lineEndP)) <*> A.option "" (A.takeWhile1 isIndentChar <* A.endOfInput) + +paragraphBreak :: Text -> FormattedText +paragraphBreak t = markupText (ParagraphBreak $ T.count "\n" t) t + +markupText :: BlockToken -> Text -> FormattedText +markupText = FormattedText . Just . Markup + +blockP :: Parser MarkdownList +blockP = tableP <|> fenceP <|> headingP <|> ruleP <|> listItemP <|> quoteP <|> paragraphP + +blockStartP :: Parser () +blockStartP = void tableStartP <|> void fenceStartP <|> void headingMarkP <|> void ruleP <|> void listMarkP <|> quoteStartP + +paragraphLineP :: Parser () +paragraphLineP = hasTextP *> notP blockStartP + +hasTextP :: Parser () +hasTextP = lookAhead lineTextP >>= guard . not . isBlank + +notP :: Parser a -> Parser () +notP p = ((True <$ lookAhead p) <|> pure False) >>= guard . not + +tableP :: Parser MarkdownList +tableP = do + (headerLine, br, delimiter, aligns) <- tableStartP + let row = tableRowTokens $ length aligns + bodyRows <- A.many' $ (\br' l -> markupText HardBreak br' : row l) <$> lineEndP <* notP blockStartP <*> (lineTextP >>= \l -> l <$ guard (T.any (== '|') l)) + pure $ row headerLine <> [markupText HardBreak br, markupText (DelimiterRow aligns) delimiter] <> concat bodyRows + +tableStartP :: Parser (Text, Text, Text, [TableAlign]) +tableStartP = do + headerLine <- lineTextP + br <- lineEndP + delimiter <- lineTextP + aligns <- maybe mzero pure $ tableStart headerLine delimiter + pure (headerLine, br, delimiter, aligns) + +tableRowTokens :: Int -> Text -> MarkdownList +tableRowTokens n t = case map cellParts cellTexts of + (before, c, after) : more -> markupText RowMark (lead <> before) : cellTokens c after more + [] -> [markupText RowMark lead, markupText CellMark trail] + where + (lead, allCells, trail) = tableLine t + cellTexts = case splitAt (n - 1) allCells of + (ts, rest@(_ : _ : _)) -> ts <> [T.intercalate "|" rest] + _ -> allCells + cellTokens c after = \case + (before', c', after') : more -> cellText c <> [markupText CellMark (after <> "|" <> before')] <> cellTokens c' after' more + [] -> cellText c <> [markupText CellMark (after <> trail)] + cellText c = if T.null c then [] else parseMarkdownList c + cellParts cell = + let before = T.takeWhile isSpace cell + rest = T.drop (T.length before) cell + after = T.takeWhileEnd isSpace rest + in (before, T.dropEnd (T.length after) rest, after) + +fenceP :: Parser MarkdownList +fenceP = do + (l, (col, n, ch, lang)) <- fenceStartP + codeLines <- A.many' $ (,) <$> lineEndP <*> (lineTextP >>= \cl -> cl <$ guard (not $ closingFence n ch cl)) + closing <- optional $ (,) <$> lineEndP <*> lineTextP + pure $ markupText (FenceMark lang) l : concatMap (codeLine col) codeLines <> maybe [] (\(br, cl) -> [markupText HardBreak br, markupText (FenceMark Nothing) cl]) closing + where + codeLine col (br, cl) = + let (ind, code) = dropColumns col cl + in markupText HardBreak br : [markupText Indent ind | not (T.null ind)] <> [FormattedText Nothing code | not (T.null code)] + +fenceStartP :: Parser (Text, (Int, Int, Char, Maybe Text)) +fenceStartP = do + l <- lineTextP + fence <- maybe mzero pure $ fenceLine l + (l, fence) <$ lookAhead lineEndP + +headingP :: Parser MarkdownList +headingP = do + (lvl, syntax) <- headingMarkP + (headingText, closing) <- headingClosing <$> lineTextP + pure $ markupText (HeadingMark lvl) syntax : parseMarkdownList headingText <> [markupText HeadingClose closing | not (T.null closing)] + +headingMarkP :: Parser (Int, Text) +headingMarkP = do + ind <- A.takeWhile isIndentChar + hashes <- A.takeWhile1 (== '#') + guard $ T.length hashes <= 6 + sp <- A.string " " + hasTextP + pure (T.length hashes, ind <> hashes <> sp) + +ruleP :: Parser MarkdownList +ruleP = lineTextP >>= \l -> [markupText RuleMark l] <$ guard (isRule l) + +listItemP :: Parser MarkdownList +listItemP = do + (num, syntax) <- listMarkP + l <- lineTextP + ls <- A.many' $ (\br ind l' -> (br, [markupText Indent ind | not (T.null ind)], l')) <$> lineEndP <* paragraphLineP <*> A.takeWhile isIndentChar <*> lineTextP + pure $ markupText (ListMark num) syntax : inlineLines l ls + +listMarkP :: Parser (Maybe Text, Text) +listMarkP = do + ind <- A.takeWhile isIndentChar + (num, marker) <- ((Nothing,) . T.singleton <$> A.satisfy (`elem` ("-*+" :: String))) <|> numberedP + sp <- A.string " " + hasTextP + pure (num, ind <> marker <> sp) + where + numberedP = do + digits <- A.takeWhile1 isDigit + guard $ T.length digits <= 9 + marker <- T.snoc digits <$> A.satisfy (\c -> c == '.' || c == ')') + pure (Just marker, marker) + +quoteP :: Parser MarkdownList +quoteP = do + leading <- A.many' $ (\l br -> [markupText QuoteMark l, markupText HardBreak br]) <$> blankQuoteLineP <*> lineEndP + (mark, l) <- quoteTextP + ls <- nextLinesP True + pure $ concat leading <> (markupText QuoteMark mark : inlineLines l ls) + where + quoteTextP = (,) <$> quoteMarkP <* hasTextP <*> lineTextP + nextLinesP afterText = A.option [] $ do + br <- lineEndP + (syntax, l, isText) <- quoteLineP <|> blankLineP <|> (if afterText then lazyLineP else mzero) + ((br, syntax, l) :) <$> nextLinesP isText + quoteLineP = (\(m, l) -> ([markupText QuoteMark m], l, True)) <$> quoteTextP + blankLineP = (\l -> ([markupText QuoteMark l], "", False)) <$> blankQuoteLineP + lazyLineP = ([],,True) <$> (notP quoteMarkP *> paragraphLineP *> lineTextP) + +quoteMarkP :: Parser Text +quoteMarkP = do + ind <- A.takeWhile isIndentChar + sp <- A.char '>' *> A.option "" (T.singleton <$> A.satisfy isIndentChar) + pure $ ind <> ">" <> sp + +blankQuoteLineP :: Parser Text +blankQuoteLineP = (<>) <$> quoteMarkP <*> A.takeWhile isIndentChar <* lookAhead (void lineEndP <|> A.endOfInput) + +quoteStartP :: Parser () +quoteStartP = optional (blankQuoteLineP *> lineEndP) *> quoteMarkP *> hasTextP + +paragraphP :: Parser MarkdownList +paragraphP = do + l <- lineTextP + ls <- A.many' $ (,[],) <$> lineEndP <* notP underlineP <* paragraphLineP <*> lineTextP + underline <- A.option [] $ (\br (lvl, u) -> [markupText HardBreak br, markupText (HeadingUnderline lvl) u]) <$> lineEndP <*> underlineP + pure $ inlineLines l ls <> underline + +underlineP :: Parser (Int, Text) +underlineP = lineTextP >>= \l -> maybe mzero (pure . (,l)) (underlineLevel l) + +inlineLines :: Text -> [(Text, MarkdownList, Text)] -> MarkdownList +inlineLines l0 nextLines = insertBreaks breaks $ intercalate ["\n"] $ map (parseMarkdownList . T.intercalate "\n") $ runs lineTexts + where + (lineTexts, breaks) = go l0 nextLines + go l = \case + (br, syntax, l') : more -> + let (l1, brk) = if isBlank l || isBlank l' then (l, markupText HardBreak br) else breakAfter l br + (ls, bs) = go l' more + in (l1 : ls, (brk : syntax) : bs) + [] -> ([l], []) + runs = \case + l : ls | not (isBlank l) -> let (run, ls') = break isBlank ls in (l : run) : runs ls' + l : ls -> [l] : runs ls + [] -> [] + +breakAfter :: Text -> Text -> (Text, FormattedText) +breakAfter l br + | T.length spaces >= 2 = (T.dropEnd (T.length spaces) l, markupText HardBreak (spaces <> br)) + | odd (T.length $ T.takeWhileEnd (== '\\') l) = (T.dropEnd 1 l, markupText HardBreak ("\\" <> br)) + | otherwise = (l, markupText SoftBreak br) + where + spaces = T.takeWhileEnd (== ' ') l + +insertBreaks :: [MarkdownList] -> MarkdownList -> MarkdownList +insertBreaks breaks = \case + FormattedText f t : fts -> case (T.breakOn "\n" t, breaks) of + ((t1, t2), brk : breaks') | not (T.null t2) -> segment f t1 <> brk <> insertBreaks breaks' (FormattedText f (T.drop 1 t2) : fts) + _ -> segment f t <> insertBreaks breaks fts + [] -> [] + where + segment f t = [FormattedText f t | isJust f || not (T.null t)] + +lineTextP :: Parser Text +lineTextP = T.concat <$> A.many' (A.takeWhile1 (\c -> c /= '\n' && c /= '\r') <|> (A.string "\r" <* (A.peekChar >>= guard . (/= Just '\n')))) + +lineEndP :: Parser Text +lineEndP = A.string "\r\n" <|> A.string "\n" + +isIndentChar :: Char -> Bool +isIndentChar c = c == ' ' || c == '\t' + +isBlank :: Text -> Bool +isBlank = T.all isSpace + +indentColumn :: Text -> Int +indentColumn = T.foldl' (\col c -> if c == '\t' then nextTabStop col else col + 1) 0 + +nextTabStop :: Int -> Int +nextTabStop col = (col `div` 4 + 1) * 4 + +dropColumns :: Int -> Text -> (Text, Text) +dropColumns n = go 0 + where + go col s + | col >= n = ("", s) + | otherwise = case T.uncons s of + Just (' ', s') -> first (T.cons ' ') $ go (col + 1) s' + Just ('\t', s') -> first (T.cons '\t') $ go (nextTabStop col) s' + _ -> ("", s) + +fenceLine :: Text -> Maybe (Int, Int, Char, Maybe Text) +fenceLine t = do + let (ind, s) = T.span isIndentChar t + ch <- T.find (\c -> c == '`' || c == '~') (T.take 1 s) + let ticks = T.length $ T.takeWhile (== ch) s + info = T.drop ticks s + guard $ ticks >= 3 && (ch == '~' || T.all (/= '`') info) + pure (indentColumn ind, ticks, ch, listToMaybe $ T.words info) + +closingFence :: Int -> Char -> Text -> Bool +closingFence n ch t = + let s = T.dropWhile isIndentChar t + ticks = T.length $ T.takeWhile (== ch) s + in ticks >= n && T.all isSpace (T.drop ticks s) + +headingClosing :: Text -> (Text, Text) +headingClosing c = + let trimmed = T.dropWhileEnd isIndentChar c + hashes = T.takeWhileEnd (== '#') trimmed + before = T.dropEnd (T.length hashes) trimmed + c' = T.dropWhileEnd isIndentChar before + in if not (T.null hashes) && T.any (not . isSpace) c' && T.any isIndentChar (T.takeEnd 1 before) + then (c', T.drop (T.length c') c) + else (c, "") + +underlineLevel :: Text -> Maybe Int +underlineLevel t = case T.uncons s of + Just ('=', _) | T.all (== '=') s -> Just 1 + Just ('-', _) | T.all (== '-') s -> Just 2 + _ -> Nothing + where + s = T.dropWhileEnd isSpace $ T.dropWhile isIndentChar t + +isRule :: Text -> Bool +isRule t = case T.uncons s of + Just (c, _) -> c `elem` ("-*_" :: String) && T.length s >= 3 && T.all (== c) s + Nothing -> False + where + s = T.filter (not . isIndentChar) t + +tableStart :: Text -> Text -> Maybe [TableAlign] +tableStart t d = do + guard $ T.any (== '|') t + aligns <- delimiterRow d + guard $ length (tableCells t) == length aligns + pure aligns + +delimiterRow :: Text -> Maybe [TableAlign] +delimiterRow t + | T.any (== '|') t = mapM cellAlign $ tableCells t + | otherwise = Nothing + where + cellAlign cell = + let s = T.strip cell + (left, s') = maybe (False, s) (True,) $ T.stripPrefix ":" s + (right, dashes) = maybe (False, s') (True,) $ T.stripSuffix ":" s' + in if not (T.null dashes) && T.all (== '-') dashes + then Just $ case (left, right) of + (True, True) -> TACenter + (False, True) -> TARight + (True, False) -> TALeft + (False, False) -> TANone + else Nothing + +tableCells :: Text -> [Text] +tableCells t = let (_, cells, _) = tableLine t in cells + +tableLine :: Text -> (Text, [Text], Text) +tableLine t = (ind <> lead, T.splitOn "|" body, trail) + where + (ind, s) = T.span isIndentChar t + (lead, s') = maybe ("", s) ("|",) $ T.stripPrefix "|" s + ws = T.takeWhileEnd isSpace s' + s'' = T.dropEnd (T.length ws) s' + (body, trail) = maybe (s'', ws) (,"|" <> ws) $ T.stripSuffix "|" s'' + parseUri :: ByteString -> Either Text U.URI parseUri s = case U.parseURI U.laxURIParserOptions s of Left e -> Left $ "Invalid URI: " <> tshow e @@ -473,10 +941,10 @@ markdownText :: FormattedText -> Text markdownText (FormattedText f_ t) = case f_ of Nothing -> t Just f -> case f of - Bold -> around '*' + Bold (BoolDef singleStar) -> if singleStar then around '*' else "**" <> t <> "**" Italic -> around '_' StrikeThrough -> around '~' - Snippet -> around '`' + Snippet -> codeSpan Secret -> around '#' Small -> "!- " <> t <> "!" Colored (FormatColor c) -> color c @@ -488,9 +956,16 @@ markdownText (FormattedText f_ t) = case f_ of Command _ -> t Email -> t Phone -> t + Escape -> t + Markup _ -> t Unknown _ -> t where around c = c `T.cons` t `T.snoc` c + codeSpan = + let ticks = T.replicate (1 + maximum (0 : map T.length (filter (T.isPrefixOf "`") $ T.group t))) "`" + pad = T.isPrefixOf "`" t || T.isSuffixOf "`" t || (T.isPrefixOf " " t && T.isSuffixOf " " t && not (T.all (== ' ') t)) + sp = if pad then " " else "" + in ticks <> sp <> t <> sp <> ticks color c = case colorStr c of Just cStr -> cStr <> t `T.snoc` '!' Nothing -> t @@ -514,7 +989,7 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill takeNameTill p = A.peekChar' >>= \c -> if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted c = A.char c *> takeNameTill (== c) <* A.char c + quoted c = A.char c *> takeNameTill (\x -> x == c || x == '\n') <* A.char c refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\'' commandTextP :: Parser (Text, Text) @@ -533,11 +1008,24 @@ viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s $(JQ.deriveJSON (enumJSON $ dropPrefix "XL") ''SimplexLinkType) +$(JQ.deriveJSON (enumJSON $ dropPrefix "TA") ''TableAlign) + +$(JQ.deriveJSON (sumTypeJSON fstToLower) ''BlockToken) + $(JQ.deriveToJSON (sumTypeJSON fstToLower) ''Format) instance FromJSON Format where parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''Format) v <|> pure (Unknown v) +$(JQ.deriveJSON defaultJSON ''SegmentRange) + +$(JQ.deriveJSON defaultJSON ''ListEntry) + +$(JQ.deriveToJSON (sumTypeJSON fstToLower) ''MarkdownBlock) + +instance FromJSON MarkdownBlock where + parseJSON v = $(JQ.mkParseJSON (sumTypeJSON fstToLower) ''MarkdownBlock) v <|> pure (UnknownBlock v) + $(JQ.deriveJSON defaultJSON ''FormattedText) $(JQ.deriveToJSON defaultJSON ''ParsedMarkdown) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 50035b55be..d298a1d3c1 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -263,6 +263,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem -- The key is a name used in the message text, used to look up CIMention. mentions :: Map MemberName CIMention, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), quotedItem :: Maybe (CIQuote c), reactions :: [CIReactionCount], file :: Maybe (CIFile d) @@ -611,10 +612,16 @@ data CIQuote (c :: ChatType) = CIQuote sharedMsgId :: Maybe SharedMsgId, -- Nothing for the messages from the old clients sentAt :: UTCTime, content :: MsgContent, - formattedText :: Maybe MarkdownList + formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock) } deriving (Show) +mkCIQuote :: CIQDirection c -> Maybe ChatItemId -> Maybe SharedMsgId -> UTCTime -> MsgContent -> CIQuote c +mkCIQuote chatDir itemId sharedMsgId sentAt content = CIQuote {chatDir, itemId, sharedMsgId, sentAt, content, formattedText, markdownBlocks} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText content + quoteItemId :: CIQuote c -> Maybe ChatItemId quoteItemId CIQuote {itemId} = itemId @@ -1360,13 +1367,14 @@ data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, msgContent :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), itemVersionTs :: UTCTime, createdAt :: UTCTime } deriving (Eq, Show) mkItemVersion :: ChatItem c d -> Maybe ChatItemVersion -mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent content +mkItemVersion ChatItem {content, formattedText, markdownBlocks, meta} = version <$> ciMsgContent content where CIMeta {itemId, itemTs, createdAt} = meta version mc = @@ -1374,6 +1382,7 @@ mkItemVersion ChatItem {content, formattedText, meta} = version <$> ciMsgContent { chatItemVersionId = itemId, msgContent = mc, formattedText, + markdownBlocks, itemVersionTs = itemTs, createdAt = createdAt } diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 085df84956..94b4f013ea 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -41,7 +41,7 @@ import Simplex.Chat import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands -import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) +import Simplex.Chat.Markdown (parseUri, parsedMarkdown, sanitizeUri) import Simplex.Chat.Mobile.Badges import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -393,7 +393,7 @@ chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) chatParseMarkdown :: ByteString -> JSONByteString -chatParseMarkdown = J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 +chatParseMarkdown = J.encode . parsedMarkdown . safeDecodeUtf8 {-# INLINE chatParseMarkdown #-} chatParseServer :: ByteString -> JSONByteString diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b8909da1b3..5ad9107e6f 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -690,7 +690,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c - ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content + ciQuote itemId dir = mkCIQuote dir itemId msgId sentAt content getDirectChatItemQuote_ :: Int64 -> Bool -> IO (CIQuote 'CTDirect) getDirectChatItemQuote_ contactId userSent = do fmap ciQuoteDirect . maybeFirstRow fromOnly $ @@ -1119,7 +1119,9 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = Nothing, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = Nothing, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTLocal d ciMeta content status = @@ -1282,6 +1284,7 @@ safeToDirectItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1628,6 +1631,7 @@ safeToGroupItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -1868,6 +1872,7 @@ safeToLocalItem currentTs itemId = \case content = CIInvalidJSON errorText, mentions = M.empty, formattedText = Nothing, + markdownBlocks = Nothing, quotedItem = Nothing, reactions = [], file = Nothing @@ -2302,7 +2307,7 @@ toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction . unBI <$> qu toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c) toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir = - CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) + mkCIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) @@ -2331,7 +2336,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toDirectQuote quoteRow, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -2423,7 +2430,9 @@ toGroupChatItem _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, mentions = M.empty, formattedText, markdownBlocks, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} + where + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown itemText badItem = Left $ SEBadChatItem itemId (Just itemTs) ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = @@ -2569,6 +2578,7 @@ updateDirectChatItem' db User {userId} contactId ci newContent edited live timed updatedChatItem :: ChatItem c d -> CIContent d -> Bool -> Bool -> Maybe CITimed -> UTCTime -> ChatItem c d updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTimed, itemLive}} newContent edited live timed_ currentTs = let newText = ciContentToText newContent + ParsedMarkdown formattedText markdownBlocks = parsedMarkdown newText edited' = itemEdited || edited live' = (live &&) <$> itemLive timed' = case timed_ of @@ -2580,7 +2590,7 @@ updatedChatItem ci@ChatItem {meta = meta@CIMeta {itemStatus, itemEdited, itemTim let deleteAt' = addUTCTime (realToFrac ttl) currentTs in Just CITimed {ttl, deleteAt = Just deleteAt'} _ -> itemTimed - in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText = parseMaybeMarkdownList newText} + in ci {content = newContent, meta = meta {itemText = newText, itemEdited = edited', itemTimed = timed', itemLive = live'}, formattedText, markdownBlocks} -- this function assumes that direct item with correct chat direction already exists, -- it should be checked before calling it @@ -2861,7 +2871,7 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) - pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing} + pure ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} deleteMemberCIs :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO () deleteMemberCIs db User {userId} GroupInfo {groupId, membership} member = do @@ -2933,7 +2943,7 @@ updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIBlockedByAdmin, deletedTs, currentTs, userId, groupId, itemId) - pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing} + pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing, markdownBlocks = Nothing} pattern DBCINotDeleted :: Int pattern DBCINotDeleted = 0 @@ -3370,8 +3380,8 @@ getChatItemVersions db itemId = do where toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = - let formattedText = parseMaybeMarkdownList $ msgContentText msgContent - in ChatItemVersion {chatItemVersionId, msgContent, formattedText, itemVersionTs, createdAt} + let ParsedMarkdown formattedText markdownBlocks = parsedMarkdown $ msgContentText msgContent + in ChatItemVersion {chatItemVersionId, msgContent, formattedText, markdownBlocks, itemVersionTs, createdAt} directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of diff --git a/src/Simplex/Chat/Styled.hs b/src/Simplex/Chat/Styled.hs index 2dd1faa391..e1925ce835 100644 --- a/src/Simplex/Chat/Styled.hs +++ b/src/Simplex/Chat/Styled.hs @@ -70,7 +70,7 @@ sShow = plain . show sgr :: Format -> [SGR] sgr = \case - Bold -> [SetConsoleIntensity BoldIntensity] + Bold {} -> [SetConsoleIntensity BoldIntensity] Italic -> [SetUnderlining SingleUnderline, SetItalicized True] StrikeThrough -> [SetSwapForegroundBackground True] Colored (FormatColor c) -> [SetColor Foreground Vivid c] diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 18d2286135..650209eadd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2169,7 +2169,7 @@ viewGroupDescription GroupInfo {groupProfile = GroupProfile {description}} = maybe ["No welcome message!"] ((bold' "Welcome message:" :) . map plain . T.lines) description bold' :: String -> StyledString -bold' = styled Bold +bold' = styled (Bold $ BoolDef False) viewContactAliasUpdated :: Contact -> [StyledString] viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 80f7432da2..db184f5a8a 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -31,6 +31,7 @@ import Control.Monad import Control.Monad.Except (runExceptT) import Data.Either (rights) import Data.Int (Int64) +import Data.List.NonEmpty (NonEmpty) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import qualified Data.ByteString.Char8 as B @@ -44,7 +45,7 @@ import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), storeCxt) -import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) +import Simplex.Chat.Markdown (MarkdownBlock, MarkdownList, ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Messages ( CChatItem (..), CIDirection (..), @@ -101,6 +102,7 @@ data WebMessage = WebMessage ts :: UTCTime, content :: MsgContent, formattedText :: Maybe MarkdownList, + markdownBlocks :: Maybe (NonEmpty MarkdownBlock), file :: Maybe WebFileInfo, quote :: Maybe QuotedMsg, reactions :: [CIReactionCount], @@ -112,7 +114,9 @@ data WebMessage = WebMessage data WebChannelPreview = WebChannelPreview { channel :: GroupProfile, shortDescription :: Maybe MarkdownList, + shortDescriptionBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), members :: [WebMemberProfile], subscribers :: Maybe Int64, messages :: [WebMessage], @@ -247,10 +251,14 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI let rendered = mapMaybe toRenderedItem $ rights items msgs = map fst rendered senders = collectSenders $ map memberToProfile owners <> concatMap snd rendered + ParsedMarkdown shortDescription shortDescriptionBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown sd + ParsedMarkdown welcomeMessage welcomeMessageBlocks = maybe (ParsedMarkdown Nothing Nothing) parsedMarkdown wd preview = WebChannelPreview { channel = gp, - shortDescription = toFormattedText =<< sd, - welcomeMessage = toFormattedText =<< wd, + shortDescription, + shortDescriptionBlocks, + welcomeMessage, + welcomeMessageBlocks, members = senders, subscribers, messages = msgs, @@ -307,7 +315,7 @@ channelRemoved cc gId = void $ tryPutTMVar wakeSignal () toRenderedItem :: CChatItem 'CTGroup -> Maybe (WebMessage, [WebMemberProfile]) -toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, quotedItem, reactions, file}) +toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, itemForwarded, itemEdited}, content, formattedText, markdownBlocks, quotedItem, reactions, file}) | isJust itemTimed = Nothing | otherwise = case ciMsgContent content of Just mc | not (isReport mc) -> @@ -323,6 +331,7 @@ toRenderedItem (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemTimed, ts = itemTs, content = mc, formattedText, + markdownBlocks, file = webFileInfo <$> file, quote = quotedItem >>= ciQuoteToQuotedMsg, reactions, @@ -423,13 +432,6 @@ removeStaleFiles dir activeFiles = do removeFile (dir f) `catchOwn'` \(e :: SomeException) -> logError $ "web preview: error removing stale file " <> T.pack f <> ": " <> tshow e -toFormattedText :: Text -> Maybe MarkdownList -toFormattedText t = case parseMaybeMarkdownList t of - Just fts | any hasFormat fts -> Just fts - _ -> Nothing - where - hasFormat (FormattedText fmt _) = isJust fmt - publicGroupIdFileName :: B64UrlByteString -> String publicGroupIdFileName = B.unpack . strEncode diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 521fc44d9a..dcb3321702 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -8442,6 +8442,12 @@ testUpdatedMentionNames = do `shouldBe` "hello @'Alice Jones' @robert" test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" `shouldBe` "hello @alice @alice_1" + it "replace changed names outside code" $ \_ -> do + let t = "- hello\n| x | @bob |\n|---|---|\n```\n@bob\n```" + t' = "- hello\n| x | @robert |\n|---|---|\n```\n@bob\n```" + (mc', ft', _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) (mm [("bob", Just "robert")]) + msgContentText mc' `shouldBe` t' + ft' `shouldBe` parseMaybeMarkdownList t' where test mentionsMap t = let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentionsMap diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index bbdf8c14b0..e7948ac190 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -38,7 +38,13 @@ userJSON :: LB.ByteString userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString -parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"hello\"}]}" parsedMarkdownTagged :: LB.ByteString -parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"hello\"}]}" + +markdownBlocksSwift :: LB.ByteString +markdownBlocksSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"headingMark\":{\"level\":2}}}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"lineBreak\":{}}}},\"text\":\"\\n\"},{\"format\":{\"_owsf\":true,\"markup\":{\"token\":{\"_owsf\":true,\"listMark\":{}}}},\"text\":\"- \"},{\"format\":{\"_owsf\":true,\"bold\":{\"singleStar\":true}},\"text\":\"b\"}],\"markdownBlocks\":[{\"_owsf\":true,\"heading\":{\"level\":2,\"content\":{\"from\":1,\"to\":2}}},{\"_owsf\":true,\"list\":{\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}}]}" + +markdownBlocksTagged :: LB.ByteString +markdownBlocksTagged = "{\"formattedText\":[{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"headingMark\",\"level\":2}},\"text\":\"## \"},{\"text\":\"a\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"lineBreak\"}},\"text\":\"\\n\"},{\"format\":{\"type\":\"markup\",\"token\":{\"type\":\"listMark\"}},\"text\":\"- \"},{\"format\":{\"type\":\"bold\",\"singleStar\":true},\"text\":\"b\"}],\"markdownBlocks\":[{\"type\":\"heading\",\"level\":2,\"content\":{\"from\":1,\"to\":2}},{\"type\":\"list\",\"items\":[{\"content\":{\"from\":4,\"to\":5},\"items\":[]}]}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 52d722d26a..4f37e507f7 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} module JSONTests where @@ -13,7 +14,9 @@ import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) import JSONFixtures +import Simplex.Chat.Markdown (Format (Bold), MarkdownBlock (UnknownBlock), ParsedMarkdown (..), parsedMarkdown) import Simplex.Chat.Remote.Protocol (owsf2tagged) +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Parsers import Test.Hspec import Test.Hspec.QuickCheck (modifyMaxSuccess) @@ -27,6 +30,7 @@ owsf2TaggedJSONTest = do chatStartedSwift `to` chatStartedTagged connectionsDiffSwift `to` connectionsDiffTagged parsedMarkdownSwift `to` parsedMarkdownTagged + markdownBlocksSwift `to` markdownBlocksTagged where to :: LB.ByteString -> LB.ByteString -> IO () owsf `to` tagged = @@ -77,7 +81,23 @@ owsf2TaggedSomeTypeTests = modifyMaxSuccess (const 10000) $ do it "should convert to tagged via TH encoding" $ property $ \x -> (join . fmap (JT.parseMaybe thParseJSON . owsf2tagged) . J.decode . toLazyByteString . J.fromEncoding . thToEncoding) x == Just (x :: SomeType) +markdownBlocksJSONTests :: Spec +markdownBlocksJSONTests = do + it "should encode tokens and blocks" $ + J.encode (parsedMarkdown "## a\n- *b*") `shouldBe` markdownBlocksTagged + it "should decode tokens and blocks of each kind" $ do + let ParsedMarkdown ft blocks = parsedMarkdown "# a #\nb\n===\n- *c*\n 1. d\n> e\n\n| x | y |\n|:-|-:|\n| 1 |\n```bash\ncode\n```\n---\ne \nf\\\ng\n- \\*h\n i" + J.eitherDecode (J.encode ft) `shouldBe` Right ft + J.eitherDecode (J.encode blocks) `shouldBe` Right blocks + it "should decode an unknown block" $ + J.eitherDecode "{\"type\":\"future\"}" `shouldBe` Right (UnknownBlock $ J.object ["type" J..= ("future" :: String)]) + it "should decode bold without singleStar" $ do + J.eitherDecode "{\"type\":\"bold\"}" `shouldBe` Right (Bold $ BoolDef False) + (JT.parseEither J.parseJSON . owsf2tagged =<< J.eitherDecode "{\"_owsf\":true,\"bold\":{}}") `shouldBe` Right (Bold $ BoolDef False) + jsonTests :: Spec -jsonTests = describe "owsf2tagged" $ do - it "should convert chat types" owsf2TaggedJSONTest - describe "SomeType" owsf2TaggedSomeTypeTests +jsonTests = do + describe "owsf2tagged" $ do + it "should convert chat types" owsf2TaggedJSONTest + describe "SomeType" owsf2TaggedSomeTypeTests + describe "Markdown blocks" markdownBlocksJSONTests diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 903e3b3773..9c0de7e6e2 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -1,15 +1,22 @@ {-# LANGUAGE BlockArguments #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} module MarkdownTests where +import Control.Applicative ((<|>)) +import Control.Monad (forM_) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Markdown +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Agent.Protocol (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$$>)) @@ -32,6 +39,21 @@ markdownTests = do textWithCommands textWithSimplexNames multilineMarkdownList + inlineMarkdownGFM + backslashEscapes + textsWithoutBlocks + blockMarkdown + breaksInBlocks + headings + thematicBreaks + lists + quotes + codeBlocks + tables + formatsAcrossLines + liveMessages + markdownConsumers + linearTimeParsing testSanitizeUri infixr 1 ==>, <==, <==>, ==>>, <<==, <<==>> @@ -54,8 +76,17 @@ s <<== ft = T.concat (map markdownText ft) `shouldBe` s (<<==>>) :: Text -> MarkdownList -> Expectation s <<==>> ft = (s ==>> ft) >> (s <<== ft) +boldF :: Format +boldF = Bold $ BoolDef True + +bold2F :: Format +bold2F = Bold $ BoolDef False + bold :: Text -> Markdown -bold = markdown Bold +bold = markdown boldF + +bold2 :: Text -> Markdown +bold2 = markdown bold2F textFormat :: Spec textFormat = describe "text format (bold)" do @@ -76,17 +107,17 @@ textFormat = describe "text format (bold)" do <==> "this is " <> bold "bold" <> " " it "correct markdown with double asterisk" do "this is **bold formatted** text" - ==> "this is " <> bold "bold formatted" <> " text" + <==> "this is " <> bold2 "bold formatted" <> " text" "**bold formatted** text" - ==> bold "bold formatted" <> " text" + <==> bold2 "bold formatted" <> " text" "this is **bold**" - ==> "this is " <> bold "bold" + <==> "this is " <> bold2 "bold" " **bold** text" - ==> " " <> bold "bold" <> " text" + <==> " " <> bold2 "bold" <> " text" "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " "this is **bold** " - ==> "this is " <> bold "bold" <> " " + <==> "this is " <> bold2 "bold" <> " " it "ignored as markdown" do "this is * unformatted * text" <==> "this is * unformatted * text" @@ -478,7 +509,576 @@ multilineMarkdownList = describe "multiline markdown" do it "command markdown" do "/link 1" <<==>> [command' "link 1" "/link 1"] " /link 1" <<==>> [command' "link 1" " /link 1"] - "*0*\n/7 /+" <<==>> [FormattedText (Just Bold) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + "*0*\n/7 /+" <<==>> [FormattedText (Just boldF) "0", "\n", command' "7" "/7", " ", command' "+" "/+"] + +inlineMarkdownGFM :: Spec +inlineMarkdownGFM = describe "inline markdown of GFM" do + it "strike-through and bold with double delimiters" do + "~~struck~~ text" ==> markdown StrikeThrough "struck" <> " text" + "~struck~ text" <==> markdown StrikeThrough "struck" <> " text" + "__bold__ text" ==> bold2 "bold" <> " text" + "**a**b" <==> bold2 "a" <> "b" + "~~ a~~" ==> "~~ a~~" + "__ a__" ==> "__ a__" + "a__b__" ==> "a__b__" + markdownText (fmt boldF "a") `shouldBe` "*a*" + markdownText (fmt bold2F "a") `shouldBe` "**a**" + it "code spans" do + "`` x ` y ``" ==> markdown Snippet "x ` y" + "``x ` y``" <== markdown Snippet "x ` y" + "` a `" ==> markdown Snippet "a" + "` a`" <==> markdown Snippet " a" + "` `" <==> markdown Snippet " " + "` a `" <==> markdown Snippet " a " + "``a``" ==> markdown Snippet "a" + "```a```" ==> markdown Snippet "a" + "``` a ```" ==> markdown Snippet "a" + "`` a`" <==> "`` a`" + "`a``b`" ==> markdown Snippet "a``b" + "```a``b```" <== markdown Snippet "a``b" + markdownText (fmt Snippet "`a") `shouldBe` "`` `a ``" + it "autolinks" do + "" <==> web "https://simplex.chat" "https://simplex.chat" "" + "mail " <==> "mail " <> web "chat@simplex.chat" "mailto:chat@simplex.chat" "" + "" <==> "" + " "" <==> "" + +backslashEscapes :: Spec +backslashEscapes = describe "backslash escapes" do + it "an escaped ASCII punctuation character is text" do + "\\*not bold*" <==> markdown Escape "\\" <> "*not bold*" + "a\\_b_" <==> "a" <> markdown Escape "\\" <> "_b_" + "\\\\" <==> markdown Escape "\\" <> "\\" + "\\`a`" <==> markdown Escape "\\" <> "`a`" + it "a backslash before other characters is text" do + "\\a" <==> "\\a" + "\\ a" <==> "\\ a" + "a\\" <==> "a\\" + "C:\\dir\\file.txt" <==> "C:\\dir\\file.txt" + it "escapes are text in code" do + "`\\*`" <==> markdown Snippet "\\*" + "```\n\\*a\n```" <===> ([fence "```", hardBr, "\\*a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + it "escaped block syntax is text" do + "\\# a\n- b" <===> ([fmt Escape "\\", "# a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\- a\n- b" <===> ([fmt Escape "\\", "- a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "\\> a\n- b" <===> ([fmt Escape "\\", "> a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 2), List [entry 4 5 []]]) + "1\\. a\n- b" <===> (["1", fmt Escape "\\", ". a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + it "escapes are kept in texts without blocks" do + "\\*a*" <<==>> [fmt Escape "\\", "*a*"] + "\\*a*\nb" <<==>> [fmt Escape "\\", "*a*\nb"] + +textsWithoutBlocks :: Spec +textsWithoutBlocks = describe "texts without blocks, as on master" do + it "no format" do + parseMaybeMarkdownList "a\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n\nb" `shouldBe` Nothing + parseMaybeMarkdownList "a\n" `shouldBe` Nothing + parseMaybeMarkdownList "a \nb" `shouldBe` Nothing + parseMaybeMarkdownList "\n\na" `shouldBe` Nothing + it "line breaks are text" do + "*a*\n\nb" <<==>> [fmt boldF "a", "\n\nb"] + "*a*\nb" <<==>> [fmt boldF "a", "\nb"] + "*a*\r\nb" <<==>> [fmt boldF "a", "\r\nb"] + "*a* \nb" <<==>> [fmt boldF "a", " \nb"] + "*a*\\\nb" <<==>> [fmt boldF "a", "\\\nb"] + " a\n*b*" <<==>> [" a\n", fmt boldF "b"] + "\n\n*a*" <<==>> ["\n\n", fmt boldF "a"] + it "the final line break is removed" do + "*a*\n" ==>> [fmt boldF "a"] + "*a*\r\n" ==>> [fmt boldF "a"] + "*a*\n\n" ==>> [fmt boldF "a", "\n"] + +infix 1 ===>, <===> + +(===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s ===> (ft, blocks) = do + let ParsedMarkdown ft' blocks' = parsedMarkdown s + (ft', blocks') `shouldBe` (Just ft, L.nonEmpty blocks) + rangesCoverContent ft blocks + +(<===>) :: Text -> (MarkdownList, [MarkdownBlock]) -> Expectation +s <===> parsed@(ft, _) = (s ===> parsed) >> (withoutFinalBreak <<== ft) + where + withoutFinalBreak = fromMaybe s $ T.stripSuffix "\r\n" s <|> T.stripSuffix "\n" s + +noBlocks :: Text -> Expectation +noBlocks s = let ParsedMarkdown _ blocks = parsedMarkdown s in blocks `shouldBe` Nothing + +rangesCoverContent :: MarkdownList -> [MarkdownBlock] -> Expectation +rangesCoverContent ft blocks = do + ranges `shouldSatisfy` all (\(SegmentRange a b) -> 0 <= a && a <= b && b <= length ft) + zip ranges (drop 1 ranges) `shouldSatisfy` all (\(SegmentRange _ b, SegmentRange a _) -> b <= a) + [i | SegmentRange a b <- ranges, i <- [a .. b - 1], i `elem` contentIndices] `shouldBe` contentIndices + where + ranges = concatMap blockRanges blocks + contentIndices = [i | (i, FormattedText f _) <- zip [0 ..] ft, not (isMarkup f)] + blockRanges = \case + Paragraph r -> [r] + Heading _ r -> [r] + CodeBlock _ r -> [r] + Quote r -> [r] + List entries -> concatMap entryRanges entries + Table _ headerCells bodyRows -> headerCells <> concat bodyRows + Rule r -> [r] + UnknownBlock _ -> [] + entryRanges (ListEntry _ r children) = r : concatMap entryRanges children + +fmt :: Format -> Text -> FormattedText +fmt = FormattedText . Just + +mk :: BlockToken -> Text -> FormattedText +mk = markupText + +lineBr :: FormattedText +lineBr = mk LineBreak "\n" + +softBr :: FormattedText +softBr = mk SoftBreak "\n" + +hardBr :: FormattedText +hardBr = mk HardBreak "\n" + +heading1 :: FormattedText +heading1 = mk (HeadingMark 1) "# " + +bullet :: Text -> FormattedText +bullet = mk (ListMark Nothing) + +numbered :: Text -> FormattedText +numbered n = mk (ListMark $ Just n) (n <> " ") + +quoteMark :: Text -> FormattedText +quoteMark = mk QuoteMark + +fence :: Text -> FormattedText +fence = mk (FenceMark Nothing) + +rng :: Int -> Int -> SegmentRange +rng = SegmentRange + +entry :: Int -> Int -> [ListEntry] -> ListEntry +entry a b = ListEntry Nothing (rng a b) + +blockMarkdown :: Spec +blockMarkdown = describe "block markdown" do + it "blocks and their ranges" do + "# Plan\n- buy *milk*\n and eggs\n - nested\n\n> a\n> b" + <===> ( [ mk (HeadingMark 1) "# ", + "Plan", + mk LineBreak "\n", + mk (ListMark Nothing) "- ", + "buy ", + FormattedText (Just boldF) "milk", + mk SoftBreak "\n", + mk Indent " ", + "and eggs", + mk LineBreak "\n", + mk (ListMark Nothing) " - ", + "nested", + mk (ParagraphBreak 2) "\n\n", + mk QuoteMark "> ", + "a", + mk SoftBreak "\n", + mk QuoteMark "> ", + "b" + ], + [Heading 1 (rng 1 2), List [entry 4 9 [entry 11 12 []]], Quote (rng 14 18)] + ) + +breaksInBlocks :: Spec +breaksInBlocks = describe "line breaks in texts with blocks" do + it "soft breaks in paragraphs, list items and quotes" do + "a\nb\nc\n# d" <===> (["a", softBr, "b", softBr, "c", lineBr, heading1, "d"], [Paragraph (rng 0 5), Heading 1 (rng 7 8)]) + " a\n b\n# c" <===> ([" a", softBr, " b", lineBr, heading1, "c"], [Paragraph (rng 0 3), Heading 1 (rng 5 6)]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + it "hard breaks by two or more spaces and by a backslash" do + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \nb\n- c" <===> (["a", mk HardBreak " \n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\nb\n- c" <===> (["a", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", mk HardBreak "\\\n", "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "- a \n b" <===> ([bullet "- ", "a", mk HardBreak " \n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\\\n b" <===> ([bullet "- ", "a", mk HardBreak "\\\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a \n> b" <===> ([quoteMark "> ", "a", mk HardBreak " \n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "> a\\\n> b" <===> ([quoteMark "> ", "a", mk HardBreak "\\\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "spaces and backslashes that are content" do + "a \nb\n- c" <===> (["a ", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a\\\\\nb\n- c" <===> (["a", fmt Escape "\\", "\\", softBr, "b", lineBr, bullet "- ", "c"], [Paragraph (rng 0 5), List [entry 7 8 []]]) + "a \n\n- c" <===> (["a ", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a\\\n\n- c" <===> (["a\\", mk (ParagraphBreak 2) "\n\n", bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "a \n- c" <===> (["a ", lineBr, bullet "- ", "c"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# h\na " <===> ([heading1, "h", lineBr, "a "], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# h\na\\" <===> ([heading1, "h", lineBr, "a\\"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "line and paragraph breaks between blocks" do + "## a\nb" <===> ([mk (HeadingMark 2) "## ", "a", lineBr, "b"], [Heading 2 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n\n\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n\n\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "# a\n \n\t\nb" <===> ([heading1, "a", mk (ParagraphBreak 3) "\n \n\t\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + it "blank lines at the text start and end" do + "\n\n# a" <===> ([mk (ParagraphBreak 2) "\n\n", heading1, "a"], [Heading 1 (rng 2 3)]) + "\n \n# a" <===> ([mk (ParagraphBreak 2) "\n \n", heading1, "a"], [Heading 1 (rng 2 3)]) + "# a\n\n" <===> ([heading1, "a", lineBr], [Heading 1 (rng 1 2)]) + "# a\n\n\n" <===> ([heading1, "a", mk (ParagraphBreak 2) "\n\n"], [Heading 1 (rng 1 2)]) + "# a\n " <===> ([heading1, "a", mk (ParagraphBreak 1) "\n "], [Heading 1 (rng 1 2)]) + it "CR before line breaks" do + "a\r\nb\r\n- c" <===> (["a", mk SoftBreak "\r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "a \r\nb\r\n- c" <===> (["a", mk HardBreak " \r\n", "b", mk LineBreak "\r\n", bullet "- ", "c"], [Paragraph (rng 0 3), List [entry 5 6 []]]) + "# a\r\n- b" <===> ([heading1, "a", mk LineBreak "\r\n", bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "# a\r\n\r\nb" <===> ([heading1, "a", mk (ParagraphBreak 2) "\r\n\r\n", "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- a\r\n b" <===> ([bullet "- ", "a", mk SoftBreak "\r\n", mk Indent " ", "b"], [List [entry 1 5 []]]) + "> a\r\n> b" <===> ([quoteMark "> ", "a", mk SoftBreak "\r\n", quoteMark "> ", "b"], [Quote (rng 1 5)]) + "a\r\n===" <===> (["a", mk HardBreak "\r\n", mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "```\r\na\r\n```" <===> ([fence "```", mk HardBreak "\r\n", "a", mk HardBreak "\r\n", fence "```"], [CodeBlock Nothing (rng 2 3)]) + "|a|\r\n|-|\r\n|b|" <===> ([mk RowMark "|", "a", mk CellMark "|", mk HardBreak "\r\n", mk (DelimiterRow [TANone]) "|-|", mk HardBreak "\r\n", mk RowMark "|", "b", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + +headings :: Spec +headings = describe "headings" do + it "levels 1 to 6" do + forM_ ([1 .. 6] :: [Int]) $ \n -> do + let hashes = T.replicate n "#" + (hashes <> " a") <===> ([mk (HeadingMark n) (hashes <> " "), "a"], [Heading n (rng 1 2)]) + noBlocks "####### a" + it "a space and text after the marker" do + noBlocks "#" + noBlocks "# " + noBlocks "#a" + noBlocks "#\ta" + noBlocks "#\tx#" + "#\n- b" <===> (["#", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + it "indentation and inline content" do + " # a" <===> ([mk (HeadingMark 1) " # ", "a"], [Heading 1 (rng 1 2)]) + "# *a* b" <===> ([heading1, fmt boldF "a", " b"], [Heading 1 (rng 1 3)]) + it "closing sequences" do + "# a #" <===> ([heading1, "a", mk HeadingClose " #"], [Heading 1 (rng 1 2)]) + "# a ## " <===> ([heading1, "a", mk HeadingClose " ## "], [Heading 1 (rng 1 2)]) + "# a #\nb" <===> ([heading1, "a", mk HeadingClose " #", lineBr, "b"], [Heading 1 (rng 1 2), Paragraph (rng 4 5)]) + "# a#" <===> ([heading1, "a#"], [Heading 1 (rng 1 2)]) + "# a \\#" <===> ([heading1, "a ", fmt Escape "\\", "#"], [Heading 1 (rng 1 4)]) + it "one line, after and before other blocks" do + "a\n# b" <===> (["a", lineBr, heading1, "b"], [Paragraph (rng 0 1), Heading 1 (rng 3 4)]) + "# a\n# b" <===> ([heading1, "a", lineBr, heading1, "b"], [Heading 1 (rng 1 2), Heading 1 (rng 4 5)]) + it "setext headings" do + "a\n===" <===> (["a", hardBr, mk (HeadingUnderline 1) "==="], [Heading 1 (rng 0 1)]) + "a\n---" <===> (["a", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 1)]) + "a\n-" <===> (["a", hardBr, mk (HeadingUnderline 2) "-"], [Heading 2 (rng 0 1)]) + "a\nb\n=" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 1) "="], [Heading 1 (rng 0 3)]) + "a\nb\n---" <===> (["a", softBr, "b", hardBr, mk (HeadingUnderline 2) "---"], [Heading 2 (rng 0 3)]) + "a\n === " <===> (["a", hardBr, mk (HeadingUnderline 1) " === "], [Heading 1 (rng 0 1)]) + "a\n---\nb" <===> (["a", hardBr, mk (HeadingUnderline 2) "---", lineBr, "b"], [Heading 2 (rng 0 1), Paragraph (rng 4 5)]) + it "underlines only under paragraphs" do + "a\n- - -" <===> (["a", lineBr, mk RuleMark "- - -"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "a\n***" <===> (["a", lineBr, mk RuleMark "***"], [Paragraph (rng 0 1), Rule (rng 2 3)]) + "# a\n===" <===> ([heading1, "a", lineBr, "==="], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "> a\n===" <===> ([quoteMark "> ", "a", softBr, "==="], [Quote (rng 1 4)]) + "- a\n===" <===> ([bullet "- ", "a", softBr, "==="], [List [entry 1 4 []]]) + noBlocks "a\n= =" + noBlocks "a\n\n===" + noBlocks "===" + noBlocks "===\na" + +thematicBreaks :: Spec +thematicBreaks = describe "thematic breaks" do + it "3 or more of one character, with spaces between and around" do + forM_ (["---", "***", "___", "- - -", "* * *", "_ _ _", "-- -", " ***", "*** ", "----------"] :: [Text]) $ \r -> + r <===> ([mk RuleMark r], [Rule (rng 0 1)]) + it "other lines are text" do + noBlocks "--" + noBlocks "*-*" + noBlocks "---x" + noBlocks "-- a" + it "between other blocks" do + "a\n***\nb" <===> (["a", lineBr, mk RuleMark "***", lineBr, "b"], [Paragraph (rng 0 1), Rule (rng 2 3), Paragraph (rng 4 5)]) + "- a\n---" <===> ([bullet "- ", "a", lineBr, mk RuleMark "---"], [List [entry 1 2 []], Rule (rng 3 4)]) + +lists :: Spec +lists = describe "lists" do + it "bullet markers" do + forM_ (["- ", "* ", "+ "] :: [Text]) $ \m -> (m <> "a") <===> ([bullet m, "a"], [List [entry 1 2 []]]) + "- a\n* b\n+ c" <===> ([bullet "- ", "a", lineBr, bullet "* ", "b", lineBr, bullet "+ ", "c"], [List [entry 1 2 [], entry 4 5 [], entry 7 8 []]]) + it "numbers as written" do + "1. a\n2) b" <===> ([numbered "1.", "a", lineBr, numbered "2)", "b"], [List [ListEntry (Just "1.") (rng 1 2) [], ListEntry (Just "2)") (rng 4 5) []]]) + "3. a\n7. b" <===> ([numbered "3.", "a", lineBr, numbered "7.", "b"], [List [ListEntry (Just "3.") (rng 1 2) [], ListEntry (Just "7.") (rng 4 5) []]]) + "123456789. a" <===> ([numbered "123456789.", "a"], [List [ListEntry (Just "123456789.") (rng 1 2) []]]) + it "markers without a space and text are text" do + noBlocks "-a" + noBlocks "- " + noBlocks "1.a" + noBlocks "1. " + noBlocks "1234567890. a" + noBlocks "a. b" + it "nesting by the column of the marker" do + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "1. a\n - b" <===> ([numbered "1.", "a", lineBr, bullet " - ", "b"], [List [ListEntry (Just "1.") (rng 1 2) [entry 4 5 []]]]) + "- a\n - b" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n\t- b" <===> ([bullet "- ", "a", lineBr, bullet "\t- ", "b"], [List [entry 1 2 [entry 4 5 []]]]) + "- a\n - b\n- c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet "- ", "c"], [List [entry 1 2 [entry 4 5 []], entry 7 8 []]]) + "- a\n - b\n - c" <===> ([bullet "- ", "a", lineBr, bullet " - ", "b", lineBr, bullet " - ", "c"], [List [entry 1 2 [entry 4 5 [], entry 7 8 []]]]) + " - a\n- b" <===> ([bullet " - ", "a", lineBr, bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "depth at most 7" do + let ks = [1 .. 8] :: [Int] + marker k = T.replicate (2 * (k - 1)) " " <> "- " + e k = entry (3 * k - 2) (3 * k - 1) + T.intercalate "\n" [marker k <> T.pack (show k) | k <- ks] + <===> ( intercalate [lineBr] [[bullet (marker k), FormattedText Nothing (T.pack $ show k)] | k <- ks], + [List [e 1 [e 2 [e 3 [e 4 [e 5 [e 6 [e 7 [], e 8 []]]]]]]]] + ) + it "continuation lines" do + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n b" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b"], [List [entry 1 5 []]]) + "- a\n\tb" <===> ([bullet "- ", "a", softBr, mk Indent "\t", "b"], [List [entry 1 5 []]]) + "- a\nb" <===> ([bullet "- ", "a", softBr, "b"], [List [entry 1 4 []]]) + "- a\n b\n - c" <===> ([bullet "- ", "a", softBr, mk Indent " ", "b", lineBr, bullet " - ", "c"], [List [entry 1 5 [entry 7 8 []]]]) + "- *a* b" <===> ([bullet "- ", fmt boldF "a", " b"], [List [entry 1 3 []]]) + it "blank lines between items" do + "- a\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + "- a\n\n\n- b" <===> ([bullet "- ", "a", mk (ParagraphBreak 3) "\n\n\n", bullet "- ", "b"], [List [entry 1 2 [], entry 4 5 []]]) + it "lists and other blocks" do + "- a\n\nb" <===> ([bullet "- ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [List [entry 1 2 []], Paragraph (rng 3 4)]) + "a\n- b" <===> (["a", lineBr, bullet "- ", "b"], [Paragraph (rng 0 1), List [entry 3 4 []]]) + "# a\n- b" <===> ([heading1, "a", lineBr, bullet "- ", "b"], [Heading 1 (rng 1 2), List [entry 4 5 []]]) + "- a\n# b" <===> ([bullet "- ", "a", lineBr, heading1, "b"], [List [entry 1 2 []], Heading 1 (rng 4 5)]) + "- a\n> b" <===> ([bullet "- ", "a", lineBr, quoteMark "> ", "b"], [List [entry 1 2 []], Quote (rng 4 5)]) + "- a\n# h\n- b" <===> ([bullet "- ", "a", lineBr, heading1, "h", lineBr, bullet "- ", "b"], [List [entry 1 2 []], Heading 1 (rng 4 5), List [entry 7 8 []]]) + +quotes :: Spec +quotes = describe "quotes" do + it "quote marks" do + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + ">a" <===> ([quoteMark ">", "a"], [Quote (rng 1 2)]) + ">\ta" <===> ([quoteMark ">\t", "a"], [Quote (rng 1 2)]) + "> a" <===> ([quoteMark "> ", " a"], [Quote (rng 1 2)]) + " > a" <===> ([quoteMark " > ", "a"], [Quote (rng 1 2)]) + ">a\n> b" <===> ([quoteMark ">", "a", softBr, quoteMark "> ", "b"], [Quote (rng 1 5)]) + it "quote marks without text are text" do + noBlocks ">" + noBlocks "> " + noBlocks ">\n>" + it "block syntax inside a quote is text" do + "> > b\nc" <===> ([quoteMark "> ", "> b", softBr, "c"], [Quote (rng 1 4)]) + "> # a" <===> ([quoteMark "> ", "# a"], [Quote (rng 1 2)]) + "> - a" <===> ([quoteMark "> ", "- a"], [Quote (rng 1 2)]) + it "blank quote lines" do + "> a\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + "> a\n>\n>\n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Quote (rng 1 9)]) + "> a\n> \n> b" <===> ([quoteMark "> ", "a", hardBr, quoteMark "> ", hardBr, quoteMark "> ", "b"], [Quote (rng 1 7)]) + ">\n> a\n>" <===> ([quoteMark ">", hardBr, quoteMark "> ", "a", hardBr, quoteMark ">"], [Quote (rng 3 4)]) + it "lines without quote marks after a line with text" do + "> a\nb" <===> ([quoteMark "> ", "a", softBr, "b"], [Quote (rng 1 4)]) + "> a\nb\n> c" <===> ([quoteMark "> ", "a", softBr, "b", softBr, quoteMark "> ", "c"], [Quote (rng 1 7)]) + "> a\nb\n>\n> c" <===> ([quoteMark "> ", "a", softBr, "b", hardBr, quoteMark ">", hardBr, quoteMark "> ", "c"], [Quote (rng 1 9)]) + "> a\n>\nb" <===> ([quoteMark "> ", "a", hardBr, quoteMark ">", lineBr, "b"], [Quote (rng 1 2), Paragraph (rng 5 6)]) + it "quotes and other blocks" do + "> a\n\nb" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", "b"], [Quote (rng 1 2), Paragraph (rng 3 4)]) + "> a\n\n> b" <===> ([quoteMark "> ", "a", mk (ParagraphBreak 2) "\n\n", quoteMark "> ", "b"], [Quote (rng 1 2), Quote (rng 4 5)]) + "> a\n- b" <===> ([quoteMark "> ", "a", lineBr, bullet "- ", "b"], [Quote (rng 1 2), List [entry 4 5 []]]) + "> a\n# b" <===> ([quoteMark "> ", "a", lineBr, heading1, "b"], [Quote (rng 1 2), Heading 1 (rng 4 5)]) + "> a\nb | c\n-|-" + <===> ( [quoteMark "> ", "a", lineBr, mk RowMark "", "b", mk CellMark " | ", "c", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "-|-"], + [Quote (rng 1 2), Table [TANone, TANone] [rng 4 5, rng 6 7] []] + ) + it "quotes after paragraph lines" do + "a\n> b" <===> (["a", lineBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 3 4)]) + "a\n>\n> b" <===> (["a", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 1), Quote (rng 5 6)]) + "a\n>\n>\n> b" <===> (["a", softBr, ">", lineBr, quoteMark ">", hardBr, quoteMark "> ", "b"], [Paragraph (rng 0 3), Quote (rng 7 8)]) + parseMaybeMarkdownList (T.intercalate "\n" $ "a" : replicate 7000 ">") `shouldBe` Nothing + +codeBlocks :: Spec +codeBlocks = describe "code blocks" do + it "fences and languages" do + "```\nx *y*\n```" <===> ([fence "```", hardBr, "x *y*", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "~~~\na\n\nb\n~~~" <===> ([fence "~~~", hardBr, "a", hardBr, hardBr, "b", hardBr, fence "~~~"], [CodeBlock Nothing (rng 2 6)]) + "```bash\necho 1" <===> ([mk (FenceMark $ Just "bash") "```bash", hardBr, "echo 1"], [CodeBlock (Just "bash") (rng 2 3)]) + "```bash script\nx\n```" <===> ([mk (FenceMark $ Just "bash") "```bash script", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "``` bash\nx\n```" <===> ([mk (FenceMark $ Just "bash") "``` bash", hardBr, "x", hardBr, fence "```"], [CodeBlock (Just "bash") (rng 2 3)]) + "~~~ a`b\nc\n~~~" <===> ([mk (FenceMark $ Just "a`b") "~~~ a`b", hardBr, "c", hardBr, fence "~~~"], [CodeBlock (Just "a`b") (rng 2 3)]) + noBlocks "``` a`b\nc\n```" + it "closing fences" do + "```\na\n~~~\nb\n```" <===> ([fence "```", hardBr, "a", hardBr, "~~~", hardBr, "b", hardBr, fence "```"], [CodeBlock Nothing (rng 2 7)]) + "```\na\n``` " <===> ([fence "```", hardBr, "a", hardBr, fence "``` "], [CodeBlock Nothing (rng 2 3)]) + "```\na\n`````" <===> ([fence "```", hardBr, "a", hardBr, fence "`````"], [CodeBlock Nothing (rng 2 3)]) + "````\na\n```\n````" <===> ([fence "````", hardBr, "a", hardBr, "```", hardBr, fence "````"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n``` b" <===> ([fence "```", hardBr, "a", hardBr, "``` b"], [CodeBlock Nothing (rng 2 5)]) + it "blank and indented code lines" do + "```\n\na\n\n```" <===> ([fence "```", hardBr, hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + "```\na\n\n```" <===> ([fence "```", hardBr, "a", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n ```" <===> ([fence " ```", hardBr, mk Indent " ", " a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n\ta\n ```" <===> ([fence " ```", hardBr, mk Indent "\t", "a", hardBr, fence " ```"], [CodeBlock Nothing (rng 2 4)]) + " ```\n a\n```" <===> ([fence " ```", hardBr, mk Indent " ", "a", hardBr, fence "```"], [CodeBlock Nothing (rng 2 4)]) + it "code is text" do + "```\n*a* `b` @c https://simplex.chat\n```" <===> ([fence "```", hardBr, "*a* `b` @c https://simplex.chat", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\na \nb\\\n```" <===> ([fence "```", hardBr, "a ", hardBr, "b\\", hardBr, fence "```"], [CodeBlock Nothing (rng 2 5)]) + it "a fence followed by a line is a code block" do + "```\n```" <===> ([fence "```", hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n```\nx" <===> ([fence "```", hardBr, fence "```", lineBr, "x"], [CodeBlock Nothing (rng 2 2), Paragraph (rng 4 5)]) + "```\n\n```" <===> ([fence "```", hardBr, hardBr, fence "```"], [CodeBlock Nothing (rng 2 2)]) + "```\n \n```" <===> ([fence "```", hardBr, " ", hardBr, fence "```"], [CodeBlock Nothing (rng 2 3)]) + "```\n\n" <===> ([fence "```", hardBr], [CodeBlock Nothing (rng 2 2)]) + it "a fence line at the text end is text" do + noBlocks "```" + noBlocks "```\n" + noBlocks "a\n```" + "a\n```\nb" <===> (["a", lineBr, fence "```", hardBr, "b"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + it "code blocks and other blocks" do + "a\n```\nb\n```" <===> (["a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [Paragraph (rng 0 1), CodeBlock Nothing (rng 4 5)]) + "```\na\n```\nb" <===> ([fence "```", hardBr, "a", hardBr, fence "```", lineBr, "b"], [CodeBlock Nothing (rng 2 3), Paragraph (rng 6 7)]) + "- a\n```\nb\n```" <===> ([bullet "- ", "a", lineBr, fence "```", hardBr, "b", hardBr, fence "```"], [List [entry 1 2 []], CodeBlock Nothing (rng 5 6)]) + +tables :: Spec +tables = describe "tables" do + it "alignments" do + "| a | b | c | d |\n|---|:--|--:|:-:|" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " | ", "c", mk CellMark " | ", "d", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TALeft, TARight, TACenter]) "|---|:--|--:|:-:|"], + [Table [TANone, TALeft, TARight, TACenter] [rng 1 2, rng 3 4, rng 5 6, rng 7 8] []] + ) + "a | b\n:- | -:" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TALeft, TARight]) ":- | -:"], [Table [TALeft, TARight] [rng 1 2, rng 3 4] []]) + it "rows and cells" do + "| a | b |\n|---|:-:|\n| 1 | 2 |" + <===> ( [mk RowMark "| ", "a", mk CellMark " | ", "b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TACenter]) "|---|:-:|", hardBr, mk RowMark "| ", "1", mk CellMark " | ", "2", mk CellMark " |"], + [Table [TANone, TACenter] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]] + ) + "|a|b|\n|-|-|\n|c|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10]]]) + "|a|b|\n|-|-|\n|c|d|e|" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n|c|d|e" <===> (header2 <> [hardBr, mk RowMark "|", "c", mk CellMark "|", "d|e", mk CellMark ""], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 10, rng 11 12]]]) + "|a|b|\n|-|-|\n||c|" <===> (header2 <> [hardBr, mk RowMark "|", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|b|\n|-|-|\n| |c|" <===> (header2 <> [hardBr, mk RowMark "| ", mk CellMark "|", "c", mk CellMark "|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [[rng 9 9, rng 10 11]]]) + "|a|\n|-|\n||" <===> ([mk RowMark "|", "a", mk CellMark "|", hardBr, mk (DelimiterRow [TANone]) "|-|", hardBr, mk RowMark "|", mk CellMark "|"], [Table [TANone] [rng 1 2] [[rng 7 7]]]) + " | a |\n |---|\n | b |" <===> ([mk RowMark " | ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) " |---|", hardBr, mk RowMark " | ", "b", mk CellMark " |"], [Table [TANone] [rng 1 2] [[rng 7 8]]]) + it "inline content in cells" do + "| *a* | @b |\n|---|---|" <===> ([mk RowMark "| ", fmt boldF "a", mk CellMark " | ", fmt (Mention "b") "@b", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "lines that are not tables" do + noBlocks "| a |\n| b |" + noBlocks "| a | b |\n|---|" + noBlocks "| a |\n|---|x" + noBlocks "a\n-|-" + it "tables and other blocks" do + "a | b\n- | -\nc" <===> ([mk RowMark "", "a", mk CellMark " | ", "b", mk CellMark "", hardBr, mk (DelimiterRow [TANone, TANone]) "- | -", lineBr, "c"], [Table [TANone, TANone] [rng 1 2, rng 3 4] [], Paragraph (rng 8 9)]) + "| a |\n|---|\n- b | c" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|", lineBr, bullet "- ", "b | c"], [Table [TANone] [rng 1 2] [], List [entry 7 8 []]]) + "x\n| a |\n|---|" <===> (["x", lineBr, mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|---|"], [Paragraph (rng 0 1), Table [TANone] [rng 3 4] []]) + where + header2 = [mk RowMark "|", "a", mk CellMark "|", "b", mk CellMark "|", hardBr, mk (DelimiterRow [TANone, TANone]) "|-|-|"] + +formatsAcrossLines :: Spec +formatsAcrossLines = describe "formats across line breaks" do + it "each delimited format, in a paragraph" do + "*a\nb*" ==>> [fmt boldF "a", "\n", fmt boldF "b"] + "**a\nb**" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "__a\nb__" ==>> [fmt bold2F "a", "\n", fmt bold2F "b"] + "_a\nb_" ==>> [fmt Italic "a", "\n", fmt Italic "b"] + "~a\nb~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "~~a\nb~~" ==>> [fmt StrikeThrough "a", "\n", fmt StrikeThrough "b"] + "`a\nb`" ==>> [fmt Snippet "a", "\n", fmt Snippet "b"] + "#a\nb#" ==>> [fmt Secret "a", "\n", fmt Secret "b"] + "!1 a\nb!" ==>> [fmt (colored Red) "a", "\n", fmt (colored Red) "b"] + "!- a\nb!" ==>> [fmt Small "a", "\n", fmt Small "b"] + "*a\nb\nc*" ==>> [fmt boldF "a", "\n", fmt boldF "b", "\n", fmt boldF "c"] + "*a \nb*" ==>> [fmt boldF "a", " \n", fmt boldF "b"] + "# h\n*a\nb*" ===> ([heading1, "h", lineBr, fmt boldF "a", softBr, fmt boldF "b"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + it "in a list item and a quote" do + "- *a\n b*" ===> ([bullet "- ", fmt boldF "a", softBr, mk Indent " ", fmt boldF "b"], [List [entry 1 5 []]]) + "- *a\nb*" ===> ([bullet "- ", fmt boldF "a", softBr, fmt boldF "b"], [List [entry 1 4 []]]) + "> *a\n> b*" ===> (quoted, [Quote (rng 1 5)]) + "> *a*\n> *b*" <===> (quoted, [Quote (rng 1 5)]) + "> *a\nb*" ===> ([quoteMark "> ", fmt boldF "a", softBr, fmt boldF "b"], [Quote (rng 1 4)]) + it "not across blocks, blank lines and cells" do + parseMaybeMarkdownList "*a\n\nb*" `shouldBe` Nothing + "# *a\nb*" <===> ([heading1, "*a", lineBr, "b*"], [Heading 1 (rng 1 2), Paragraph (rng 3 4)]) + "- *a\n- b*" <===> ([bullet "- ", "*a", lineBr, bullet "- ", "b*"], [List [entry 1 2 [], entry 4 5 []]]) + "> *a\n>\n> b*" <===> ([quoteMark "> ", "*a", hardBr, quoteMark ">", hardBr, quoteMark "> ", "b*"], [Quote (rng 1 7)]) + "| *a | b* |\n|---|---|" <===> ([mk RowMark "| ", "*a", mk CellMark " | ", "b*", mk CellMark " |", hardBr, mk (DelimiterRow [TANone, TANone]) "|---|---|"], [Table [TANone, TANone] [rng 1 2, rng 3 4] []]) + it "a format starting or ending at a line break keeps its empty part" do + "# h\n`\na`" ===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet "a"], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + "# h\n``\n``" <===> ([heading1, "h", lineBr, fmt Snippet "", softBr, fmt Snippet ""], [Heading 1 (rng 1 2), Paragraph (rng 3 6)]) + T.concat (map markdownText $ fromMaybe [] $ parseMaybeMarkdownList "# h\n`\na`") `shouldBe` "# h\n``\n`a`" + it "an unclosed delimiter is text to the line end" do + "*a\n_b_" ==>> ["*a\n", fmt Italic "b"] + parseMaybeMarkdownList "*a\nb" `shouldBe` Nothing + it "white space at the content ends: as on master, within the line" do + parseMaybeMarkdownList "*a\n*" `shouldBe` Nothing + parseMaybeMarkdownList "*a\nb *" `shouldBe` Nothing + parseMaybeMarkdownList "_ a\nb_" `shouldBe` Nothing + parseMaybeMarkdownList "**a\n**" `shouldBe` Nothing + "5 * 3\n*b*" ==>> ["5 * 3\n", fmt boldF "b"] + it "links, mentions and names within one line" do + parseMaybeMarkdownList "[a\nb](https://simplex.chat)" `shouldBe` Nothing + parseMaybeMarkdownList "@'a\nb'" `shouldBe` Nothing + "https://simplex.chat\nb" ==>> [uri' "https://simplex.chat", "\nb"] + "@alice\nb" ==>> [fmt (Mention "alice") "@alice", "\nb"] + where + quoted = [quoteMark "> ", fmt boldF "a", softBr, quoteMark "> ", fmt boldF "b"] + +liveMessages :: Spec +liveMessages = describe "incomplete syntax of live messages" do + it "a code block once a line follows the fence" do + noBlocks "```" + noBlocks "```js" + "```\nls" <===> ([fence "```", hardBr, "ls"], [CodeBlock Nothing (rng 2 3)]) + "``` a ```" ==>> [fmt Snippet "a"] + it "a rule until another character is added" do + "---" <===> ([mk RuleMark "---"], [Rule (rng 0 1)]) + noBlocks "---x" + it "a table once the delimiter row has a cell" do + noBlocks "| a |" + noBlocks "| a |\n|" + "| a |\n|-" <===> ([mk RowMark "| ", "a", mk CellMark " |", hardBr, mk (DelimiterRow [TANone]) "|-"], [Table [TANone] [rng 1 2] []]) + noBlocks "| a |\n|---|x" + it "block marks before their text" do + noBlocks "-" + noBlocks "1." + noBlocks ">" + "# a" <===> ([heading1, "a"], [Heading 1 (rng 1 2)]) + "- a" <===> ([bullet "- ", "a"], [List [entry 1 2 []]]) + "1. a" <===> ([numbered "1.", "a"], [List [ListEntry (Just "1.") (rng 1 2) []]]) + "> a" <===> ([quoteMark "> ", "a"], [Quote (rng 1 2)]) + +linearTimeParsing :: Spec +linearTimeParsing = describe "bounded scans for linear-time parsing" do + it "phone numbers: at most 22 characters, as on master" do + "1 1 1 1 1 1 1 1 1 1 1 1" <==> "1 " <> phone "1 1 1 1 1 1 1 1 1 1 1" + it "link texts stop at \"[\", link targets and autolinks at white space" do + "[a [b](https://simplex.chat)" <==> "[a " <> web "b" "https://simplex.chat" "[b](https://simplex.chat)" + "[a](https://simplex.chat x)" <==> "[a](https://simplex.chat x)" + "" <==> "" + it "a text with more than 2000 markup tokens has no blocks" do + let ParsedMarkdown _ blocks = parsedMarkdown $ T.replicate 1000 "- a\n" + length <$> blocks `shouldBe` Just 1 + noBlocks $ T.replicate 1001 "- a\n" + length . filter (\(FormattedText f _) -> f == Just boldF) <$> parseMaybeMarkdownList (T.replicate 1001 "- *a*\n") `shouldBe` Just 1001 + it "large adversarial texts" do + forM_ (["1 ", "[ ", "[a]( ", "< ", "a\n", ">\n", "- a\n", "|\n", "|-|\n", "*a\n"] :: [Text]) $ \p -> + length (show $ parseMaybeMarkdownList $ "|a|\n|-|\n" <> T.replicate 4000 p) `shouldSatisfy` (> 0) + +markdownConsumers :: Spec +markdownConsumers = describe "consumers of parsed Markdown" do + it "mentionedNames: mentions in blocks, not in code" do + mentions "- @alice" `shouldBe` ["alice"] + mentions "> @alice" `shouldBe` ["alice"] + mentions "# @alice" `shouldBe` ["alice"] + mentions "| @alice |\n|---|" `shouldBe` ["alice"] + mentions "```\n@alice\n```" `shouldBe` [] + mentions "`@alice`" `shouldBe` [] + it "hasLinks: links in blocks, not in code" do + links "- https://simplex.chat" `shouldBe` True + links "| https://simplex.chat |\n|---|" `shouldBe` True + links "```\nhttps://simplex.chat\n```" `shouldBe` False + it "SimpleX links in blocks and in code" do + simplexLinks ("- " <> addr) `shouldBe` True + simplexLinks ("| " <> addr <> " |\n|---|") `shouldBe` True + simplexLinks ("```\n" <> addr <> "\n```") `shouldBe` False + hasObfuscatedSimplexLink ("```\n" <> addr <> "\n```") `shouldBe` True + textWithoutSimplexLinks ("- check [this link](" <> addr <> ") out") `shouldBe` "- check out" + it "markdownText of markup tokens is their text" do + forM_ ([LineBreak, SoftBreak, HardBreak, ParagraphBreak 2, Indent, HeadingMark 1, HeadingClose, HeadingUnderline 1, ListMark Nothing, QuoteMark, FenceMark Nothing, RowMark, CellMark, DelimiterRow [TANone], RuleMark] :: [BlockToken]) $ \tkn -> + markdownText (mk tkn "x") `shouldBe` "x" + markdownText (fmt Escape "\\") `shouldBe` "\\" + where + parsed = fromMaybe [] . parseMaybeMarkdownList + mentions = mentionedNames . parsed + links = hasLinks . parsed + isSimplexLinkFormat (FormattedText f _) = maybe False isSimplexLink f + simplexLinks = any isSimplexLinkFormat . parsed + textWithoutSimplexLinks = T.concat . map (\(FormattedText _ t) -> t) . filter (not . isSimplexLinkFormat) . parsed + addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw" testSanitizeUri :: Spec testSanitizeUri = describe "sanitizeUri" $ do diff --git a/website/src/directory.html b/website/src/directory.html index 3505cccc4b..41dea04862 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -123,6 +123,57 @@ active_directory: true color: magenta; } + #directory .entry { + --md-card: rgba(139, 135, 134, 0.12); + --md-line: rgba(139, 135, 134, 0.35); + --md-bar: rgba(0, 0, 0, 0.16); + --md-stripe: rgba(139, 135, 134, 0.08); + --md-link: #0053D0; + } + + .dark #directory .entry { + --md-card: rgba(255, 255, 255, 0.08); + --md-line: rgba(139, 135, 134, 0.45); + --md-bar: rgba(255, 255, 255, 0.22); + --md-stripe: rgba(139, 135, 134, 0.12); + --md-link: #70F0F9; + } + + #directory .entry .text-container { min-width: 0; } + #directory .entry .md-gap { height: 0.7em; } + #directory .entry .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } + #directory .entry .md-h4 { font-weight: 700; } + #directory .entry .md-entry { display: flex; gap: 6px; } + #directory .entry .md-mark { flex: none; min-width: 1.1em; } + #directory .entry .md-text { flex: 1; min-width: 0; } + #directory .entry .md-quote { display: flex; gap: 10px; } + #directory .entry .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--md-bar); } + #directory .entry .md-quote > .md-text { opacity: 0.7; } + #directory .entry .md-code { position: relative; background: var(--md-card); border-radius: 4px; overflow: hidden; } + #directory .entry .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } + #directory .entry .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 4px; background: var(--md-card); color: var(--md-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } + #directory .entry .md-code:hover .md-copy, + #directory .entry .md-code.tapped .md-copy, + #directory .entry .md-copy:focus-visible { opacity: 1; pointer-events: auto; } + #directory .entry .md-copy svg { width: 18px; height: 18px; } + #directory .entry .md-icon-done, + #directory .entry .md-copy.copied .md-icon-copy { display: none; } + #directory .entry .md-copy.copied .md-icon-done { display: block; } + #directory .entry .md-table { overflow-x: auto; } + #directory .entry .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--md-line); border-radius: 4px; overflow: hidden; } + #directory .entry .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } + #directory .entry .md-frame th, + #directory .entry .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--md-line); border-bottom: 1px solid var(--md-line); } + #directory .entry .md-frame th:last-child, + #directory .entry .md-frame td:last-child { border-right: 0; } + #directory .entry .md-frame tr:last-child th, + #directory .entry .md-frame tr:last-child td { border-bottom: 0; } + #directory .entry .md-frame th { font-weight: 700; background: var(--md-card); } + #directory .entry .md-frame tr:nth-child(odd) td { background: var(--md-stripe); } + #directory .entry .md-rule { border: 0; border-top: 1px solid var(--md-line); margin: 6px 0; } + .pagination { display: flex; justify-content: center; diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index b0a1696010..d754d36b9c 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -23,6 +23,9 @@ const STYLE = ` --sp-color-white: #000; --sp-qr-fg: #062D56; --sp-qr-bg: #ffffff; + --sp-md-line: rgba(139, 135, 134, 0.35); + --sp-md-bar: rgba(0, 0, 0, 0.16); + --sp-md-stripe: rgba(139, 135, 134, 0.08); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.4; @@ -58,6 +61,9 @@ const STYLE = ` --sp-color-white: #fff; --sp-qr-fg: #FFFBFA; --sp-qr-bg: transparent; + --sp-md-line: rgba(139, 135, 134, 0.45); + --sp-md-bar: rgba(255, 255, 255, 0.22); + --sp-md-stripe: rgba(139, 135, 134, 0.12); } .simplex-preview-header { @@ -497,6 +503,44 @@ const STYLE = ` .simplex-preview-text .black { color: var(--sp-color-black); } .simplex-preview-text .white { color: var(--sp-color-white); } +.simplex-preview-msg-col { + min-width: 0; +} + +.simplex-preview-text .md-gap { height: 0.7em; } +.simplex-preview-text .md-h1 { font-size: 1.5em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h2 { font-size: 1.3em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h3 { font-size: 1.15em; line-height: 1.3; font-weight: 700; } +.simplex-preview-text .md-h4 { font-weight: 700; } +.simplex-preview-text .md-entry { display: flex; gap: 6px; } +.simplex-preview-text .md-mark { flex: none; min-width: 1.1em; } +.simplex-preview-text .md-text { flex: 1; min-width: 0; } +.simplex-preview-text .md-quote { display: flex; gap: 10px; } +.simplex-preview-text .md-bar { flex: none; width: 4px; border-radius: 2px; background: var(--sp-md-bar); } +.simplex-preview-text .md-quote > .md-text { opacity: 0.7; } +.simplex-preview-text .md-code { position: relative; background: var(--sp-quote); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-code pre { margin: 0; padding: 8px 12px; overflow-x: auto; white-space: pre; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.875em; line-height: 1.45; } +.simplex-preview-text .md-copy { position: absolute; top: 4px; right: 4px; width: 28px; height: 28px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 7px; background: var(--sp-quote); color: var(--sp-link); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.15s; } +.simplex-preview-text .md-code:hover .md-copy, +.simplex-preview-text .md-code.tapped .md-copy, +.simplex-preview-text .md-copy:focus-visible { opacity: 1; pointer-events: auto; } +.simplex-preview-text .md-copy svg { width: 18px; height: 18px; } +.simplex-preview-text .md-icon-done, +.simplex-preview-text .md-copy.copied .md-icon-copy { display: none; } +.simplex-preview-text .md-copy.copied .md-icon-done { display: block; } +.simplex-preview-text .md-table { overflow-x: auto; } +.simplex-preview-text .md-frame { display: inline-block; vertical-align: top; border: 1px solid var(--sp-md-line); border-radius: 10px; overflow: hidden; } +.simplex-preview-text .md-frame table { border-collapse: collapse; font-variant-numeric: tabular-nums; } +.simplex-preview-text .md-frame th, +.simplex-preview-text .md-frame td { padding: 4px 12px; white-space: nowrap; border-right: 1px solid var(--sp-md-line); border-bottom: 1px solid var(--sp-md-line); } +.simplex-preview-text .md-frame th:last-child, +.simplex-preview-text .md-frame td:last-child { border-right: 0; } +.simplex-preview-text .md-frame tr:last-child th, +.simplex-preview-text .md-frame tr:last-child td { border-bottom: 0; } +.simplex-preview-text .md-frame th { font-weight: 700; background: var(--sp-quote); } +.simplex-preview-text .md-frame tr:nth-child(odd) td { background: var(--sp-md-stripe); } +.simplex-preview-text .md-rule { border: 0; border-top: 1px solid var(--sp-md-line); margin: 6px 0; } + .simplex-preview-main { flex: 1; min-width: 0; @@ -885,6 +929,7 @@ function render(container, data, channelLink, showAppBadges) { ts: messages.length > 0 ? messages[0].ts : new Date().toISOString(), content: { type: 'text', text: typeof welcome === 'string' ? welcome : '' }, formattedText: Array.isArray(welcome) ? welcome : null, + markdownBlocks: data.welcomeMessageBlocks, reactions: [] }; allMessages = [welcomeMsg].concat(messages); @@ -987,7 +1032,7 @@ function renderInfoContent(container, data, channelLink, subscriberCount, showAp if (shortDescr) { const descrDiv = document.createElement('div'); descrDiv.className = 'simplex-preview-info-descr'; - descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr) : escapeHtml(shortDescr); + descrDiv.innerHTML = Array.isArray(shortDescr) ? renderMarkdown(shortDescr, data.shortDescriptionBlocks) : escapeHtml(shortDescr); container.appendChild(descrDiv); } @@ -1068,25 +1113,6 @@ function qrColor(value, fallback) { return value === 'transparent' ? '#0000' : value; } -// navigator.clipboard is undefined outside secure contexts; fall back to execCommand -function copyToClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(text); - } - return new Promise(function(resolve, reject) { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.top = '-9999px'; - document.body.appendChild(ta); - ta.select(); - let ok = false; - try { ok = document.execCommand('copy'); } catch (e) {} - document.body.removeChild(ta); - ok ? resolve() : reject(new Error('copy failed')); - }); -} - function renderDesktopConversion(container, channelLink, showAppBadges) { if (showAppBadges) { renderAppBadges(container); @@ -1190,6 +1216,7 @@ function setupSecretToggles(container) { container.addEventListener('click', (e) => { const secret = e.target.closest('.secret'); if (secret) secret.classList.toggle('visible'); + handleCodeClick(e); }); } @@ -1246,6 +1273,7 @@ function renderMessages(container, messages, membersMap, channel) { } const col = document.createElement('div'); + col.className = 'simplex-preview-msg-col'; const bubble = renderBubble(msg, member, showTail, membersMap, channel); col.appendChild(bubble); @@ -1519,7 +1547,7 @@ function appendTextBlock(bubble, msg) { textDiv.className = 'simplex-preview-text'; const meta = renderMetaHTML(msg); if (msg.formattedText && msg.formattedText.length > 0) { - textDiv.innerHTML = renderMarkdown(msg.formattedText) + meta; + textDiv.innerHTML = renderMarkdown(msg.formattedText, msg.markdownBlocks) + meta; } else { textDiv.innerHTML = escapeHtml(msg.content.text || '') + meta; } diff --git a/website/src/js/directory.jsc b/website/src/js/directory.jsc index 5ca3b7fbcf..dd07735260 100644 --- a/website/src/js/directory.jsc +++ b/website/src/js/directory.jsc @@ -32,6 +32,7 @@ async function initDirectory() { liveBtn.addEventListener('click', () => renderEntries('live', byActiveAtDesc, liveBtn)); newBtn.addEventListener('click', () => renderEntries('new', byCreatedAtDesc, newBtn)); topBtn.addEventListener('click', () => renderEntries('top', bySortPriority, topBtn)); + document.getElementById('directory').addEventListener('click', handleCodeClick); window.addEventListener('popstate', applyHash); function applyHash() { @@ -229,7 +230,7 @@ function displayEntries(entries) { for (let entry of entries) { try { - const { entryType, displayName, simplexName, groupLink, shortDescr, welcomeMessage, imageFile } = entry; + const { entryType, displayName, simplexName, groupLink, shortDescr, shortDescrBlocks, welcomeMessage, welcomeMessageBlocks, imageFile } = entry; const entryDiv = document.createElement('div'); entryDiv.className = 'entry w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8'; @@ -248,11 +249,11 @@ function displayEntries(entries) { textContainer.appendChild(simplexNameElement); } - const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage) : undefined; - const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr) : undefined; + const welcomeMessageHTML = welcomeMessage ? renderMarkdown(welcomeMessage, welcomeMessageBlocks) : undefined; + const shortDescrHTML = shortDescr ? renderMarkdown(shortDescr, shortDescrBlocks) : undefined; if (shortDescrHTML && welcomeMessageHTML?.includes(shortDescrHTML) !== true) { const descrElement = document.createElement('p'); - descrElement.innerHTML = renderMarkdown(shortDescr); + descrElement.innerHTML = shortDescrHTML; textContainer.appendChild(descrElement); } diff --git a/website/src/js/simplex-lib.jsc b/website/src/js/simplex-lib.jsc index d05dd6a931..73e304f9dd 100644 --- a/website/src/js/simplex-lib.jsc +++ b/website/src/js/simplex-lib.jsc @@ -54,7 +54,157 @@ function targetBlank(uri) { return isCurrentSite(uri) ? '' : ' target="_blank" rel="noopener"' } -function renderMarkdown(fts) { +const MAX_LIST_DEPTH = 7; + +const MAX_TABLE_CELLS = 1000; + +const COPY_BUTTON = + ''; + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + +function handleCodeClick(e) { + const card = e.target.closest('.md-code'); + if (!card) return; + const button = e.target.closest('.md-copy'); + if (!button) { + card.classList.toggle('tapped'); + return; + } + const pre = card.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.innerText).then(() => { + button.classList.add('copied'); + setTimeout(() => button.classList.remove('copied'), 1500); + }).catch(() => {}); +} + +function validBlocks(fts, blocks) { + let pos = 0; + const range = (r) => { + const valid = !!r && Number.isInteger(r.from) && Number.isInteger(r.to) && pos <= r.from && r.from <= r.to && r.to <= fts.length; + if (valid) pos = r.to; + return valid; + }; + const entries = (items, depth) => + Array.isArray(items) && (items.length === 0 || depth <= MAX_LIST_DEPTH) && items.every((e) => !!e && range(e.content) && entries(e.items, depth + 1)); + const rows = (rs) => Array.isArray(rs) && rs.every((row) => Array.isArray(row) && row.every(range)); + return Array.isArray(blocks) && blocks.every((b) => { + switch (b && b.type) { + case 'paragraph': + case 'heading': + case 'codeBlock': + case 'quote': + return range(b.content); + case 'list': + return entries(b.items, 1); + case 'table': + return Array.isArray(b.columns) && rows([b.header]) && rows(b.rows); + case 'rule': + return range(b.mark); + default: + return false; + } + }); +} + +function renderBlocks(fts, blocks) { + let html = ''; + let pos = 0; + const segments = (r) => renderSegments(fts.slice(r.from, r.to), true); + const isParagraphBreak = (ft) => ft.format && ft.format.type === 'markup' && ft.format.token && ft.format.token.type === 'paragraphBreak'; + const gap = (to) => { + if (html && fts.slice(pos, to).some(isParagraphBreak)) html += '

'; + pos = Math.max(pos, to); + }; + const block = (r, open, close) => { + gap(r.from); + html += open + segments(r) + close; + pos = r.to; + }; + const entry = (e, level) => { + html += `
${escapeHtml(String(e.number ?? '•'))}${segments(e.content)}
`; + pos = e.content.to; + e.items.forEach((item) => entry(item, level + 1)); + }; + const table = (b) => { + const rows = [b.header].concat(b.rows); + const width = b.columns.length * rows.length <= MAX_TABLE_CELLS ? b.columns.length : 0; + const align = (c) => (b.columns[c] === 'center' || b.columns[c] === 'right' ? b.columns[c] : 'left'); + const cell = (tag, c, r) => `<${tag} style="text-align: ${align(c)};">${r ? segments(r) : ''}`; + const row = (cells, tag) => + '
' + row(b.header, 'th') + b.rows.map((r) => row(r, 'td')).join('') + '
'; + const last = rows[rows.length - 1]; + pos = last.length > 0 ? last[last.length - 1].to : pos; + }; + for (const b of blocks) { + switch (b.type) { + case 'paragraph': + block(b.content, '
', '
'); + break; + case 'heading': + block(b.content, `
`, '
'); + break; + case 'codeBlock': + block(b.content, '
', '
' + COPY_BUTTON + '
'); + break; + case 'quote': + block(b.content, '
', '
'); + break; + case 'list': + if (b.items.length > 0) gap(b.items[0].content.from); + b.items.forEach((item) => entry(item, 0)); + break; + case 'table': + table(b); + break; + case 'rule': + block(b.mark, '
', ''); + break; + } + } + return html; +} + +function markupHtml(token) { + switch (token && token.type) { + case 'softBreak': + return ' '; + case 'lineBreak': + case 'hardBreak': + return '
'; + default: + return ''; + } +} + +function renderMarkdown(fts, blocks) { + return validBlocks(fts, blocks) ? renderBlocks(fts, blocks) : renderSegments(fts, false); +} + +function renderSegments(fts, withBlocks) { let html = ''; for (const ft of fts) { const { format, text } = ft; @@ -65,7 +215,7 @@ function renderMarkdown(fts) { try { switch (format.type) { case 'bold': - html += `${escapeHtml(text)}`; + html += withBlocks && format.singleStar ? `${escapeHtml(text)}` : `${escapeHtml(text)}`; break; case 'italic': html += `${escapeHtml(text)}`; @@ -113,6 +263,11 @@ function renderMarkdown(fts) { case 'phone': html += `${escapeHtml(text)}`; break; + case 'escape': + break; + case 'markup': + html += withBlocks ? markupHtml(format.token) : escapeHtml(text); + break; case 'unknown': html += escapeHtml(text); break;