Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2026-06-02 12:46:22 +02:00
commit b15b6ebf50
297 changed files with 24616 additions and 340 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

@ -35,10 +35,12 @@ private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantP
stack.push(.linkColor(theme.linkColor))
stack.push(.linkMarkerColor(theme.linkHighlightColor))
switch attributes.font.style {
case .sans:
stack.push(.fontSerif(false))
case .serif:
stack.push(.fontSerif(true))
case .sans:
stack.push(.fontSerif(false))
case .serif:
stack.push(.fontSerif(true))
case .monospace:
stack.push(.fontFixed(true))
}
stack.push(.fontSize(attributes.font.size))
stack.push(.lineSpacingFactor(attributes.font.lineSpacingFactor))
@ -85,6 +87,16 @@ private func instantPageFont(style: InstantPageTextAttributes, bold: Bool = fals
} else {
return Font.regular(size)
}
case .monospace:
if bold && italic {
return Font.semiboldItalicMonospace(size)
} else if bold {
return Font.semiboldMonospace(size)
} else if italic {
return Font.italicMonospace(size)
} else {
return Font.monospace(size)
}
}
}

View file

@ -16,7 +16,7 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
return 0.0
case (.divider, _), (_, .divider):
if fitToWidth {
return 20.0
return 21.0
} else {
return 25.0
}
@ -54,18 +54,14 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
} else {
return 31.0
}
case (.preformatted, .paragraph):
return 19.0
case (.formula, .paragraph):
return 19.0
case (.paragraph, .paragraph):
if fitToWidth {
return 10.0
return 2.0
} else {
return 25.0
}
case (_, .paragraph):
return 20.0
case (.title, .formula), (.authorDate, .formula):
return 34.0
case (.header, .formula), (.subheader, .formula), (.heading, .formula):
@ -76,8 +72,6 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
}
case (.list, .formula):
return 31.0
case (.preformatted, .formula):
return 19.0
case (.paragraph, .formula):
return 19.0
case (_, .formula):
@ -86,8 +80,12 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
return 34.0
case (.header, .list), (.subheader, .list), (.heading, .list):
return 31.0
case (.preformatted, .list):
return 19.0
case (.preformatted, _), (_, .preformatted):
if fitToWidth {
return 12.0
} else {
return 19.0
}
case (.formula, .list):
if fitToWidth {
return 10.0
@ -100,12 +98,6 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
} else {
return 25.0
}
case (.paragraph, .preformatted):
return 19.0
case (.formula, .preformatted):
return 19.0
case (_, .preformatted):
return 20.0
case (_, .header), (_, .subheader), (_, .heading):
return 32.0
default:
@ -121,9 +113,11 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
case .topLevel:
switch lower {
case .heading:
return 13.0
default:
return 6.0
case .table:
return 10.0
default:
return 5.0
}
case .cell:
return 0.0
@ -139,6 +133,8 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
case .topLevel:
if case .relatedArticles = upper {
return 0.0
} else if case .thinking = upper {
return 2.0
} else {
if fitToWidth {
return 5.0

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

@ -307,7 +307,18 @@ public final class InstantPageV2View: UIView {
var validIds: [InlineStickerItemLayer.Key] = []
for view in self.itemViews {
guard let textView = view as? InstantPageV2TextView else { continue }
// Top-level `.text` items host their emoji directly. The thinking block hosts emoji on
// its shimmer-wrapped inner text view, which the page never sees as a top-level item
// so without this it is skipped and the emoji never get layers (invisible). Nested V2
// sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji.
let textView: InstantPageV2TextView
if let topLevelTextView = view as? InstantPageV2TextView {
textView = topLevelTextView
} else if let thinkingView = view as? InstantPageV2ThinkingView {
textView = thinkingView.textView
} else {
continue
}
let textItem = textView.item.textItem
let boundsWidth = textItem.frame.size.width
for line in textItem.lines {
@ -390,7 +401,17 @@ public final class InstantPageV2View: UIView {
var validKeys: Set<InlineImageKey> = []
for view in self.itemViews {
guard let textView = view as? InstantPageV2TextView else { continue }
// Same nesting as updateInlineEmoji: top-level `.text` items host their inline images
// directly; the thinking block hosts them on its shimmer-wrapped inner text view, which
// the page never sees as a top-level item. Nested V2 sub-layouts run their own pass.
let textView: InstantPageV2TextView
if let topLevelTextView = view as? InstantPageV2TextView {
textView = topLevelTextView
} else if let thinkingView = view as? InstantPageV2ThinkingView {
textView = thinkingView.textView
} else {
continue
}
let textItem = textView.item.textItem
let boundsWidth = textItem.frame.size.width
for line in textItem.lines {
@ -1859,7 +1880,7 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
var itemFrame: CGRect { return self.item.frame }
private let shimmerView: ShimmeringMaskView
private let textView: InstantPageV2TextView
let textView: InstantPageV2TextView // exposed so the parent V2 view can host its inline emoji
init(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
self.item = item
@ -1877,15 +1898,22 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// Parent positions children (see CLAUDE.md "View frame ownership"): the shimmer covers the
/// whole block; the inner text view sits at its block-local typographic frame (expanded by the
/// text view's clipping inset, matching `InstantPageV2TextView.init`).
/// Parent positions self at the item frame (the bare line box). The shimmer and its gradient
/// mask are sized to the text view's clipping-inset-EXPANDED frame and shifted to
/// `(-inset, -inset)`, so the mask doesn't crop the glyph overhang the inset reserves (tall
/// ascenders, descenders, the last line's underline) the symptom of sizing the mask to the
/// bare line box. The inner text view fills the shimmer; its `+inset` render translate lands the
/// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's
/// frame is inset-expanded (`actualFrame` / `InstantPageV2TextView.init`).
private func layoutContents() {
self.shimmerView.frame = CGRect(origin: .zero, size: self.item.frame.size)
self.textView.frame = self.item.textItem.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
let inset = v2TextViewClippingInset
let expandedSize = CGSize(width: self.item.frame.size.width + inset * 2.0,
height: self.item.frame.size.height + inset * 2.0)
self.shimmerView.frame = CGRect(x: -inset, y: -inset, width: expandedSize.width, height: expandedSize.height)
self.textView.frame = CGRect(origin: .zero, size: expandedSize)
self.shimmerView.update(
size: self.item.frame.size,
containerWidth: self.item.frame.size.width,
size: expandedSize,
containerWidth: expandedSize.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate

View file

@ -960,7 +960,11 @@ func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextSt
}
let attributes = styleStack.textAttributes()
let font = (attributes[NSAttributedString.Key.font] as? UIFont) ?? UIFont.systemFont(ofSize: 17.0)
let itemSize = font.pointSize * 24.0 / 17.0
// Size the inline emoji to the font's line height (A + D) plus a 4pt bump at the 17pt
// body font (scaled proportionally). Must match the V2 layout's emoji cell size
// (InstantPageV2Layout.swift). The run delegate still reports the font's own
// ascent/descent (below), so the line height is unchanged only the emoji width changes.
let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: font.descender, width: itemSize))
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { pointer in

View file

@ -7,6 +7,7 @@ import TelegramUIPreferences
public enum InstantPageFontStyle {
case sans
case serif
case monospace
}
public struct InstantPageFont {
@ -50,6 +51,7 @@ enum InstantPageTextCategoryType {
case credit
case table
case article
case codeBlock
}
public struct InstantPageTextCategories {
@ -61,8 +63,9 @@ public struct InstantPageTextCategories {
let credit: InstantPageTextAttributes
let table: InstantPageTextAttributes
let article: InstantPageTextAttributes
let codeBlock: InstantPageTextAttributes
public init(kicker: InstantPageTextAttributes, header: InstantPageTextAttributes, subheader: InstantPageTextAttributes, paragraph: InstantPageTextAttributes, caption: InstantPageTextAttributes, credit: InstantPageTextAttributes, table: InstantPageTextAttributes, article: InstantPageTextAttributes) {
public init(kicker: InstantPageTextAttributes, header: InstantPageTextAttributes, subheader: InstantPageTextAttributes, paragraph: InstantPageTextAttributes, caption: InstantPageTextAttributes, credit: InstantPageTextAttributes, table: InstantPageTextAttributes, article: InstantPageTextAttributes, codeBlock: InstantPageTextAttributes) {
self.kicker = kicker
self.header = header
self.subheader = subheader
@ -71,26 +74,29 @@ public struct InstantPageTextCategories {
self.credit = credit
self.table = table
self.article = article
self.codeBlock = codeBlock
}
func attributes(type: InstantPageTextCategoryType, link: Bool) -> InstantPageTextAttributes {
switch type {
case .kicker:
return self.kicker.withUnderline(link)
case .header:
return self.header.withUnderline(link)
case .subheader:
return self.subheader.withUnderline(link)
case .paragraph:
return self.paragraph.withUnderline(link)
case .caption:
return self.caption.withUnderline(link)
case .credit:
return self.credit.withUnderline(link)
case .table:
return self.table.withUnderline(link)
case .article:
return self.article.withUnderline(link)
case .kicker:
return self.kicker.withUnderline(link)
case .header:
return self.header.withUnderline(link)
case .subheader:
return self.subheader.withUnderline(link)
case .paragraph:
return self.paragraph.withUnderline(link)
case .caption:
return self.caption.withUnderline(link)
case .credit:
return self.credit.withUnderline(link)
case .table:
return self.table.withUnderline(link)
case .article:
return self.article.withUnderline(link)
case .codeBlock:
return self.codeBlock.withUnderline(link)
}
}
@ -103,7 +109,8 @@ public struct InstantPageTextCategories {
caption: self.caption.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif),
credit: self.credit.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif),
table: self.table.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif),
article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif)
article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif),
codeBlock: self.codeBlock.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif)
)
}
}
@ -214,7 +221,8 @@ private let lightTheme = InstantPageTheme(
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x79828b)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x79828b)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: .black),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: .black)
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: .black),
codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: .black)
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0xf5f8fc),
@ -247,7 +255,8 @@ private let sepiaTheme = InstantPageTheme(
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x927e6b)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x927e6b)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d))
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d)),
codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x4f321d))
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0xefe7d6),
@ -280,7 +289,8 @@ private let grayTheme = InstantPageTheme(
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xa0a0a0)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xa0a0a0)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece))
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece)),
codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xcecece))
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0x555556),
@ -313,7 +323,8 @@ private let darkTheme = InstantPageTheme(
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x6a6a6a)),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0x6a6a6a)),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0))
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0)),
codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: UIColor(rgb: 0xb0b0b0))
),
serif: false,
codeBlockBackgroundColor: UIColor(rgb: 0x131313),

View file

@ -440,7 +440,7 @@ public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? {
/// Also returns `trailingBottomPadding`: the renderer draws the baseline at the line frame's maxY,
/// so the visible text of a plain line sits ~5pt below it. A status that *trails on the line* should
/// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad
/// is 0 when the line is taller than its font line height (an inline animated emoji, ~pointSize·24/17,
/// is 0 when the line is taller than its font line height (a tall inline attachment, e.g. a formula,
/// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status
/// wraps onto its own line below the text there it should sit at the bare maxY.
public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? {
@ -1899,17 +1899,14 @@ private func layoutDivider(
boundingWidth: CGFloat,
context: LayoutContext
) -> [InstantPageV2LaidOutItem] {
// Geometry matches V1 InstantPageLayout.swift lines 361363:
// lineWidth = floor(boundingWidth / 2.0), x = floor((boundingWidth - lineWidth) / 2.0), h = 1pt.
// Color matches V1: theme.textCategories.caption.color.
let lineWidth = floor(boundingWidth / 2.0)
let frame = CGRect(
x: floor((boundingWidth - lineWidth) / 2.0),
y: 0.0,
width: lineWidth,
height: 1.0
height: UIScreenPixel
)
return [.divider(InstantPageV2DividerItem(frame: frame, color: context.theme.textCategories.caption.color))]
return [.divider(InstantPageV2DividerItem(frame: frame, color: context.theme.separatorColor))]
}
// MARK: - Code block layout (ported from V1 InstantPageLayout.swift lines 329351)
@ -1921,11 +1918,8 @@ private func layoutCodeBlock(
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// V1 InstantPageLayout.swift line 330: backgroundInset = 14.0 (top + bottom padding).
let backgroundInset: CGFloat = 14.0
// V1 line 342: text x offset is 17.0 (hardcoded, not backgroundInset).
let textXOffset: CGFloat = 17.0
// V1 line 348: shape is .rect no corner radius.
let backgroundInset: CGFloat = 15.0
let textXOffset: CGFloat = 11.0
let cornerRadius: CGFloat = 0.0
let attributedString: NSAttributedString
@ -1940,7 +1934,7 @@ private func layoutCodeBlock(
} else {
// V1 lines 335338: fall back to plain paragraph style when no language.
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false)
setupStyleStack(styleStack, theme: context.theme, category: .codeBlock, link: false)
attributedString = attributedStringForRichText(text, styleStack: styleStack)
}
@ -2004,16 +1998,22 @@ private func layoutThinking(
setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes)
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
// Mirror a normal `.text` item's sizing: lay the text out flush (offset .zero) and put the page
// inset onto the BLOCK frame, so the `.thinking` item's frame == a `.text` item's frame
// (`(horizontalInset, 0, textWidth, height)`) instead of a full-bleed `(0, 0, boundingWidth, )`
// box. The shimmer (sized to `item.frame.size`) then hugs the text rather than the whole page
// width; the rendered text stays at the same place (`horizontalInset`) since the block carries
// the inset.
let (textItem, _, textSize) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
offset: CGPoint(x: horizontalInset, y: 0.0),
offset: CGPoint(x: 0.0, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let textItem = textItem else { return [] }
let blockFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: textSize.height)
let blockFrame = CGRect(x: horizontalInset, y: 0.0, width: textSize.width, height: textSize.height)
return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))]
}
@ -2522,10 +2522,12 @@ private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantP
stack.push(.linkColor(theme.linkColor))
stack.push(.linkMarkerColor(theme.linkHighlightColor))
switch attributes.font.style {
case .sans:
stack.push(.fontSerif(false))
case .serif:
stack.push(.fontSerif(true))
case .sans:
stack.push(.fontSerif(false))
case .serif:
stack.push(.fontSerif(true))
case .monospace:
stack.push(.fontFixed(true))
}
stack.push(.fontSize(attributes.font.size))
stack.push(.lineSpacingFactor(attributes.font.lineSpacingFactor))
@ -2572,6 +2574,16 @@ private func instantPageFont(style: InstantPageTextAttributes, bold: Bool = fals
} else {
return Font.regular(size)
}
case .monospace:
if bold && italic {
return Font.semiboldItalicMonospace(size)
} else if bold {
return Font.semiboldMonospace(size)
} else if italic {
return Font.italicMonospace(size)
} else {
return Font.monospace(size)
}
}
}
@ -2739,10 +2751,18 @@ func layoutTextItem(
let fontLineHeight = floor(fontAscent + fontDescent)
let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor)
let fontDescentBelowBaseline = max(0.0, -fontDescent)
// True font-height line box: shift the whole line stack down by the ascender headroom above
// the cap line (A L) and pad the final height by the descender (D) below the last baseline,
// so a single-line item measures exactly A + D. Exact (not pixel-snapped): this is an
// intra-item line offset; crispness rides on the item's own pixel-snapped frame origin, and
// intra-item line positions may already be fractional (e.g. after a non-integral extraDescent).
// Inter-line advance is unchanged. (Named `lineBoxTopInset` to avoid colliding with the
// formula-bleed `topInset` local near the end of this function.)
let lineBoxTopInset = max(0.0, fontAscent - fontLineHeight)
let baselineToNextTopSlack = max(0.0, fontLineSpacing - 4.0)
var lastIndex: CFIndex = 0
var currentLineOrigin = CGPoint()
var currentLineOrigin = CGPoint(x: 0.0, y: lineBoxTopInset)
var hasAnchors = false
var maxLineWidth: CGFloat = 0.0
@ -2825,20 +2845,27 @@ func layoutTextItem(
} else if let emoji = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute {
let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil)
let font = (attributes[NSAttributedString.Key.font] as? UIFont) ?? UIFont.systemFont(ofSize: 17.0)
let itemSize = font.pointSize * 24.0 / 17.0
// Size the inline emoji to the font's line height (A + D = the true
// line-box height) plus a 4pt bump at the 17pt body font (scaled
// proportionally) so it reads a touch larger than the bare line box.
// The line is NOT inflated (lineAscent stays fontLineHeight). Must match
// the run-delegate width in attributedStringForRichText (InstantPageTextItem.swift).
let itemSize = font.ascender - font.descender + 4.0 * font.pointSize / 17.0
pendingEmoji.append(PendingV2EmojiAttachment(xOffset: xOffset, range: range, emoji: emoji, size: itemSize))
}
}
}
}
// Inline emoji and images do NOT inflate the line: they are centered on the font
// line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString`
// and the chat `InteractiveTextComponent`). Their run delegates already report the
// font's own ascent/descent, so CoreText lays the line out at the normal height the
// old `lineAscent = emoji.size` inflation both doubled the line height and (because the
// baseline sits at the bottom of the box) shoved the text baseline down. Only formulas,
// which carry their own typographic metrics, are allowed to grow the line.
var lineAscent: CGFloat = fontLineHeight
var lineDescent: CGFloat = fontDescentBelowBaseline
for image in pendingImages {
if image.size.height > lineAscent {
lineAscent = image.size.height
}
}
for formula in pendingFormulas {
let formulaAscent = formula.attachment.rendered.size.height - formula.attachment.rendered.descent
if formulaAscent > lineAscent {
@ -2848,17 +2875,15 @@ func layoutTextItem(
lineDescent = formula.attachment.rendered.descent
}
}
for emoji in pendingEmoji {
if emoji.size > lineAscent {
lineAscent = emoji.size
}
}
let baselineY = workingLineOrigin.y + lineAscent
for image in pendingImages {
// Center on the font line box (baseline fontLineHeight/2), matching V1's
// `(fontLineHeight - imageHeight) / 2` offset, instead of bottom-aligning on the
// baseline. Keeps the text baseline put and lets the image bleed symmetrically.
let imageFrame = CGRect(
x: workingLineOrigin.x + image.xOffset,
y: baselineY - image.size.height,
y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - image.size.height / 2.0),
width: image.size.width,
height: image.size.height
)
@ -2876,9 +2901,12 @@ func layoutTextItem(
lineFormulaItems.append(InstantPageTextFormulaRun(frame: formulaFrame, range: formula.range, attachment: attachment))
}
for emoji in pendingEmoji {
// Center on the font line box (baseline fontLineHeight/2) so a 24pt emoji on a
// ~17pt line bleeds symmetrically rather than forcing the line taller and pushing
// the text baseline down. Matches the chat `InteractiveTextComponent` placement.
let emojiFrame = CGRect(
x: workingLineOrigin.x + emoji.xOffset,
y: baselineY - emoji.size,
y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - emoji.size / 2.0),
width: emoji.size,
height: emoji.size
)
@ -2886,6 +2914,15 @@ func layoutTextItem(
}
extraDescent = max(0.0, lineDescent - baselineToNextTopSlack)
// A centered attachment taller than the line bleeds below the baseline; grow the
// descent so the following line isn't overlapped (mirrors V1's extraDescent handling).
// Emoji sized to the font line height (A + D) fit the line box, so they contribute nothing.
for imageItem in lineImageItems {
extraDescent = max(extraDescent, imageItem.frame.maxY - (baselineY + baselineToNextTopSlack))
}
for emojiItem in lineEmojiItems {
extraDescent = max(extraDescent, emojiItem.frame.maxY - (baselineY + baselineToNextTopSlack))
}
if !minimizeWidth && !hadIndexOffset && lineCharacterCount > 1 && lineWidth > currentMaxWidth + 5.0 {
if let imageItem = lineImageItems.last {
@ -3019,22 +3056,23 @@ func layoutTextItem(
let localIndex = emoji.range.location - lineRange.location
if localIndex >= 0 && localIndex < rects.count {
let x = CTLineGetOffsetForStringIndex(line, emoji.range.location, nil)
// characterRects are baseline-relative (positive-up). The emoji cell sits
// bottom-on-baseline (see frame loop: y = baselineY - emoji.size), so its
// baseline-relative bottom is 0 and maxY = emoji.size the width feeds the
// reveal cost map; maxY feeds the reveal-mask y conversion in the renderer.
rects[localIndex] = CGRect(x: x, y: 0.0, width: emoji.size, height: emoji.size)
// characterRects are baseline-relative (positive-up). The emoji cell is now
// centered on the font line box (see frame loop), so in baseline-relative
// coords it spans [fontLineHeight/2 size/2, fontLineHeight/2 + size/2].
// Width feeds the reveal cost map; maxY feeds the reveal-mask y conversion in
// the renderer (lineAscent maxY), keeping the mask tracking the centered cell.
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - emoji.size / 2.0, width: emoji.size, height: emoji.size)
}
}
for image in pendingImages {
let localIndex = image.range.location - lineRange.location
if localIndex >= 0 && localIndex < rects.count {
let x = CTLineGetOffsetForStringIndex(line, image.range.location, nil)
// Image cell sits bottom-on-baseline (frame loop: y = baselineY - image.size.height).
// Baseline-relative cell: y = 0, height = image.size.height. The full width feeds
// the reveal cost map so the streaming cursor is charged the image's width when
// crossing it same as an emoji cell.
rects[localIndex] = CGRect(x: x, y: 0.0, width: image.size.width, height: image.size.height)
// Image cell is centered on the font line box (see frame loop). Baseline-relative
// cell spans [fontLineHeight/2 height/2, fontLineHeight/2 + height/2]; the full
// width feeds the reveal cost map so the streaming cursor is charged the image's
// width when crossing it same as an emoji cell.
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height)
}
}
lineCharacterRects = rects
@ -3069,7 +3107,9 @@ func layoutTextItem(
var height: CGFloat = 0.0
if !lines.isEmpty && !(string.string == "\u{200b}" && hasAnchors) {
height = lines.last!.frame.maxY + extraDescent
// + fontDescentBelowBaseline: contain the last line's descender below its baseline, so
// (with the topInset shift) a single-line item measures exactly A + D = true font height.
height = lines.last!.frame.maxY + extraDescent + fontDescentBelowBaseline
}
var textWidth = boundingWidth

View file

@ -228,9 +228,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
nameColors = nil
}
let codeBlockBackgroundColor: UIColor
let codeBlockTitleColor: UIColor
let codeBlockAccentColor: UIColor
let codeBlockBackgroundColor: UIColor
if !isIncoming {
mainColor = messageTheme.accentTextColor
if let _ = nameColors?.secondary {
@ -243,12 +243,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
if item.presentationData.theme.theme.overallDarkAppearance {
codeBlockTitleColor = .white
codeBlockAccentColor = UIColor(white: 1.0, alpha: 0.5)
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.25)
} else {
codeBlockTitleColor = mainColor
codeBlockAccentColor = mainColor
codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1)
}
codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1)
} else {
let authorNameColor = nameColors?.main
secondaryColor = nameColors?.secondary
@ -263,11 +263,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
codeBlockTitleColor = mainColor
codeBlockAccentColor = mainColor
if item.presentationData.theme.theme.overallDarkAppearance {
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.65)
} else {
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.05)
}
codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1)
}
let _ = secondaryColor
@ -284,7 +280,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor)
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
)
let pageTheme = InstantPageTheme(
type: isDark ? .dark : .light,
@ -306,8 +303,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
separatorColor: isIncoming ? UIColor(white: 0.0, alpha: 0.25): messageTheme.accentControlColor.withMultipliedAlpha(0.25),
secondaryControlColor: messageTheme.secondaryTextColor
separatorColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3),
secondaryControlColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3)
)
var hasDraft = false
@ -320,6 +317,15 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
if let attribute = item.message.richText {
#if DEBUG && false
let instantPage = InstantPage(blocks: [.thinking(.concat([
.textCustomEmoji(fileId: 5384559872899555845, alt: "a"),
.plain("Thinking...")
]))], media: [:], isComplete: true, rtl: false, url: "", views: nil)
#else
let instantPage = attribute.instantPage
#endif
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
url: "",
displayUrl: "",
@ -339,7 +345,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
file: nil,
story: nil,
attributes: [],
instantPage: attribute.instantPage
instantPage: instantPage
)))
pageWebpage = webpage
@ -352,9 +358,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
current.messageStableVersion == currentMessageStableVersion {
pageLayout = current.layout
} else {
#if DEBUG && false
let instantPage = InstantPage(blocks: [.thinking(.concat([
.textCustomEmoji(fileId: 5384559872899555845, alt: "a"),
.plain("Thinking...")
]))], media: [:], isComplete: true, rtl: false, url: "", views: nil)
#else
let instantPage = attribute.instantPage
#endif
pageLayout = layoutInstantPageV2(
webpage: webpage,
instantPage: attribute.instantPage,
instantPage: instantPage,
userLocation: .other,
boundingWidth: suggestedBoundingWidth - 2.0,
horizontalInset: pageHorizontalInset,

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

@ -1782,7 +1782,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)
}
@ -2215,14 +2215,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

