From a9f8b0d0670ff965d1b26055e4ff393c538dd013 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 26 May 2026 23:10:48 +0200 Subject: [PATCH] =?UTF-8?q?Make=20rich=20messages=20editable=20via=20Insta?= =?UTF-8?q?ntPage=E2=86=94markdown=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 24 +- .../Sources/InstantPageToMarkdown.swift | 249 ++++++++++++++++++ .../Sources/Node/ChatListItemStrings.swift | 175 ------------ .../Sources/InstantPagePreviewText.swift | 177 +++++++++++++ .../Sources/MessageContentKind.swift | 5 + .../Chat/ChatControllerLoadDisplayNode.swift | 34 ++- 6 files changed, 483 insertions(+), 181 deletions(-) create mode 100644 submodules/BrowserUI/Sources/InstantPageToMarkdown.swift create mode 100644 submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift diff --git a/CLAUDE.md b/CLAUDE.md index d3f9f51694..a402293def 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,10 +145,32 @@ On message send, the app auto-decides: if the typed markdown maps onto the regul - **Approach A (parse-then-inspect):** the classifier reuses the real parser, so "what triggers rich" can't drift from "what the rich renderer shows." `markdownMightNeedRichLayout` is a cheap necessary-condition over-approximation — it may over-trigger a parse but must **never** false-negative. It detects `#`, list markers, dash-lines (`-{1,}`, which also catches setext-H2 underlines → heading blocks), `\n=` (setext H1), `|`, `![`. It deliberately does **not** detect `$`/`\(` (formulas don't trigger rich). - **Chat vs. document path = `file == nil` / `context.documentURL == nil`.** `inputRichTextAttributeFromText` passes `file: nil`; the document-attachment path passes a real file. Two chat-only behaviors key off this: (a) generated heading anchors are **skipped** (`markdownBlocksWithGeneratedAnchors` runs only for documents — anchors exist for intra-document `#slug` links and otherwise prepend a spurious invisible `.anchor` block per heading); (b) a level-1 `#` heading maps to `.heading(text:, level: 1)`, not `.title` (the document/article-title treatment). H2–H6 → `.heading(level: 2…6)` for both paths. This converter only ever emits `.title` (H1-doc) or `.heading` — never `.header`/`.subheader`. - **The classifier is fed the RAW `effectiveInputText.string`**, not the post-`convertMarkdownToAttributes` `inputText`, so inline `**bold**` survives into the rich render. The entity branch still uses the converted `inputText`. -- **Bypassed for `.customChatContents`** (business links / quick replies) via `isSpecialChatContents`. **Editing is out of scope** — the gate is only on the compose/send path; edits route earlier to `editMessage`. +- **Bypassed for `.customChatContents`** (business links / quick replies) via `isSpecialChatContents`. The compose/send gate lives here; **editing has its own symmetric re-classification** — see "Editing rich messages" below. - **Transmission:** `RichTextMessageAttribute` → `Api.InputRichMessage` via `messages.sendMessage(richMessage:)` (flag bit 23, `StandaloneSendMessage.swift`); recipients reconstruct it from the incoming `richMessage` field (`StoreMessage_Telegram.swift`). The rich branch sends `text: ""` + the attribute, nils `mediaReference` (no separate webpage preview), and bypasses 4096-char chunking. iOS < 15 / oversize markdown → `inputRichTextAttributeFromText` returns nil → entity path (which chunks). - **`debugRichText` experimental flag is now orphaned** — it previously gated this path and is no longer read anywhere, though `DebugController`/`ExperimentalUISettings` still define/persist it. Optional cleanup. +## Editing rich messages (InstantPage → markdown) + +Rich messages (`RichTextMessageAttribute`, `text == ""`) are made editable by reconstructing markdown source from the stored `InstantPage`, populating the editor with it, and re-classifying on save — the inverse of the send path above. Always-on (no flag). Images/videos are out of scope (skipped by the converter). + +### Where things live + +| File | Responsibility | +|---|---| +| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | `markdownStringFromInstantPage(_:)` — the inverse converter (block + inline + list + table + escaping). Pure, best-effort, never fails. | +| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | `setupEditMessage`: rich message → reconstruct markdown into the edit field. `editMessage` (save): re-classify the raw input, route rich-or-plain. | +| `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` | `previewText()` extensions (`RichText`/`InstantPage*`) — one-line plaintext previews. | +| `submodules/TelegramStringFormatting/Sources/MessageContentKind.swift` | `messageContentKind` returns `.text(instantPage.previewText())` for rich, cascading to all preview surfaces. | + +### Non-obvious invariants + +- **The converter emits CommonMark inline, NOT the entity-regex dialect.** `**bold**`, `*italic*`, `` `code` ``, `~~strike~~`, `[text](url)` — because re-send re-parses the text through the *rich* path (`richMarkdownAttributeIfNeeded` → `NSAttributedString(markdown:)`, Apple CommonMark), not `convertMarkdownToAttributes` (whose dialect is `__italic__`/`||spoiler||`). The two parsers disagree on `__`/`*`; the rich round-trip is the contract. +- **Re-classify every edit (edit ≡ send).** `editMessage` runs the same `richMarkdownAttributeIfNeeded` on the RAW input string. Rich → `pendingUpdateMessageManager.add(text: "", entities: nil, richText: attr, …)`; else the unchanged plain path. So normal→rich (add a table) and rich→plain (drop all triggers) both work. Bypassed for `.customChatContents`. +- **Change-detection compares the rich attribute.** The save guard adds `currentRichText != richTextAttribute` (rich branch — skips no-op rich edits) and `currentRichText != nil` (plain branch — so rich→plain still saves even when `text.string` looks unchanged). `RichTextMessageAttribute` is `Equatable` on `instantPage`. +- **The `text.length == 0` early-return guard is safe for rich.** `convertMarkdownToAttributes` only rewrites inline tokens, never strips `#`/`-`/`|`, so a rich message's markdown source stays non-empty and passes; the rich branch then sends `text: ""`. +- **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). + ## Postbox → TelegramEngine refactor (in progress) A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`. diff --git a/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift new file mode 100644 index 0000000000..4e3762c32c --- /dev/null +++ b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift @@ -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") +} diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index 049059d932..e385d285a3 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -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? diff --git a/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift b/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift new file mode 100644 index 0000000000..7dfaf15708 --- /dev/null +++ b/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift @@ -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 + } +} diff --git a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift index a198c881ed..046286b6c5 100644 --- a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift +++ b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift @@ -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)) } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 4da30fbab9..c60daed5df 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -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) + } } }