mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 06:14:14 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
18 KiB
Swift
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
|
|
}
|