Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

350 lines
18 KiB
Swift

import Foundation
import UIKit
import Postbox
import TelegramCore
import TextFormat
struct InstantPagePreviewEnvironment {
let label: (InstantPagePreviewLabel) -> String
let formatDate: ((Int32, MessageTextEntityType.DateTimeFormat) -> String)?
let media: [MediaId: Media]
}
enum InstantPagePreviewLabel {
case photo, video, voice, music, file, location
case formula, table, unsupported, embeddedContent, channel
case photos(Int32), videos(Int32)
}
let instantPagePreviewCustomEmoji = NSAttributedString.Key("TelegramInstantPagePreviewCustomEmoji")
let instantPagePreviewIcon = NSAttributedString.Key("TelegramInstantPagePreviewIcon")
let instantPagePreviewIconFallback = NSAttributedString.Key("TelegramInstantPagePreviewIconFallback")
private let instantPagePreviewItalic = NSAttributedString.Key("TelegramInstantPagePreviewItalic")
// An icon/emoji is atomic even when its backing string contains several characters.
func clipInstantPagePreview(_ text: NSAttributedString, limit: Int = 200, truncated: Bool = false) -> NSAttributedString {
guard truncated || text.string.count > limit else { return text }
let index = text.string.index(text.string.startIndex, offsetBy: min(limit - 1, text.string.count))
var end = NSRange(text.string.startIndex ..< index, in: text.string).length
if end > 0 && end < text.length {
var range = NSRange()
let emoji = text.attribute(instantPagePreviewCustomEmoji, at: end, effectiveRange: &range) ?? text.attribute(ChatTextInputAttributes.customEmoji, at: end, effectiveRange: &range)
if emoji != nil, range.location < end {
end = range.location
}
}
let result = NSMutableAttributedString(attributedString: text.attributedSubstring(from: NSRange(location: 0, length: end)))
while result.string.last?.isWhitespace == true { result.deleteCharacters(in: NSRange(location: result.length - 1, length: 1)) }
result.append(NSAttributedString(string: "…"))
return result
}
private func trimPreview(_ text: NSAttributedString) -> NSAttributedString {
let string = text.string
let start = string.firstIndex(where: { !$0.isWhitespace }) ?? string.endIndex
let end = string.lastIndex(where: { !$0.isWhitespace }).map { string.index(after: $0) } ?? start
return text.attributedSubstring(from: NSRange(start ..< end, in: string))
}
private final class InstantPagePreviewBuilder {
let environment: InstantPagePreviewEnvironment
var nodesLeft = 4096
var textUnitsLeft = 16384
var exhausted = false
private var thinking: [(RichText, Int)] = []
init(environment: InstantPagePreviewEnvironment) {
self.environment = environment
}
private func enter(_ depth: Int) -> Bool {
guard !self.exhausted else { return false }
guard depth <= 64, self.nodesLeft > 0 else { self.exhausted = true; return false }
self.nodesLeft -= 1
return true
}
// Bound the input BEFORE asking Swift to find grapheme boundaries. If cut in
// the middle of a grapheme, conservatively omit the last cluster of the prefix.
private func text(_ value: String) -> NSAttributedString {
let units = Array(value.utf16.prefix(self.textUnitsLeft + 1))
let cut = units.count > self.textUnitsLeft
let prefix = String(decoding: units.prefix(self.textUnitsLeft), as: UTF16.self)
self.textUnitsLeft -= min(units.count, self.textUnitsLeft)
if cut { self.exhausted = true }
let input = cut && !prefix.isEmpty ? String(prefix.dropLast()) : prefix
var output = ""
var wasSpace = false
var count = 0
for character in input {
if character.isWhitespace {
if !wasSpace { output.append(" "); count += 1 }
wasSpace = true
} else {
output.append(character)
count += 1
wasSpace = false
}
if count > 200 && !character.isWhitespace { break }
}
return NSAttributedString(string: output)
}
private func label(_ label: InstantPagePreviewLabel) -> NSAttributedString {
return self.text(self.environment.label(label))
}
private func join(_ values: [NSAttributedString]) -> NSAttributedString {
let result = NSMutableAttributedString()
for value in values {
let value = trimPreview(value)
guard value.length != 0 else { continue }
if result.length != 0 { result.append(NSAttributedString(string: " ")) }
result.append(value)
if result.string.count > 200 { break }
}
return result
}
private func attribute(_ key: NSAttributedString.Key, _ value: Any, _ text: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: text)
result.addAttribute(key, value: value, range: NSRange(location: 0, length: result.length))
return result
}
private func icon(_ label: InstantPagePreviewLabel, value: Int32) -> NSAttributedString {
return NSAttributedString(string: "\u{fffc}", attributes: [
instantPagePreviewIcon: NSNumber(value: value),
instantPagePreviewIconFallback: self.environment.label(label)
])
}
func rich(_ value: RichText, depth: Int) -> NSAttributedString {
guard self.enter(depth) else { return NSAttributedString() }
switch value {
case .empty: return NSAttributedString()
case let .plain(value): return self.text(value)
case let .italic(value): return self.attribute(instantPagePreviewItalic, true, self.rich(value, depth: depth + 1))
case let .underline(value): return self.attribute(.underlineStyle, NSUnderlineStyle.single.rawValue, self.rich(value, depth: depth + 1))
case let .strikethrough(value): return self.attribute(.strikethroughStyle, NSUnderlineStyle.single.rawValue, self.rich(value, depth: depth + 1))
case let .textSpoiler(value): return self.attribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler), true, self.rich(value, depth: depth + 1))
case let .textDate(value, date, format):
let text: NSAttributedString
if let format, let formatDate = self.environment.formatDate {
text = self.text(formatDate(date, format))
} else {
text = self.rich(value, depth: depth + 1)
}
return self.attribute(NSAttributedString.Key(rawValue: TelegramTextAttributes.Date), date, text)
case let .textCustomEmoji(fileId, alt):
let file = self.environment.media[MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile
let text = self.text(alt.isEmpty ? (file == nil ? "�" : "\u{fffc}") : alt)
let emoji = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: file)
let result = self.attribute(instantPagePreviewCustomEmoji, emoji, text)
// Entity text nodes replace the backing text by an attachment. Without
// a known file that would hide alt text even when resolution fails.
return file == nil ? result : self.attribute(ChatTextInputAttributes.customEmoji, emoji, result)
case .formula: return self.icon(.formula, value: 0)
case .image: return self.label(.photo)
case let .textButton(button): return self.rich(button.text, depth: depth + 1)
case let .concat(values):
let result = NSMutableAttributedString()
for value in values {
if self.exhausted || trimPreview(result).string.count > 200 { break }
let next = self.rich(value, depth: depth + 1)
// Each leaf normalizes whitespace, but adjacent leaves can meet at a space.
if result.string.last == " ", next.string.first == " " {
result.append(next.attributedSubstring(from: NSRange(location: 1, length: next.length - 1)))
} else {
result.append(next)
}
}
return result
case let .bold(value), let .fixed(value), let .subscript(value), let .superscript(value), let .marked(value), let .url(value, _, _), let .email(value, _), let .phone(value, _), let .anchor(value, _), let .textAutoEmail(value), let .textAutoPhone(value), let .textAutoUrl(value), let .textBankCard(value), let .textTonAddress(value), let .textBotCommand(value), let .textCashtag(value), let .textHashtag(value), let .textMention(value), let .textMentionName(value, _):
return self.rich(value, depth: depth + 1)
}
}
private struct Fragment {
let text: NSAttributedString
var photos: Int32 = 0
var videos: Int32 = 0
var mediaOnly = false
}
private func caption(_ value: InstantPageCaption, depth: Int) -> NSAttributedString {
return self.join([self.rich(value.text, depth: depth + 1), self.rich(value.credit, depth: depth + 1)])
}
func blocks(_ values: [InstantPageBlock], depth: Int) -> NSAttributedString {
var result: [NSAttributedString] = []
var length = 0
for value in values {
guard !self.exhausted, length <= 200 else { break }
if case let .thinking(text) = value {
guard self.enter(depth) else { break }
self.thinking.append((text, depth + 1))
continue
}
let text = trimPreview(self.block(value, depth: depth).text)
if text.length != 0 {
result.append(text)
length += text.string.count + (result.count > 1 ? 1 : 0)
}
}
return self.join(result)
}
func thinkingFallback() -> NSAttributedString {
var values: [NSAttributedString] = []
var length = 0
for (text, depth) in self.thinking {
guard !self.exhausted, length <= 200 else { break }
let value = self.rich(text, depth: depth)
values.append(value)
length += value.string.count + 1
}
return self.join(values)
}
private func block(_ value: InstantPageBlock, depth: Int) -> Fragment {
guard self.enter(depth) else { return Fragment(text: NSAttributedString()) }
let next = depth + 1
let text: NSAttributedString
switch value {
case let .title(value), let .subtitle(value), let .header(value), let .subheader(value), let .paragraph(value), let .footer(value), let .kicker(value), let .heading(value, _), let .preformatted(value, _), let .authorDate(value, _):
text = self.rich(value, depth: next)
case let .thinking(value):
self.thinking.append((value, next))
text = NSAttributedString()
case .unsupported: text = self.label(.unsupported)
case .divider, .anchor: text = NSAttributedString()
case .formula: text = self.icon(.formula, value: 0)
case let .table(title, _, _, _, _): text = self.join([self.icon(.table, value: 1), self.rich(title, depth: next)])
case let .cover(value): return self.block(value, depth: next)
case let .blockQuote(values, caption, _): text = self.join([self.blocks(values, depth: next), self.rich(caption, depth: next)])
case let .pullQuote(value, caption): text = self.join([self.rich(value, depth: next), self.rich(caption, depth: next)])
case let .details(title, values, _): text = self.join([self.rich(title, depth: next), self.blocks(values, depth: next)])
case let .image(_, caption, _, _, _), let .video(_, caption, _, _, _):
let isPhoto: Bool
if case .image = value { isPhoto = true } else { isPhoto = false }
let captionText = self.caption(caption, depth: next)
return Fragment(text: self.join([self.label(isPhoto ? .photo : .video), captionText]), photos: isPhoto ? 1 : 0, videos: isPhoto ? 0 : 1, mediaOnly: captionText.length == 0)
case let .audio(id, caption):
text = self.join([self.label((self.environment.media[id] as? TelegramMediaFile)?.isVoice == true ? .voice : .music), self.caption(caption, depth: next)])
case let .document(id, caption):
let name = (self.environment.media[id] as? TelegramMediaFile)?.fileName
text = self.join([name.flatMap { $0.isEmpty ? nil : self.text($0) } ?? self.label(.file), self.caption(caption, depth: next)])
case let .map(_, _, _, _, caption): text = self.join([self.label(.location), self.caption(caption, depth: next)])
case let .webEmbed(url, _, _, caption, _, _, _):
let captionText = self.caption(caption, depth: next)
if captionText.length > 0 {
text = captionText
} else {
let urlText = trimPreview(url.map(self.text) ?? NSAttributedString())
text = urlText.length > 0 ? urlText : self.label(.embeddedContent)
}
case let .postEmbed(url, _, _, author, _, values, caption):
let body = self.join([self.blocks(values, depth: next), self.caption(caption, depth: next)])
text = body.length > 0 ? body : self.text(author.isEmpty ? url : author)
case let .channelBanner(channel):
let title = trimPreview(channel.map { self.text($0.title) } ?? NSAttributedString())
text = title.length > 0 ? title : self.label(.channel)
case let .relatedArticles(title, articles):
var values = [self.rich(title, depth: next)]
for article in articles {
guard self.enter(next), self.join(values).string.count <= 200 else { break }
values.append(self.text(article.title.flatMap { $0.isEmpty ? nil : $0 } ?? article.url))
}
text = self.join(values)
case let .buttonRow(_, buttons):
var values: [NSAttributedString] = []
for button in buttons {
guard !self.exhausted, self.join(values).string.count <= 200 else { break }
values.append(self.rich(button.text, depth: next))
}
text = self.join(values)
case let .list(items, _):
var values: [NSAttributedString] = []
for item in items {
guard self.enter(next), self.join(values).string.count <= 200 else { break }
let body: NSAttributedString
let number: String?
let checked: Bool?
switch item {
case .unknown: continue
case let .text(value, num, check):
body = self.rich(value, depth: next + 1); number = num; checked = check
case let .blocks(blocks, num, check):
body = self.blocks(blocks, depth: next + 1); number = num; checked = check
}
let prefix: NSAttributedString
if let checked { prefix = self.text(checked ? "☑︎" : "☐") }
else if let number, !number.isEmpty { prefix = self.text(number + ".") }
else { prefix = NSAttributedString() }
values.append(self.join([prefix, body]))
}
text = self.join(values)
case let .collage(items, caption), let .slideshow(items, caption):
let captionText = self.caption(caption, depth: next)
if captionText.length != 0 { return Fragment(text: captionText) }
var children: [NSAttributedString] = []
var photos: Int32 = 0
var videos: Int32 = 0
var mediaOnly = true
var childLength = 0
for item in items {
guard !self.exhausted else { break }
let child = self.block(item, depth: next)
photos += child.photos; videos += child.videos
if child.text.length != 0 {
mediaOnly = mediaOnly && child.mediaOnly
if childLength <= 200 {
children.append(child.text)
childLength += child.text.string.count + 1
}
}
}
if mediaOnly, photos + videos > 0 {
var labels: [String] = []
if photos > 0 { labels.append(self.environment.label(photos == 1 || self.exhausted ? .photo : .photos(photos))) }
if videos > 0 { labels.append(self.environment.label(videos == 1 || self.exhausted ? .video : .videos(videos))) }
return Fragment(text: NSAttributedString(string: labels.joined(separator: ", ")), photos: photos, videos: videos, mediaOnly: true)
}
text = self.join(children)
}
return Fragment(text: text)
}
}
func buildInstantPagePreview(blocks: [InstantPageBlock], environment: InstantPagePreviewEnvironment) -> NSAttributedString {
let builder = InstantPagePreviewBuilder(environment: environment)
var text = trimPreview(builder.blocks(blocks, depth: 0))
if text.length == 0 { text = trimPreview(builder.thinkingFallback()) }
return clipInstantPagePreview(text, truncated: builder.exhausted)
}
public func styleInstantPagePreview(_ text: NSAttributedString, font: UIFont, italicFont: UIFont, textColor: UIColor) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: text)
let range = NSRange(location: 0, length: result.length)
result.addAttributes([.font: font, .foregroundColor: textColor], range: range)
text.enumerateAttribute(instantPagePreviewItalic, in: range) { value, range, _ in
if value != nil { result.addAttribute(.font, value: italicFont, range: range) }
}
return result
}
public func instantPagePreviewPlainText(_ text: NSAttributedString) -> String {
let result = NSMutableAttributedString(attributedString: text)
text.enumerateAttributes(in: NSRange(location: 0, length: text.length), options: .reverse) { attributes, range, _ in
if let fallback = attributes[instantPagePreviewIconFallback] as? String {
// Adjacent identical icons may coalesce into a single attribute run.
let count = text.attributedSubstring(from: range).string.filter { $0 == "\u{fffc}" }.count
result.replaceCharacters(in: range, with: String(repeating: fallback, count: count))
} else if (attributes[instantPagePreviewCustomEmoji] != nil || attributes[ChatTextInputAttributes.customEmoji] != nil), text.attributedSubstring(from: range).string == "\u{fffc}" {
result.replaceCharacters(in: range, with: "�")
}
}
return clipInstantPagePreview(result).string
}