mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +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
|
|
@ -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