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:
parent
e1b48665a8
commit
a9f8b0d067
6 changed files with 483 additions and 181 deletions
24
CLAUDE.md
24
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), `|`, `` — 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`.
|
||||
|
|
|
|||
249
submodules/BrowserUI/Sources/InstantPageToMarkdown.swift
Normal file
249
submodules/BrowserUI/Sources/InstantPageToMarkdown.swift
Normal 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")
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue