Make rich messages editable via InstantPage↔markdown round-trip

Reconstruct markdown source from a stored InstantPage to populate the edit
field, then re-classify on save (the inverse of the send path). Adds the
InstantPageToMarkdown converter, edit-field population and save-time
re-classification in ChatControllerLoadDisplayNode, and a shared InstantPage
previewText surfaced through MessageContentKind for reply/pinned/forward
previews. Documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-26 23:10:48 +02:00
parent e1b48665a8
commit a9f8b0d067
6 changed files with 483 additions and 181 deletions

View file

@ -0,0 +1,249 @@
import Foundation
import TelegramCore
/// Reconstructs a markdown source string from an `InstantPage`.
///
/// This is the inverse of the markdown`InstantPage` conversion used when
/// sending rich messages. It is best-effort and never fails: media blocks
/// (images/videos/audio/embeds/maps) are skipped, and any block or inline node
/// without a CommonMark representation falls back to its plain text.
///
/// Inline formatting is emitted as CommonMark (`**bold**`, `*italic*`,
/// `` `code` ``, `~~strike~~`, `[text](url)`) because the send-time classifier
/// re-parses the text through the rich (Apple CommonMark) path, not the entity
/// regex.
public func markdownStringFromInstantPage(_ instantPage: InstantPage) -> String {
return markdownString(from: instantPage.blocks)
}
private func markdownString(from blocks: [InstantPageBlock]) -> String {
var pieces: [String] = []
for block in blocks {
if let piece = markdownString(from: block) {
pieces.append(piece)
}
}
return pieces.joined(separator: "\n\n")
}
private func markdownString(from block: InstantPageBlock) -> String? {
switch block {
case let .heading(text, level):
let hashes = String(repeating: "#", count: max(1, min(6, Int(level))))
return "\(hashes) \(markdownInline(from: text))"
case let .title(text):
return "# \(markdownInline(from: text))"
case let .paragraph(text):
let rendered = markdownInline(from: text)
return rendered.isEmpty ? nil : escapeLeadingBlockMarker(rendered)
case let .preformatted(text, language):
let language = language ?? ""
return "```\(language)\n\(markdownInline(from: text))\n```"
case let .blockQuote(text, _):
return markdownBlockQuote(text)
case let .pullQuote(text, _):
return markdownBlockQuote(text)
case let .list(items, ordered):
return markdownList(items: items, ordered: ordered, indent: 0)
case let .table(_, rows, _, _):
return markdownTable(rows: rows)
case .divider:
return "---"
case let .formula(latex):
return "$\(latex)$"
case .anchor:
// Chat send already strips generated heading anchors; drop them here too.
return nil
case let .subtitle(text), let .header(text), let .subheader(text), let .footer(text), let .kicker(text):
let rendered = markdownInline(from: text)
return rendered.isEmpty ? nil : escapeLeadingBlockMarker(rendered)
default:
// Media and other structural blocks are skipped (out of scope).
return nil
}
}
private func markdownBlockQuote(_ text: RichText) -> String {
let body = markdownInline(from: text)
let lines = body.split(separator: "\n", omittingEmptySubsequences: false)
return lines.map { "> \($0)" }.joined(separator: "\n")
}
private func markdownInline(from richText: RichText) -> String {
switch richText {
case .empty:
return ""
case let .plain(string):
return escapeMarkdown(string)
case let .bold(text):
return "**\(markdownInline(from: text))**"
case let .italic(text):
return "*\(markdownInline(from: text))*"
case let .fixed(text):
return "`\(markdownInline(from: text))`"
case let .strikethrough(text):
return "~~\(markdownInline(from: text))~~"
case let .url(text, url, _):
return "[\(markdownInline(from: text))](\(url))"
case let .email(text, email):
return "[\(markdownInline(from: text))](mailto:\(email))"
case let .phone(text, phone):
return "[\(markdownInline(from: text))](tel:\(phone))"
case let .concat(parts):
return parts.map { markdownInline(from: $0) }.joined()
case let .anchor(text, _):
return markdownInline(from: text)
case let .formula(latex):
return "$\(latex)$"
// No CommonMark equivalent; emit inner text. These cannot arise from a
// markdown-composed message (Apple's markdown parser never produces them),
// so this is purely defensive.
case let .underline(text):
return markdownInline(from: text)
case let .marked(text):
return markdownInline(from: text)
case let .superscript(text):
return markdownInline(from: text)
case let .`subscript`(text):
return markdownInline(from: text)
default:
// .image, .textCustomEmoji, and the entity cases (.textMention,
// .textHashtag, ): fall back to plain text.
return escapeMarkdown(richText.plainText)
}
}
/// Backslash-escapes inline markdown-significant characters so literal text
/// does not re-parse as formatting. Pragmatic set, not full CommonMark.
private func escapeMarkdown(_ 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
}
/// Escapes a line-leading character that would otherwise be read as a block
/// marker (ATX heading, blockquote, or list item) when the text starts a line.
private func escapeLeadingBlockMarker(_ string: String) -> String {
guard let first = string.first else {
return string
}
if first == "#" || first == ">" || first == "-" || first == "+" {
return "\\" + string
}
// Ordered-list marker: one or more digits followed by '.' or ')'.
if first.isNumber {
var index = string.startIndex
while index < string.endIndex, string[index].isNumber {
index = string.index(after: index)
}
if index < string.endIndex, string[index] == "." || string[index] == ")" {
let prefix = string[string.startIndex ..< index]
let delimiter = string[index]
let rest = string[string.index(after: index)...]
return "\(prefix)\\\(delimiter)\(rest)"
}
}
return string
}
/// Collapses newlines for a single-line GFM table cell. The `|` character is
/// already escaped by `escapeMarkdown` for `.plain` runs.
private func escapeTableCell(_ string: String) -> String {
return string.replacingOccurrences(of: "\n", with: " ")
}
private func markdownList(items: [InstantPageListItem], ordered: Bool, indent: Int) -> String {
let indentString = String(repeating: " ", count: indent * 2)
var lines: [String] = []
var index = 1
for item in items {
// The stored per-item marker string (`InstantPageListItem`'s `String?`) is
// intentionally ignored: ordered markers are regenerated from the running
// index (CommonMark renumbers anyway) and the unordered marker is fixed.
let marker = ordered ? "\(index). " : "- "
switch item {
case let .text(text, _):
lines.append("\(indentString)\(marker)\(markdownInline(from: text))")
case let .blocks(blocks, _):
var remainder = blocks
var markerLineText = ""
if case let .paragraph(text)? = remainder.first {
markerLineText = markdownInline(from: text)
remainder = Array(remainder.dropFirst())
}
lines.append("\(indentString)\(marker)\(markerLineText)")
let childIndentString = String(repeating: " ", count: (indent + 1) * 2)
for block in remainder {
if case let .list(nestedItems, nestedOrdered) = block {
lines.append(markdownList(items: nestedItems, ordered: nestedOrdered, indent: indent + 1))
} else if let rendered = markdownString(from: block) {
for line in rendered.split(separator: "\n", omittingEmptySubsequences: false) {
lines.append("\(childIndentString)\(line)")
}
}
}
case .unknown:
break
}
index += 1
}
return lines.joined(separator: "\n")
}
private func markdownTable(rows: [InstantPageTableRow]) -> String? {
guard !rows.isEmpty else {
return nil
}
let columnCount = rows.map { $0.cells.count }.max() ?? 0
guard columnCount > 0 else {
return nil
}
func renderRow(_ row: InstantPageTableRow) -> String {
var cellStrings: [String] = []
for columnIndex in 0 ..< columnCount {
if columnIndex < row.cells.count, let text = row.cells[columnIndex].text {
cellStrings.append(escapeTableCell(markdownInline(from: text)))
} else {
cellStrings.append("")
}
}
return "| " + cellStrings.joined(separator: " | ") + " |"
}
var lines: [String] = []
lines.append(renderRow(rows[0]))
var separators: [String] = []
for columnIndex in 0 ..< columnCount {
let alignment: TableHorizontalAlignment
if columnIndex < rows[0].cells.count {
alignment = rows[0].cells[columnIndex].alignment
} else {
alignment = .left
}
switch alignment {
case .left:
separators.append("---")
case .center:
separators.append(":---:")
case .right:
separators.append("---:")
}
}
lines.append("| " + separators.joined(separator: " | ") + " |")
for row in rows.dropFirst() {
lines.append(renderRow(row))
}
return lines.joined(separator: "\n")
}

View file

@ -77,181 +77,6 @@ private func paidContentGroupType(paidContent: TelegramMediaPaidContent) -> Mess
return currentType
}
extension RichText {
func previewText() -> String {
switch self {
case .empty:
return ""
case let .plain(value):
return value
case let .bold(value):
return value.previewText()
case let .italic(value):
return value.previewText()
case let .underline(value):
return value.previewText()
case let .strikethrough(value):
return value.previewText()
case let .fixed(value):
return value.previewText()
case let .url(value, _, _):
return value.previewText()
case let .email(value, _):
return value.previewText()
case let .concat(values):
var result = ""
for value in values {
result.append(value.previewText())
}
return result
case let .`subscript`(value):
return value.previewText()
case let .superscript(value):
return value.previewText()
case let .marked(value):
return value.previewText()
case let .phone(value, _):
return value.previewText()
case .image:
//TODO:localize
return "Photo"
case let .anchor(value, _):
return value.previewText()
case .formula:
//TODO:localize
return "Fx"
case let .textCustomEmoji(_, alt):
return alt
case let .textAutoEmail(value), let .textAutoPhone(value), let .textAutoUrl(value), let .textBankCard(value), let .textBotCommand(value), let .textCashtag(value), let .textHashtag(value), let .textMention(value), let .textMentionName(value, _), let .textSpoiler(value):
return value.previewText()
}
}
}
extension InstantPageListItem {
func previewText() -> String {
switch self {
case .unknown:
return ""
case let .text(text, num):
if let num, !num.isEmpty {
return "\(num). \(text.previewText())"
} else {
return text.previewText()
}
case let .blocks(blocks, num):
var blocksText = ""
for block in blocks {
if !blocksText.isEmpty {
blocksText.append("\n")
}
blocksText.append(block.previewText())
}
if let num {
return "\(num). \(blocksText)"
} else {
return blocksText
}
}
}
}
extension InstantPageBlock {
func previewText() -> String {
switch self {
case .unsupported:
return ""
case let .title(text):
return text.previewText()
case let .subtitle(text):
return text.previewText()
case let .authorDate(author, _):
return author.previewText()
case let .header(text):
return text.previewText()
case let .subheader(text):
return text.previewText()
case let .heading(text, _):
return text.previewText()
case .formula:
return "Fx"
case let .paragraph(text):
return text.previewText()
case let .preformatted(text, _):
return text.previewText()
case let .footer(text):
return text.previewText()
case .divider:
return "\n"
case .anchor:
return ""
case let .list(items, _):
var result = ""
for item in items {
if !result.isEmpty {
result.append("\n")
}
result.append(item.previewText())
}
return result
case let .blockQuote(text, caption):
return text.previewText() + caption.previewText()
case let .pullQuote(text, caption):
return text.previewText() + caption.previewText()
case .image(_, _, _, _):
//TODO:localize
return "Photo"
case .video(_, _, _, _):
//TODO:localize
return "Video"
case .audio:
//TODO:localize
return "Audio"
case .cover:
return ""
case .webEmbed:
return ""
case .postEmbed:
return ""
case .collage:
return ""
case .slideshow:
return ""
case .channelBanner:
return ""
case .kicker:
return ""
case .table:
//TODO:localize
return "Table"
case .details:
return ""
case .relatedArticles:
return ""
case .map:
//TODO:localize
return "Map"
}
}
}
extension InstantPage {
func previewText() -> String {
let maxLength: Int = 200
var result = ""
for block in self.blocks {
if !result.isEmpty {
result.append("\n")
}
result.append(block.previewText())
if result.count > maxLength {
break
}
}
return result
}
}
public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, contentSettings: ContentSettings, messages: [EngineMessage], chatPeer: EngineRenderedPeer, accountPeerId: EnginePeer.Id, enableMediaEmoji: Bool = true, isPeerGroup: Bool = false) -> (peer: EnginePeer?, hideAuthor: Bool, messageText: String, messageEntities: [MessageTextEntity], spoilers: [NSRange]?, customEmojiRanges: [(NSRange, ChatTextInputTextCustomEmojiAttribute)]?) {
let peer: EnginePeer?

View file

@ -0,0 +1,177 @@
import Foundation
import TelegramCore
extension RichText {
public func previewText() -> String {
switch self {
case .empty:
return ""
case let .plain(value):
return value
case let .bold(value):
return value.previewText()
case let .italic(value):
return value.previewText()
case let .underline(value):
return value.previewText()
case let .strikethrough(value):
return value.previewText()
case let .fixed(value):
return value.previewText()
case let .url(value, _, _):
return value.previewText()
case let .email(value, _):
return value.previewText()
case let .concat(values):
var result = ""
for value in values {
result.append(value.previewText())
}
return result
case let .`subscript`(value):
return value.previewText()
case let .superscript(value):
return value.previewText()
case let .marked(value):
return value.previewText()
case let .phone(value, _):
return value.previewText()
case .image:
//TODO:localize
return "Photo"
case let .anchor(value, _):
return value.previewText()
case .formula:
//TODO:localize
return "Fx"
case let .textCustomEmoji(_, alt):
return alt
case let .textAutoEmail(value), let .textAutoPhone(value), let .textAutoUrl(value), let .textBankCard(value), let .textBotCommand(value), let .textCashtag(value), let .textHashtag(value), let .textMention(value), let .textMentionName(value, _), let .textSpoiler(value):
return value.previewText()
}
}
}
extension InstantPageListItem {
public func previewText() -> String {
switch self {
case .unknown:
return ""
case let .text(text, num):
if let num, !num.isEmpty {
return "\(num). \(text.previewText())"
} else {
return text.previewText()
}
case let .blocks(blocks, num):
var blocksText = ""
for block in blocks {
if !blocksText.isEmpty {
blocksText.append("\n")
}
blocksText.append(block.previewText())
}
if let num {
return "\(num). \(blocksText)"
} else {
return blocksText
}
}
}
}
extension InstantPageBlock {
public func previewText() -> String {
switch self {
case .unsupported:
return ""
case let .title(text):
return text.previewText()
case let .subtitle(text):
return text.previewText()
case let .authorDate(author, _):
return author.previewText()
case let .header(text):
return text.previewText()
case let .subheader(text):
return text.previewText()
case let .heading(text, _):
return text.previewText()
case .formula:
return "Fx"
case let .paragraph(text):
return text.previewText()
case let .preformatted(text, _):
return text.previewText()
case let .footer(text):
return text.previewText()
case .divider:
return "\n"
case .anchor:
return ""
case let .list(items, _):
var result = ""
for item in items {
if !result.isEmpty {
result.append("\n")
}
result.append(item.previewText())
}
return result
case let .blockQuote(text, caption):
return text.previewText() + caption.previewText()
case let .pullQuote(text, caption):
return text.previewText() + caption.previewText()
case .image(_, _, _, _):
//TODO:localize
return "Photo"
case .video(_, _, _, _):
//TODO:localize
return "Video"
case .audio:
//TODO:localize
return "Audio"
case .cover:
return ""
case .webEmbed:
return ""
case .postEmbed:
return ""
case .collage:
return ""
case .slideshow:
return ""
case .channelBanner:
return ""
case .kicker:
return ""
case .table:
//TODO:localize
return "Table"
case .details:
return ""
case .relatedArticles:
return ""
case .map:
//TODO:localize
return "Map"
}
}
}
extension InstantPage {
public func previewText() -> String {
let maxLength: Int = 200
var result = ""
for block in self.blocks {
if !result.isEmpty {
result.append("\n")
}
result.append(block.previewText())
if result.count > maxLength {
break
}
}
return result
}
}

View file

@ -309,6 +309,11 @@ public func messageContentKind(contentSettings: ContentSettings, message: Engine
return kind
}
}
for attribute in message.attributes {
if let attribute = attribute as? RichTextMessageAttribute {
return .text(NSAttributedString(string: attribute.instantPage.previewText()))
}
}
return .text(messageTextWithAttributes(message: message))
}

