Custom emoji in markdown rich messages (send + round-trip)

Parse custom emoji into RichText.textCustomEmoji when sending markdown
rich messages, and round-trip them through edit, copy, and paste using a
shared tg://emoji?id=<fileId> markdown-link marker.

- Send: rewrite each customEmoji input attribute into a
  [<alt>](tg://emoji?id=<fileId>) marker before the CommonMark parse, then
  intercept the marker URL afterward to emit .textCustomEmoji. Only rich
  messages are affected; a custom emoji alone stays on the entity path.
- Reverse: InstantPageToMarkdown (whole-message copy + edit reconstruction)
  and InstantPageMultiTextAdapter (selection copy) emit the marker;
  edit-load and chat paste reattach it as a live customEmoji attribute.
- Marker helpers shared in TextFormat/CustomEmojiMarkdownMarker.swift.
- Rich sends now pass inlineStickers so recipients can fetch the files.

Follow-up to verify at runtime: recipient rendering goes out with
Api.InputRichMessage.documents: nil; if recipients see only the fallback
glyph, populate documents: in apiInputRichMessage().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-31 17:51:37 +02:00
parent 6408175725
commit 3ed8c926a8
8 changed files with 164 additions and 13 deletions

View file

@ -3,6 +3,7 @@ import UIKit
import TelegramCore
import AccountContext
import InstantPageUI
import TextFormat
private let markdownPresentationIntentAttribute = NSAttributedString.Key("NSPresentationIntent")
private let markdownInlinePresentationIntentAttribute = NSAttributedString.Key("NSInlinePresentationIntent")
@ -1137,11 +1138,41 @@ private func instantPageNeedsRichLayout(_ blocks: [InstantPageBlock]) -> Bool {
return blocks.contains { !blockIsEntityExpressible($0) }
}
// Rewrites each `ChatTextInputAttributes.customEmoji` run in the attributed
// input as a `[<alt>](tg://emoji?id=<fileId>)` markdown link, leaving all other
// text (and its markdown syntax) verbatim. With no custom emoji present this
// returns `attributedText.string` unchanged, so non-emoji messages are
// unaffected. The marker is intercepted post-parse in markdownInlineContent.
private func markdownSourceInjectingCustomEmojiMarkers(_ attributedText: NSAttributedString) -> String {
let nsString = attributedText.string as NSString
var result = ""
attributedText.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in
let substring = nsString.substring(with: range)
if let attribute = value as? ChatTextInputTextCustomEmojiAttribute {
// The link text must be non-empty: CommonMark drops `[](url)` (no
// run carries the link attribute), which would silently lose the
// emoji. Fall back to a space, matching the reattach helper.
let alt = substring.isEmpty ? " " : substring
result += "[\(escapeCustomEmojiMarkdownAlt(alt))](\(customEmojiMarkdownURL(fileId: attribute.fileId)))"
} else {
result += substring
}
}
return result
}
// Returns a RichTextMessageAttribute IFF the markdown in `text` produces an
// InstantPage block with no entity equivalent. Returns nil (-> send via the
// regular entity path) for plain text, pre-iOS-15, oversize markdown, or
// markdown that maps cleanly onto entities.
public func richMarkdownAttributeIfNeeded(context: AccountContext, text: String) -> RichTextMessageAttribute? {
public func richMarkdownAttributeIfNeeded(context: AccountContext, attributedText: NSAttributedString) -> RichTextMessageAttribute? {
// Custom emoji are rewritten to `[<alt>](tg://emoji?id=...)` link markers
// before classification + parse; the markers are intercepted back into
// .textCustomEmoji in markdownInlineContent. A link is entity-expressible,
// so an emoji-only message still classifies as not-rich (and falls through
// to the entity path, where its untouched attribute makes a .CustomEmoji
// entity) custom emoji alone never forces a rich message.
let text = markdownSourceInjectingCustomEmojiMarkers(attributedText)
guard markdownMightNeedRichLayout(text) else {
return nil
}
@ -1688,6 +1719,13 @@ private func markdownInlineContent(from attributedString: NSAttributedString, co
return
}
if let linkUrl = markdownLink(attributes: attributes, documentURL: context.documentURL),
let fileId = parseCustomEmojiFileId(fromMarkdownURL: linkUrl) {
// `text` is the parsed (already-unescaped) link display text = the alt.
fragments.append(.richText(.textCustomEmoji(fileId: fileId, alt: text)))
return
}
let segments = markdownInlineTextSegments(from: text, formulasByPlaceholder: context.formulasByPlaceholder)
for segment in segments {
let baseText: RichText

View file

@ -1,5 +1,6 @@
import Foundation
import TelegramCore
import TextFormat
/// Reconstructs a markdown source string from an `InstantPage`.
///
@ -125,9 +126,11 @@ private func markdownInline(from richText: RichText) -> String {
return markdownInline(from: text)
case let .`subscript`(text):
return markdownInline(from: text)
case let .textCustomEmoji(fileId, alt):
return "[\(escapeCustomEmojiMarkdownAlt(alt))](\(customEmojiMarkdownURL(fileId: fileId)))"
default:
// .image, .textCustomEmoji, and the entity cases (.textMention,
// .textHashtag, ): fall back to plain text.
// .image and the entity cases (.textMention, .textHashtag, ):
// fall back to plain text.
return escapeMarkdown(richText.plainText)
}
}

View file

@ -282,8 +282,13 @@ private func inlineMarkdown(from slice: NSAttributedString) -> String {
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 {
// Custom emoji: emit the shared marker carrying the fileId. The display
// placeholder may have no real alt (often a single space), so alt is
// best-effort; whole-message copy / edit reconstruction have the true alt.
if let emojiAttribute = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute {
// Non-empty link text required: CommonMark drops `[](url)` on re-parse.
let alt = substring.isEmpty ? " " : substring
result += "[\(escapeCustomEmojiMarkdownAlt(alt))](\(customEmojiMarkdownURL(fileId: emojiAttribute.fileId)))"
return
}

View file

@ -5301,7 +5301,20 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg
attributedString = chatInputStateStringFromRTF(data, type: NSAttributedString.DocumentType.rtfd)
}
}
// Rich-message markdown copied to the clipboard is plain text containing
// `[<alt>](tg://emoji?id=<fileId>)` emoji markers (no RTF/private type).
// Reattach those markers as live custom-emoji attributes so the field
// shows the animated emoji (matching the edit-load reconstruction). Only
// taken when a marker actually converted, so ordinary text pastes are
// unaffected and fall through to default paste.
if attributedString == nil, let plainText = pasteboard.string, plainText.contains("tg://emoji?id=") {
let reattached = chatInputTextWithReattachedCustomEmoji(plainText)
if reattached.string != plainText {
attributedString = reattached
}
}
if let attributedString = attributedString {
self.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
if let inputText = current.inputText.mutableCopy() as? NSMutableAttributedString {

View file

@ -1781,7 +1781,7 @@ extension ChatControllerImpl {
let inputText: NSAttributedString
if let richTextAttribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute {
inputText = NSAttributedString(string: markdownStringFromInstantPage(richTextAttribute.instantPage))
inputText = chatInputTextWithReattachedCustomEmoji(markdownStringFromInstantPage(richTextAttribute.instantPage))
} else {
inputText = chatInputStateStringWithAppliedEntities(message.text, entities: entities)
}
@ -2214,14 +2214,13 @@ 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)
richTextAttribute = richMarkdownAttributeIfNeeded(context: strongSelf.context, attributedText: expandedInputStateAttributedString(editMessage.inputState.inputText))
}
let entities = generateTextEntities(text.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(text))

View file

@ -215,8 +215,8 @@ func chatMessageDisplaySendMessageOptions(selfController: ChatControllerImpl, no
var richTextPreview: ChatSendMessageContextScreenRichTextPreview?
if case .customChatContents = selfController.presentationInterfaceState.subject {
} else if mediaPreview == nil,
let plainText = textInputView.attributedText?.string,
let attribute = richMarkdownAttributeIfNeeded(context: selfController.context, text: plainText) {
let attributedText = textInputView.attributedText,
let attribute = richMarkdownAttributeIfNeeded(context: selfController.context, attributedText: attributedText) {
richTextPreview = ChatSendMessageRichTextPreview(context: selfController.context, instantPage: attribute.instantPage)
}

View file

@ -4861,9 +4861,18 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
if case .customChatContents = self.chatPresentationInterfaceState.subject {
isSpecialChatContents = true
}
if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, text: effectiveInputText.string) {
if !isSpecialChatContents, let attribute = richMarkdownAttributeIfNeeded(context: self.context, attributedText: effectiveInputText) {
let attributes: [MessageAttribute] = [attribute]
messages.append(.message(text: "", attributes: attributes, inlineStickers: [:], mediaReference: nil, threadId: self.chatLocation.threadId, replyToMessageId: self.chatPresentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []))
var richBubbleUpEmojiOrStickersets: [ItemCollectionId] = []
for (_, packId) in bubbleUpEmojiOrStickersetsById {
if !richBubbleUpEmojiOrStickersets.contains(packId) {
richBubbleUpEmojiOrStickersets.append(packId)
}
}
if richBubbleUpEmojiOrStickersets.count > 1 {
richBubbleUpEmojiOrStickersets.removeAll()
}
messages.append(.message(text: "", attributes: attributes, inlineStickers: inlineStickers, mediaReference: nil, threadId: self.chatLocation.threadId, replyToMessageId: self.chatPresentationInterfaceState.interfaceState.replyMessageSubject?.subjectModel, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: richBubbleUpEmojiOrStickersets))
mediaReference = nil
} else {
for text in breakChatInputText(trimChatInputText(inputText)) {

View file

@ -0,0 +1,84 @@
import Foundation
/// The private markdown-link URL that carries a custom emoji's file id between
/// the send path and the rich-message renderer. Shared by the forward
/// (compose/send) path and the reverse (copy/edit) converters so the encode and
/// decode cannot drift. Format: a markdown link `[<alt>](tg://emoji?id=<fileId>)`.
public func customEmojiMarkdownURL(fileId: Int64) -> String {
return "tg://emoji?id=\(fileId)"
}
/// Backslash-escapes only the characters that would break a marker link's
/// display text (the `alt`): backslash, the link-text brackets, and newline.
/// Minimal by design the forward CommonMark parser unescapes these, so the
/// alt round-trips. Shared by every site that emits a `[<alt>](tg://emoji?id=)`
/// marker so the escaping cannot drift between encoders.
public func escapeCustomEmojiMarkdownAlt(_ string: String) -> String {
var result = ""
result.reserveCapacity(string.count)
for character in string {
switch character {
case "\\", "[", "]", "\n":
result.append("\\")
result.append(character)
default:
result.append(character)
}
}
return result
}
/// Parses the file id out of a `tg://emoji?id=<fileId>` marker URL.
/// Returns nil for any other URL (ordinary links flow through unchanged).
public func parseCustomEmojiFileId(fromMarkdownURL url: String) -> Int64? {
let prefix = "tg://emoji?id="
guard url.hasPrefix(prefix) else {
return nil
}
return Int64(url.dropFirst(prefix.count))
}
/// Regex matching an emitted marker link: `[<alt>](tg://emoji?id=<digits>)`.
/// `alt` is captured as group 1 (any run of non-`]` chars), the file id as group 2.
private let customEmojiMarkerRegex = try? NSRegularExpression(
pattern: "\\[([^\\]]*)\\]\\(tg://emoji\\?id=(-?\\d+)\\)",
options: []
)
/// Reverse of the forward normalization: takes reconstructed markdown source
/// (e.g. from `markdownStringFromInstantPage`) and returns an attributed string
/// where each `tg://emoji?id=` marker link has been turned back into a live
/// `ChatTextInputAttributes.customEmoji` run (the alt text carrying a
/// `ChatTextInputTextCustomEmojiAttribute`). Everything else stays verbatim
/// markdown text. Used to populate the edit compose field so it shows the
/// animated emoji; on re-save the forward path reads the attribute's fileId back.
///
/// `file` is left nil the renderer resolves the emoji lazily from `fileId`,
/// and the send path only needs the fileId. Known limitation: an alt containing
/// a literal `]` is not matched (emoji alts do not contain brackets).
public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString {
let result = NSMutableAttributedString(string: markdown)
guard let regex = customEmojiMarkerRegex else {
return result
}
let matches = regex.matches(in: markdown, options: [], range: NSRange(markdown.startIndex..., in: markdown))
// Replace from last match to first so the earlier NSRanges stay valid.
for match in matches.reversed() {
guard match.numberOfRanges == 3 else {
continue
}
guard let altRange = Range(match.range(at: 1), in: markdown),
let idRange = Range(match.range(at: 2), in: markdown),
let fileId = Int64(markdown[idRange]) else {
continue
}
let alt = String(markdown[altRange])
// The attribute must ride on at least one character; use a space if the
// alt was emitted empty (selection-copy with no alt available).
let displayText = alt.isEmpty ? " " : alt
let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil)
let replacement = NSAttributedString(string: displayText, attributes: [ChatTextInputAttributes.customEmoji: attribute])
result.replaceCharacters(in: match.range, with: replacement)
}
return result
}