diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index 2196c0c5ee..d956ac5395 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -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 `[](tg://emoji?id=)` 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 `[](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 diff --git a/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift index 93372599f7..e5884e806a 100644 --- a/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift +++ b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift @@ -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) } } diff --git a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift index 92145a6232..b394f154ea 100644 --- a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift +++ b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift @@ -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 } diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 3265d2f969..9d029e8080 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -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 + // `[](tg://emoji?id=)` 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 { diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 8ef7139d12..3be681aab8 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -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)) diff --git a/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift b/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift index c38fca6b58..2bca0dbd4e 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift @@ -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) } diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 3bc46becb8..06570b8ed4 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -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)) { diff --git a/submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift b/submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift new file mode 100644 index 0000000000..18b0800c30 --- /dev/null +++ b/submodules/TextFormat/Sources/CustomEmojiMarkdownMarker.swift @@ -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 `[](tg://emoji?id=)`. +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 `[](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=` 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: `[](tg://emoji?id=)`. +/// `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 +}