View file

@ -15,6 +15,7 @@ import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import BrowserUI
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
@ -1779,7 +1780,12 @@ extension ChatControllerImpl {
}
}
let inputText = chatInputStateStringWithAppliedEntities(message.text, entities: entities)
let inputText: NSAttributedString
if let richTextAttribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute {
inputText = NSAttributedString(string: markdownStringFromInstantPage(richTextAttribute.instantPage))
} else {
inputText = chatInputStateStringWithAppliedEntities(message.text, entities: entities)
}
var disableUrlPreviews: [String] = []
if webpageUrl == nil {
disableUrlPreviews = detectUrls(inputText)
@ -2208,7 +2214,17 @@ extension ChatControllerImpl {
}
let text = trimChatInputText(convertMarkdownToAttributes(expandedInputStateAttributedString(editMessage.inputState.inputText)))
let rawEditText = expandedInputStateAttributedString(editMessage.inputState.inputText).string
var isSpecialChatContents = false
if case .customChatContents = strongSelf.presentationInterfaceState.subject {
isSpecialChatContents = true
}
var richTextAttribute: RichTextMessageAttribute?
if !isSpecialChatContents {
richTextAttribute = richMarkdownAttributeIfNeeded(context: strongSelf.context, text: rawEditText)
}
let entities = generateTextEntities(text.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(text))
var entitiesAttribute: TextEntitiesMessageAttribute?
if !entities.isEmpty {
@ -2290,9 +2306,17 @@ extension ChatControllerImpl {
if let currentMessage = currentMessage {
let currentEntities = currentMessage.textEntitiesAttribute?.entities ?? []
let currentWebpagePreviewAttribute = currentMessage.webpagePreviewAttribute ?? WebpagePreviewMessageAttribute(leadingPreview: false, forceLargeMedia: nil, isManuallyAdded: true, isSafe: false)
if currentMessage.text != text.string || currentEntities != entities || updatingMedia || webpagePreviewAttribute != currentWebpagePreviewAttribute || disableUrlPreview {
strongSelf.context.account.pendingUpdateMessageManager.add(messageId: editMessage.messageId, text: text.string, media: media, entities: entitiesAttribute, richText: nil, inlineStickers: inlineStickers, webpagePreviewAttribute: webpagePreviewAttribute, invertMediaAttribute: invertedMediaAttribute, disableUrlPreview: disableUrlPreview)
let currentRichText = currentMessage.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute
if let richTextAttribute = richTextAttribute {
// Rich edit: empty text, no entities, carry the rich attribute.
if currentRichText != richTextAttribute || !currentMessage.text.isEmpty || updatingMedia {
strongSelf.context.account.pendingUpdateMessageManager.add(messageId: editMessage.messageId, text: "", media: media, entities: nil, richText: richTextAttribute, inlineStickers: inlineStickers, webpagePreviewAttribute: webpagePreviewAttribute, invertMediaAttribute: invertedMediaAttribute, disableUrlPreview: disableUrlPreview)
}
} else {
if currentMessage.text != text.string || currentEntities != entities || currentRichText != nil || updatingMedia || webpagePreviewAttribute != currentWebpagePreviewAttribute || disableUrlPreview {
strongSelf.context.account.pendingUpdateMessageManager.add(messageId: editMessage.messageId, text: text.string, media: media, entities: entitiesAttribute, richText: nil, inlineStickers: inlineStickers, webpagePreviewAttribute: webpagePreviewAttribute, invertMediaAttribute: invertedMediaAttribute, disableUrlPreview: disableUrlPreview)
}
}
}