mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
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:
parent
6408175725
commit
3ed8c926a8
8 changed files with 164 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue