Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
b15b6ebf50
297 changed files with 24616 additions and 340 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 361–363:
|
||||
// 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 329–351)
|
||||
|
|
@ -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 335–338: 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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