From 471d11df169cdd971ef37dd6a116319f137a668b Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 29 May 2026 13:52:47 +0200 Subject: [PATCH] Copy rich messages as markdown (whole message + partial selection) Rich messages (RichTextMessageAttribute, text == "") are copyable as markdown two ways: the context-menu Copy action copies the whole message, and a text selection inside the rich-data bubble copies just the selected range. Both reconstruct markdown mirroring the edit round-trip (markdownStringFromInstantPage). Implements the full RichText entity case set (mention/hashtag/cashtag/bot-command/bank-card/auto url/email/phone) with tap interaction, the InstantPage -> markdown inverse converter and edit round-trip, markdown-context stamping during V2 layout (InstantPageMarkdownBlockContext: heading level, list/code/table/quote depth), partial-selection markdown emission (InstantPageMultiTextAdapter.markdownForRange), and numerous converter edge-case fixes (tables, links, fenced code, blockquote line coalescing, compact nested >> markers). CLAUDE.md documents the feature; the spec/plan scratch docs generated during development are not committed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 25 ++ .../Sources/InstantPageToMarkdown.swift | 5 +- .../Sources/InstantPageMultiTextAdapter.swift | 231 ++++++++++++++++++ .../Sources/InstantPageTextItem.swift | 26 +- .../Sources/InstantPageV2Layout.swift | 73 +++++- ...ChatMessageRichDataBubbleContentNode.swift | 10 + .../ChatInterfaceStateContextMenus.swift | 18 +- 7 files changed, 383 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c0a8abfcd8..1813186004 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,31 @@ Rich messages (`RichTextMessageAttribute`, `text == ""`) are made editable by re - **Known limitation:** a rich→plain edit that leaves only inline-formatted text loses `*italic*` (the entity path recognizes only `__…__`). Rare edge; the rich round-trip contract holds. - **`previewText()` lives in TelegramStringFormatting, not TextFormat/TelegramCore.** It will gain a `strings: PresentationStrings` param (to localize the `"Photo"`/`"Video"`/`"Table"` placeholders), so it must sit in a UI-string module — `messageContentKind`/`descriptionStringForMessage` (same module) already take `strings:`. Teaching `messageContentKind` about rich cascades the preview to the edit accessory panel, reply/pinned panels, and forward preview in one place (those surfaces need no individual change). +## Copying rich messages as markdown (whole message + partial selection) + +Rich messages (`RichTextMessageAttribute`, `text == ""`) are copyable as markdown two ways: the context-menu **Copy** action copies the whole message; a **text selection** inside the rich-data bubble copies just the selected range. Both reconstruct markdown that mirrors the edit round-trip (`markdownStringFromInstantPage`). Always-on. + +### Where things live + +| File | Responsibility | +|---|---| +| `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` | Whole-message Copy. Computes `richMessageMarkdown` from the message's `RichTextMessageAttribute.instantPage` (after `let message = messages[0]`), opens the Copy gate with `richMessageMarkdown != nil`, and short-circuits `copyTextWithEntities` to `storeMessageTextInPasteboard(markdown, entities: nil)`. | +| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | `markdownStringFromInstantPage` — the block-tree → markdown converter (also used by the edit round-trip). Blocks joined by `\n\n`; nested blockquotes via recursive `> ` wrapping. | +| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` | `InstantPageMarkdownBlockContext` (`kind` + `quoteDepth`) and the `markdownContext: InstantPageMarkdownBlockContext?` field on `InstantPageTextItem`. | +| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `stampMarkdownContext`/`bumpQuoteDepth`; stamps `markdownContext` during layout (heading/title/code/list/blockQuote/`layoutQuoteText`/table-cell). | +| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | `markdownForRange(_ range: NSRange)` + the private attributed-substring→inline-markdown converter `inlineMarkdown(from:)`. | +| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/.../ChatMessageRichDataBubbleContentNode.swift` | Intercepts `.copy` in the `TextSelectionNode` `performAction` closure: `textSelectionNode.getSelection()` → `adapter.markdownForRange(range)` → stores as plain `NSAttributedString(string:)`. | + +### Non-obvious invariants + +- **The V2 layout discards block role.** A `.text` layout item from an `H2` heading is byte-identical to a body paragraph — heading level and the title category are dropped with no back-reference to the source `InstantPageBlock`. Precise structural markdown for a *selection* therefore requires stamping `markdownContext` at layout time (lists/code/tables/details are structurally recoverable; **heading level and `.title` are not**, so they MUST be stamped). Plain paragraphs stay `nil` (≡ plain). +- **`quoteDepth` is orthogonal to `kind`** so a heading/list/code line inside a blockquote round-trips (e.g. `> ## Title`). `bumpQuoteDepth` lifts a quote's children by 1; nested quotes accumulate. `layoutQuoteText` (single-paragraph blockquote fast path AND `.pullQuote`) bumps once — it is never reached by the multi-block recursion, so no double-count. +- **A blockquote is exploded into one text item per line.** `markdownForRange` must re-coalesce a run of consecutive `quoteDepth > 0` segments into ONE `\n`-joined block (each line prefixed at its own depth); otherwise every quote line becomes its own block separated by a blank line. Code/table/list runs are likewise coalesced (one fence; one pipe table; one tight list). +- **Both converters emit compact nested-quote markers (`>>`, not `> >`).** Selection: `String(repeating: ">", count: depth) + " "`. Whole-message: when wrapping a line that already starts with `>`, prepend a bare `>`. Keep the two in sync. +- **Inline markdown is read from display attributes, not the RichText tree.** `inlineMarkdown` inspects the slice's `UIFont` (bold/italic/mono — font-based, no symbolic-trait flag for named fonts), `.strikethroughStyle`, and `TelegramTextAttributes.URL` (→ `InstantPageUrlItem.url`, angle-bracketed if it contains `(`/`)`/space). Custom-emoji placeholders are dropped (no `alt` on the display attribute). +- **`.copy` stores plain text.** Passing `NSAttributedString(string: markdown)` through the existing `performTextSelectionAction(.copy)` path (`storeAttributedTextInPasteboard`) generates no entities, so the literal `**`/`#`/`>`/`|` survive. The whole-message Copy uses `storeMessageTextInPasteboard(_, entities: nil)` directly. +- **Fidelity caveats (intentional):** custom emoji omitted; ordered list + checkbox loses the ordinal (`-` wins); a partial table selection emits touched cells as rows (no forced header `---` separator); block prefixes apply to the whole touched line on a mid-line selection (correct markdown). + ## Formulas trigger rich messages (strict math detection) `$…$`/`$$…$$` (and `\(…\)`/`\[…\]`) math triggers a rich message, gated by a diff --git a/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift index e08a2edc94..09f48bc391 100644 --- a/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift +++ b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift @@ -76,7 +76,10 @@ private func markdownBlockQuoteBlocks(_ blocks: [InstantPageBlock]) -> String { continue } for line in body.split(separator: "\n", omittingEmptySubsequences: false) { - lines.append("> \(String(line))") + // Stack nested-quote markers without internal spaces (`>>` not `> >`): + // a line already starting with `>` (a nested quote) gets a bare `>`. + let text = String(line) + lines.append(text.hasPrefix(">") ? ">\(text)" : "> \(text)") } } return lines.joined(separator: "\n") diff --git a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift index b4de3e9633..92145a6232 100644 --- a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift +++ b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift @@ -3,6 +3,7 @@ import UIKit import AsyncDisplayKit import Display import TelegramCore +import TextFormat public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { public struct Entry { @@ -110,4 +111,234 @@ public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol } return (allRects, start, end) } + + public func markdownForRange(_ range: NSRange) -> String { + struct Segment { + let context: InstantPageMarkdownBlockContext? + let inline: String + } + + var segments: [Segment] = [] + for entry in self.entries { + let entryRange = NSRange(location: entry.charOffset, length: entry.item.attributedString.length) + let intersection = NSIntersectionRange(range, entryRange) + if intersection.length == 0 { + continue + } + let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length) + let slice = entry.item.attributedString.attributedSubstring(from: localRange) + let inline = inlineMarkdown(from: slice) + if inline.isEmpty { + continue + } + segments.append(Segment(context: entry.item.markdownContext, inline: inline)) + } + if segments.isEmpty { + return "" + } + + func quotePrefixed(_ text: String, depth: Int) -> String { + guard depth > 0 else { return text } + let q = String(repeating: ">", count: depth) + " " + return text.split(separator: "\n", omittingEmptySubsequences: false).map { q + String($0) }.joined(separator: "\n") + } + + func taskMarker(_ c: Bool?) -> String { + switch c { + case .some(false): return "[ ] " + case .some(true): return "[x] " + case .none: return "" + } + } + + // Renders one segment's block content WITHOUT the quote prefix and without + // cross-segment coalescing. Used for segments inside a quoted run, where each + // line carries its own depth and the whole quote is one `\n`-joined block. + func renderContent(_ seg: Segment) -> String { + switch seg.context?.kind { + case let .code(language): + return "```\(language ?? "")\n\(seg.inline)\n```" + case .tableCell: + return "| " + escapeSelectionTableCell(seg.inline) + " |" + case let .listItem(_, marker, checked): + return "\(marker) \(taskMarker(checked))" + seg.inline.replacingOccurrences(of: "\n", with: " ") + case let .heading(level): + let hashes = String(repeating: "#", count: max(1, min(6, level))) + return "\(hashes) \(seg.inline)" + case .title: + return "# \(seg.inline)" + case .paragraph, .none: + return seg.inline + } + } + + var groups: [String] = [] + var index = 0 + while index < segments.count { + let seg = segments[index] + let depth = seg.context?.quoteDepth ?? 0 + + // A blockquote is exploded by the layout into one text item per child line, + // each stamped with quoteDepth > 0. Re-coalesce a run of consecutive quoted + // segments into a single block whose lines are joined by `\n` (each carrying + // its own `> ` depth), matching the whole-message converter — otherwise every + // quote line would become its own block and be separated by a blank line. + if depth > 0 { + var lines: [String] = [] + var j = index + while j < segments.count, let d = segments[j].context?.quoteDepth, d > 0 { + lines.append(quotePrefixed(renderContent(segments[j]), depth: d)) + j += 1 + } + groups.append(lines.joined(separator: "\n")) + index = j + continue + } + + switch seg.context?.kind { + case let .code(language): + var body = [seg.inline] + var j = index + 1 + while j < segments.count, + case let .code(lang2)? = segments[j].context?.kind, + lang2 == language, + (segments[j].context?.quoteDepth ?? 0) == depth { + body.append(segments[j].inline) + j += 1 + } + let fence = "```\(language ?? "")\n" + body.joined(separator: "\n") + "\n```" + groups.append(quotePrefixed(fence, depth: depth)) + index = j + case let .tableCell(row, _, _): + var rows: [[String]] = [[escapeSelectionTableCell(seg.inline)]] + var currentRow = row + var j = index + 1 + while j < segments.count, + case let .tableCell(row2, _, _)? = segments[j].context?.kind, + (segments[j].context?.quoteDepth ?? 0) == depth { + if row2 != currentRow { + rows.append([]) + currentRow = row2 + } + rows[rows.count - 1].append(escapeSelectionTableCell(segments[j].inline)) + j += 1 + } + let tableBlock = rows.map { "| " + $0.joined(separator: " | ") + " |" }.joined(separator: "\n") + groups.append(quotePrefixed(tableBlock, depth: depth)) + index = j + case let .listItem(_, marker, checked): + let firstLine = "\(marker) \(taskMarker(checked))" + seg.inline.replacingOccurrences(of: "\n", with: " ") + var lines = [firstLine] + var j = index + 1 + while j < segments.count, case let .listItem(_, marker2, checked2)? = segments[j].context?.kind, (segments[j].context?.quoteDepth ?? 0) == depth { + lines.append("\(marker2) \(taskMarker(checked2))" + segments[j].inline.replacingOccurrences(of: "\n", with: " ")) + j += 1 + } + groups.append(quotePrefixed(lines.joined(separator: "\n"), depth: depth)) + index = j + case let .heading(level): + let hashes = String(repeating: "#", count: max(1, min(6, level))) + groups.append(quotePrefixed("\(hashes) \(seg.inline)", depth: depth)) + index += 1 + case .title: + groups.append(quotePrefixed("# \(seg.inline)", depth: depth)) + index += 1 + case .paragraph, .none: + groups.append(quotePrefixed(seg.inline, depth: depth)) + index += 1 + } + } + + return groups.joined(separator: "\n\n") + } +} + +private func escapeSelectionMarkdown(_ string: String) -> String { + var result = "" + result.reserveCapacity(string.count) + for character in string { + switch character { + case "\\", "*", "_", "`", "[", "]", "~", "|": + result.append("\\") + result.append(character) + default: + result.append(character) + } + } + return result +} + +private func escapeSelectionTableCell(_ string: String) -> String { + return string.replacingOccurrences(of: "\n", with: " ") +} + +/// Converts a styled slice of an InstantPage text item into inline markdown, +/// reading the same attributes the renderer wrote (font-based bold/italic/mono, +/// strikethrough style, the TelegramTextAttributes.URL link item, custom emoji). +private func inlineMarkdown(from slice: NSAttributedString) -> String { + let fullRange = NSRange(location: 0, length: slice.length) + var result = "" + + slice.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in + let substring = (slice.string as NSString).substring(with: range) + + // Custom emoji: single-space placeholder with no alt available — drop it. + if attributes[ChatTextInputAttributes.customEmoji] != nil { + return + } + + var bold = false + var italic = false + var mono = false + if let font = attributes[.font] as? UIFont { + let name = font.fontName.lowercased() + if name.hasPrefix(".sfui") || name.hasPrefix(".applesystemui") { + let traits = font.fontDescriptor.symbolicTraits + if traits.contains(.traitMonoSpace) { + mono = true + } else { + bold = traits.contains(.traitBold) + italic = traits.contains(.traitItalic) + } + } else if name.contains("menlo") || name.contains("courier") || name.contains("sfmono") { + mono = true + } else { + if name.contains("bolditalic") { + bold = true; italic = true + } else if name.contains("bold") { + bold = true + } else if name.contains("italic") { + italic = true + } + } + } + let strike = attributes[.strikethroughStyle] != nil + + var inner: String + if mono { + // Inline code takes no nested emphasis; emit the raw text in backticks. + inner = "`\(substring)`" + } else { + inner = escapeSelectionMarkdown(substring) + if strike { inner = "~~\(inner)~~" } + if bold && italic { + inner = "***\(inner)***" + } else if bold { + inner = "**\(inner)**" + } else if italic { + inner = "*\(inner)*" + } + } + + if let urlItem = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? InstantPageUrlItem { + let url = urlItem.url + let needsBrackets = url.contains("(") || url.contains(")") || url.contains(" ") + let destination = needsBrackets ? "<\(url)>" : url + inner = "[\(inner)](\(destination))" + } + + result += inner + } + + return result } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index bd0c974cdc..f3a32116cf 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -191,6 +191,29 @@ private func attachmentBoundsForRange(_ range: NSRange, line: InstantPageTextLin return alignedAttachmentFrame(localBounds, line: line, boundingWidth: boundingWidth, alignment: alignment) } +/// Block-level role of a text item, used to reconstruct markdown from a +/// selection. `kind` is the primary role; `quoteDepth` (0 = not quoted) is +/// orthogonal so a heading/list/code line inside a blockquote can be emitted +/// as e.g. `> ## Title`. +public struct InstantPageMarkdownBlockContext: Equatable { + public enum Kind: Equatable { + case paragraph + case heading(level: Int) // 1...6 + case title // InstantPageBlock.title → "# " + case listItem(ordered: Bool, marker: String, checked: Bool?) + case code(language: String?) + case tableCell(row: Int, column: Int, isHeader: Bool) + } + + public var kind: Kind + public var quoteDepth: Int + + public init(kind: Kind, quoteDepth: Int = 0) { + self.kind = kind + self.quoteDepth = quoteDepth + } +} + public final class InstantPageTextItem: InstantPageItem { public let attributedString: NSAttributedString public let lines: [InstantPageTextLine] @@ -203,7 +226,8 @@ public final class InstantPageTextItem: InstantPageItem { public let wantsNode: Bool = false public let separatesTiles: Bool = false public var selectable: Bool = true - + public var markdownContext: InstantPageMarkdownBlockContext? = nil + var containsRTL: Bool { return !self.rtlLineIndices.isEmpty } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 2b55936e95..a3eca4e24b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -540,6 +540,45 @@ private func layoutBlockSequence( return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices, media: context.media, webpage: context.webpage) } +// MARK: - Markdown block context stamping helpers + +private func stampMarkdownContext(_ items: [InstantPageV2LaidOutItem], kind: InstantPageMarkdownBlockContext.Kind) { + for item in items { + switch item { + case let .text(textItem): + if textItem.textItem.markdownContext == nil { + textItem.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind) + } + case let .codeBlock(block): + if block.textItem.markdownContext == nil { + block.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind) + } + default: + break + } + } +} + +/// Marks every text item produced by a blockquote's children as quoted (depth + 1), +/// preserving each child's own kind (heading/list/paragraph/…). +private func bumpQuoteDepth(_ items: [InstantPageV2LaidOutItem]) { + for item in items { + let target: InstantPageTextItem? + switch item { + case let .text(textItem): target = textItem.textItem + case let .codeBlock(block): target = block.textItem + default: target = nil + } + guard let target else { continue } + if var ctx = target.markdownContext { + ctx.quoteDepth += 1 + target.markdownContext = ctx + } else { + target.markdownContext = InstantPageMarkdownBlockContext(kind: .paragraph, quoteDepth: 1) + } + } +} + private func layoutBlock( _ block: InstantPageBlock, boundingWidth: CGFloat, @@ -555,8 +594,10 @@ private func layoutBlock( return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset, isCover: true, previousItems: previousItems, isLast: isLast, context: &context) case let .title(text): - return layoutSimpleText(text, category: .header, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) + let titleItems = layoutSimpleText(text, category: .header, boundingWidth: boundingWidth, + horizontalInset: horizontalInset, context: &context) + stampMarkdownContext(titleItems, kind: .title) + return titleItems case let .subtitle(text): return layoutSimpleText(text, category: .subheader, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) @@ -1238,6 +1279,7 @@ private func layoutTable( horizontalInset: 0.0, context: &context ) + stampMarkdownContext(cellLayout.items, kind: .tableCell(row: i, column: k, isHeader: cell.header)) subLayout = cellLayout subLayoutHeight = cellLayout.contentSize.height isEmptyRow = false @@ -1713,6 +1755,7 @@ private func layoutHeading( fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) + stampMarkdownContext(items, kind: .heading(level: max(1, min(6, Int(level))))) return items } @@ -1870,6 +1913,7 @@ private func layoutCodeBlock( computeRevealCharacterRects: context.computeRevealCharacterRects ) guard let textItem = textItem else { return [] } + textItem.markdownContext = InstantPageMarkdownBlockContext(kind: .code(language: language)) // Position text within the block's content area. // V1 line 342: x=17.0, y=backgroundInset (block-local). @@ -1977,6 +2021,10 @@ private func layoutBlockQuote( ) result.append(.blockQuoteBar(bar)) + // Caption items (appended above) are also bumped to quoteDepth 1 and will render with a + // `>` prefix. The whole-message markdown converter drops blockquote captions entirely, and + // markdown-sent quotes carry empty captions, so this is benign. + bumpQuoteDepth(result) return result } @@ -2080,6 +2128,13 @@ private func layoutQuoteText( result.append(.blockQuoteBar(bar)) } + // Tag this quote's produced text items at quote depth 1 so the markdown + // converter renders them with a `> ` prefix. Applies to BOTH block quotes + // (single-paragraph fast path) and pull quotes — the whole-message markdown + // converter renders both flavors as `> `. Nested quotes are lifted further + // by the outer multi-block path's own bumpQuoteDepth(result) call. + bumpQuoteDepth(result) + return result } @@ -2200,6 +2255,14 @@ private func layoutList( effectiveItem = .text(.plain(" "), num, checked) } + // Derive the markdown marker string and checked state from the original item. + let markdownMarker: String + switch markerInfo.kind { + case let .number(value): markdownMarker = value + default: markdownMarker = "-" + } + let markdownChecked: Bool? = item.checked + switch effectiveItem { case let .text(text, _, _): // Layout text content. @@ -2243,6 +2306,7 @@ private func layoutList( kind: markerInfo.kind, color: context.theme.textCategories.paragraph.color ))) + stampMarkdownContext(textLaidOutItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked)) result.append(contentsOf: textLaidOutItems) contentHeight += textSize.height @@ -2289,6 +2353,11 @@ private func layoutList( blockMaxY = max(blockMaxY, ti.frame.maxY) } + // Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading) + // already stamped by a child block's own layout. So a heading nested inside a + // .blocks list item keeps .heading, not .listItem — multi-block list items are + // a documented best-effort case for markdown reconstruction. + stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked)) result.append(contentsOf: translatedItems) contentHeight = blockMaxY previousBlock = subBlock diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index f2640d0146..d0ad7cb69c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -1259,6 +1259,16 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode guard let self, let item = self.item else { return } + if case .copy = action, + let range = self.textSelectionNode?.getSelection(), + range.length > 0, + let adapter = self.textSelectionAdapter { + let markdown = adapter.markdownForRange(range) + if !markdown.isEmpty { + item.controllerInteraction.performTextSelectionAction(item.message, true, NSAttributedString(string: markdown), nil, .copy) + return + } + } item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action) } ) diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 30de107397..4d6adbe777 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -28,6 +28,7 @@ import TranslateUI import DebugSettingsUI import ChatPresentationInterfaceState import Pasteboard +import BrowserUI import SettingsUI import TextNodeWithEntities import ChatControllerInteraction @@ -1254,6 +1255,13 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } let message = messages[0] + var richMessageMarkdown: String? + if let richTextAttribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute { + let markdown = markdownStringFromInstantPage(richTextAttribute.instantPage) + if !markdown.isEmpty { + richMessageMarkdown = markdown + } + } var isExpired = false var isImage = false for media in message.media { @@ -1266,7 +1274,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } let isCopyProtected = chatPresentationInterfaceState.copyProtectionEnabled || message.isCopyProtected() - if !messageText.isEmpty || (resourceAvailable && isImage) || diceEmoji != nil { + if !messageText.isEmpty || richMessageMarkdown != nil || (resourceAvailable && isImage) || diceEmoji != nil { if !isExpired { if !isPoll { if !isCopyProtected { @@ -1277,6 +1285,14 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState UIPasteboard.general.string = diceEmoji } else { let copyTextWithEntities = { + if let richMessageMarkdown { + storeMessageTextInPasteboard(richMessageMarkdown, entities: nil) + Queue.mainQueue().after(0.2, { + let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_MessageCopied) + controllerInteraction.displayUndo(content) + }) + return + } var messageText = message.text var messageEntities: [MessageTextEntity]? var restrictedText: String?