@ -96,7 +96,8 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor)
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor)
)
let pageTheme = InstantPageTheme(
type: isDark ? .dark : .light,
@ -119,7 +120,7 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
tableHeaderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
overlayPanelColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
separatorColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
secondaryControlColor: messageTheme.secondaryTextColor
)

View file

@ -706,8 +706,8 @@ extension ChatControllerImpl {
let globalPrivacySettings = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.GlobalPrivacy())
let canStopIncomingStreamingMessage: Signal<Bool, NoError>
if let peerId = chatLocation.peerId {
let canStopIncomingStreamingMessage: Signal<Bool, NoError> = .single(false)
/*if let peerId = chatLocation.peerId {
let key = PeerAndThreadId(peerId: peerId, threadId: chatLocation.threadId)
canStopIncomingStreamingMessage = context.account.postbox.combinedView(keys: [PostboxViewKey.typingDrafts(key)])
|> map { views -> Bool in
@ -719,7 +719,7 @@ extension ChatControllerImpl {
|> distinctUntilChanged
} else {
canStopIncomingStreamingMessage = .single(false)
}
}*/
self.peerDisposable = combineLatest(
queue: Queue.mainQueue(),
@ -1400,8 +1400,8 @@ extension ChatControllerImpl {
let globalPrivacySettings = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.GlobalPrivacy())
let canStopIncomingStreamingMessage: Signal<Bool, NoError>
if let peerId = chatLocation.peerId {
let canStopIncomingStreamingMessage: Signal<Bool, NoError> = .single(false)
/*if let peerId = chatLocation.peerId {
let key = PeerAndThreadId(peerId: peerId, threadId: chatLocation.threadId)
canStopIncomingStreamingMessage = context.account.postbox.combinedView(keys: [PostboxViewKey.typingDrafts(key)])
|> map { views -> Bool in
@ -1413,7 +1413,7 @@ extension ChatControllerImpl {
|> distinctUntilChanged
} else {
canStopIncomingStreamingMessage = .single(false)
}
}*/
self.peerDisposable = (combineLatest(queue: Queue.mainQueue(),
peerView,

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
}