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

190 lines
7.2 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramCore
import TelegramPresentationData
import AccountContext
import TelegramUIPreferences
import InstantPageUI
/// Renders a `.rich` `ComposedRichMessage`'s `InstantPage` inside the text-processing sheet.
/// Interactivity is self-contained only: spoiler reveal and details/blockquote collapse work,
/// while navigation-shaped actions (links, media gallery, audio) are inert no-ops — matching
/// the long-press-Send preview and the plain areas' behavior.
final class TextProcessingRichContentView: UIView {
private let context: AccountContext
private let instantPage: InstantPage
private let webpage: TelegramMediaWebpage
private let pageView: InstantPageV2View
var requestUpdate: (() -> Void)?
private var expandedDetails: [Int: Bool] = [:]
private var cachedBoundingWidth: CGFloat?
private var cachedThemeIdentity: ObjectIdentifier?
private var cachedContentSize: CGSize = .zero
private(set) var currentTextLineMetrics: InstantPageV2TextLineMetrics?
init(context: AccountContext, instantPage: InstantPage) {
self.context = context
self.instantPage = instantPage
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
url: "",
displayUrl: "",
hash: 0,
type: nil,
websiteName: nil,
title: nil,
text: nil,
embedUrl: nil,
embedType: nil,
embedSize: nil,
duration: nil,
author: nil,
isMediaLargeByDefault: nil,
imageIsVideoCover: false,
image: nil,
file: nil,
story: nil,
attributes: [],
instantPage: instantPage
)))
self.webpage = webpage
let renderContext = InstantPageV2RenderContext(
context: context,
webpage: webpage,
sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .channel),
imageReference: { image in
return ImageMediaReference.standalone(media: image)
},
fileReference: { file in
return FileMediaReference.standalone(media: file)
},
present: { _, _ in },
push: { _ in },
openUrl: { _ in },
baseNavigationController: { return nil },
message: nil
)
self.pageView = InstantPageV2View(renderContext: renderContext)
super.init(frame: CGRect())
self.addSubview(self.pageView)
self.pageView.detailsTapped = { [weak self] index in
guard let self else {
return
}
self.expandedDetails[index] = !(self.expandedDetails[index] ?? false)
self.cachedBoundingWidth = nil
self.requestUpdate?()
}
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:))))
}
required init?(coder: NSCoder) {
preconditionFailure()
}
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
guard case .ended = recognizer.state else {
return
}
guard !self.pageView.displayContentsUnderSpoilers else {
return
}
let local = recognizer.location(in: self.pageView)
guard let hit = self.pageView.textItemAt(point: local) else {
return
}
let itemLocal = CGPoint(x: local.x - hit.parentOffset.x, y: local.y - hit.parentOffset.y)
guard let (_, attributes) = hit.item.attributesAtPoint(itemLocal, orNearest: false) else {
return
}
if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil {
self.pageView.setDisplayContentsUnderSpoilers(true, atLocation: local, animated: true)
}
}
func update(boundingWidth: CGFloat, theme: PresentationTheme, strings: PresentationStrings, transition: ComponentTransition) -> CGSize {
let themeIdentity = ObjectIdentifier(theme)
if self.cachedBoundingWidth == boundingWidth, self.cachedThemeIdentity == themeIdentity {
self.pageView.frame = CGRect(origin: CGPoint(), size: self.cachedContentSize)
return self.cachedContentSize
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let pageTheme = textProcessingInstantPageTheme(theme: theme)
let layout = layoutInstantPageV2(
webpage: self.webpage,
instantPage: self.instantPage,
userLocation: .other,
boundingWidth: boundingWidth,
horizontalInset: 0.0,
theme: pageTheme,
strings: strings,
dateTimeFormat: presentationData.dateTimeFormat,
cachedMessageSyntaxHighlight: nil,
expandedDetails: self.expandedDetails,
fitToWidth: true
)
self.pageView.update(layout: layout, theme: pageTheme, animation: .None)
self.pageView.frame = CGRect(origin: CGPoint(), size: layout.contentSize)
self.cachedBoundingWidth = boundingWidth
self.cachedThemeIdentity = themeIdentity
self.cachedContentSize = layout.contentSize
self.currentTextLineMetrics = layout.textLineMetrics()
return layout.contentSize
}
}
/// The sheet's InstantPage palette: the list item colors the plain text areas already use,
/// in the structure `ChatSendMessageRichTextPreview` established for the outgoing-bubble palette.
private func textProcessingInstantPageTheme(theme: PresentationTheme) -> InstantPageTheme {
let isDark = theme.overallDarkAppearance
let primary = theme.list.itemPrimaryTextColor
let secondary = theme.list.itemSecondaryTextColor
let accent = theme.list.itemAccentColor
if isDark {
} else {
}
let textCategories = InstantPageTextCategories.chatMessage(
primaryText: primary,
secondaryText: secondary
)
return InstantPageTheme(
type: isDark ? .dark : .light,
pageBackgroundColor: .clear,
textCategories: textCategories,
serif: false,
// Matches the bubble: the highlighted-table-cell fill, not an accent tint.
codeBlockBackgroundColor: primary.withMultipliedAlpha(0.05),
linkColor: accent,
textHighlightColor: accent.withMultipliedAlpha(0.1),
linkHighlightColor: accent.withMultipliedAlpha(0.1),
markerColor: UIColor(rgb: 0xfef3bc),
panelBackgroundColor: accent.withMultipliedAlpha(0.1),
panelHighlightedBackgroundColor: accent.withMultipliedAlpha(0.25),
panelPrimaryColor: primary,
panelSecondaryColor: secondary,
panelAccentColor: accent,
tableBorderColor: primary.withMultipliedAlpha(0.1),
tableHeaderColor: primary.withMultipliedAlpha(0.05),
controlColor: accent,
imageTintColor: nil,
overlayPanelColor: accent.withMultipliedAlpha(0.25),
separatorColor: accent.withMultipliedAlpha(0.25),
secondaryControlColor: secondary,
quoteAccentColor: accent
)
}