mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
20 KiB
Swift
373 lines
20 KiB
Swift
import Foundation
|
|
import LottieSettings
|
|
import UIKit
|
|
import ComponentFlow
|
|
import Display
|
|
import RichTextEditorUIKit
|
|
import AccountContext
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import PhotoResources
|
|
import RadialStatusNode
|
|
import LottieComponent
|
|
import GlassBackgroundComponent
|
|
import InvisibleInkDustNode
|
|
|
|
/// A composable ComponentFlow renderer for ONE rich-text still image or video poster. Owns its
|
|
/// own `TransformImageNode` + media-fetch signal + aspect-fill layout, decoupled from
|
|
/// `InstantPageImageNode`. Carries a glass "more" button; interaction is **control-scoped** — the
|
|
/// `View.hitTest` returns a control ONLY when the touch lands on it (the poster passes through to the
|
|
/// editor's own tap handling), which is how the button is tappable without stealing caret/media-select
|
|
/// taps. Built as a plain `Component` so a future mosaic/slideshow wrapper can compose it without
|
|
/// structural change. Location (`.geo`) and audio are handled by `MediaItemNodeView`, not here.
|
|
@available(iOS 13.0, *)
|
|
public final class RichTextMediaContentComponent: Component {
|
|
public let context: AccountContext
|
|
public let media: EngineMedia
|
|
/// Whether the glass "more" button is shown. Constant per usage (composer vs. article editor); not
|
|
/// part of `==` for the same reason `onControlTapped` isn't — identity equality must hold across
|
|
/// resizes so the fetch binds once.
|
|
public let showsMoreButton: Bool
|
|
|
|
/// When true, the poster is drawn aspect-FIT with a blurred aspect-filled backdrop (chat's
|
|
/// `.blurBackground`) — used for a lone photo/video so a portrait/panorama shows whole with a blurred
|
|
/// letterbox/pillarbox instead of being cropped. Mosaic cells leave it false (crop-to-fill). NOT part
|
|
/// of `==` (identity equality must hold across resizes so the fetch binds once); the host re-sets it
|
|
/// each layout pass, so a cell reused across a single↔mosaic transition switches mode.
|
|
var usesAspectFit: Bool
|
|
|
|
/// Telegram-style spoiler: the poster renders behind a heavily-blurred (`.blurBackground`) overlay +
|
|
/// an animated "dust" cover, both owned by this cell so a mosaic of items each spoilers independently.
|
|
/// Non-revealable authoring cover (the message-render side owns reveal). UNLIKE `usesAspectFit`, this
|
|
/// IS part of `==`: it can toggle WITHOUT a size change (the edit-menu / ••• Spoiler action), and the
|
|
/// host (`ComponentHostView`) skips `update` when the component compares equal at an unchanged size —
|
|
/// so omitting it here would make a spoiler toggle a visual no-op. It does not vary across resizes, so
|
|
/// including it does not defeat the fetch-binds-once optimization.
|
|
var isSpoiler: Bool
|
|
|
|
/// Set by `MediaItemNodeView`; fired when an interactive control is tapped. Not part of `==` (identity
|
|
/// equality must hold across resizes so the fetch binds once).
|
|
var onControlTapped: ((RichTextMediaControlKind, UIView, CGRect) -> Void)?
|
|
|
|
public init(context: AccountContext, media: EngineMedia, showsMoreButton: Bool = true, usesAspectFit: Bool = false, isSpoiler: Bool = false) {
|
|
self.context = context
|
|
self.media = media
|
|
self.showsMoreButton = showsMoreButton
|
|
self.usesAspectFit = usesAspectFit
|
|
self.isSpoiler = isSpoiler
|
|
}
|
|
|
|
public static func ==(lhs: RichTextMediaContentComponent, rhs: RichTextMediaContentComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.media.id != rhs.media.id {
|
|
return false
|
|
}
|
|
if lhs.isSpoiler != rhs.isSpoiler {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private final class ButtonView: UIView {
|
|
|
|
}
|
|
|
|
public final class View: UIView {
|
|
private let imageNode = TransformImageNode()
|
|
private var statusNode: RadialStatusNode?
|
|
private let fetchDisposable = MetaDisposable()
|
|
// Spoiler visuals, owned per-cell: a heavily-blurred (`.blurBackground`) overlay occluding the
|
|
// sharp poster, plus a `MediaDustNode` particle cover on top. A SEPARATE blur node is required —
|
|
// `TransformImageArguments.==` ignores `resizeMode`, so flipping the main node's resizeMode alone
|
|
// would early-out and never re-render. Both are non-revealable authoring covers (taps fall through
|
|
// to the editor). `currentMediaSignal` is the poster signal, reused for the blur (no extra fetch).
|
|
private var blurNode: TransformImageNode?
|
|
private var dustNode: MediaDustNode?
|
|
private var blurBoundMediaId: EngineMedia.Id?
|
|
// The BLURRED signal for the spoiler cover (`chatSecretPhoto`/`chatSecretMessageVideo`, which render
|
|
// `blurred: true` — the sharp foreground is suppressed, unlike `.blurBackground` on a sharp signal,
|
|
// which only fills the letterbox gaps and is covered by the sharp image). Same source as the poster.
|
|
private var currentBlurredSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
|
|
|
|
private let moreButtonBackgroundContainer: GlassBackgroundContainerView
|
|
private let moreButton: HighlightTrackingButton
|
|
private let moreButtonBackground: GlassBackgroundView
|
|
private let moreButtonIcon = ComponentView<Empty>()
|
|
|
|
private var boundMediaId: EngineMedia.Id?
|
|
private var didBind = false
|
|
|
|
// Pre-upload progress is owned HERE, not passed in by the host. The editor re-provides a media
|
|
// view only when its item signature changes (DocumentCanvasView+Media.syncMediaItemViews), and
|
|
// upload progress is not — and cannot be — part of that signature, so a host-pushed value never
|
|
// arrives during an upload. Subscribing here mirrors how this view already owns its own fetch.
|
|
private let preuploadDisposable = MetaDisposable()
|
|
private var boundPreuploadMediaId: EngineMedia.Id?
|
|
private var preuploadProgress: Float?
|
|
private var dimensions: PixelDimensions?
|
|
private var isVideo = false
|
|
private var currentSize: CGSize?
|
|
|
|
private var component: RichTextMediaContentComponent?
|
|
private weak var state: EmptyComponentState?
|
|
|
|
override init(frame: CGRect) {
|
|
self.moreButton = HighlightTrackingButton()
|
|
self.moreButtonBackgroundContainer = GlassBackgroundContainerView()
|
|
self.moreButtonBackground = GlassBackgroundView()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.imageNode.view)
|
|
|
|
self.moreButtonBackground.isUserInteractionEnabled = false
|
|
self.moreButton.addSubview(self.moreButtonBackground)
|
|
self.moreButtonBackgroundContainer.contentView.addSubview(self.moreButton)
|
|
self.addSubview(self.moreButtonBackgroundContainer)
|
|
|
|
self.moreButton.addTarget(self, action: #selector(self.moreButtonPressed), for: .touchUpInside)
|
|
self.moreButton.highligthedChanged = { [weak self] highlighted in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let transition: ComponentTransition = highlighted ? .immediate : .easeInOut(duration: 0.25)
|
|
transition.setAlpha(view: self.moreButton, alpha: highlighted ? 0.6 : 1.0)
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.fetchDisposable.dispose()
|
|
self.preuploadDisposable.dispose()
|
|
}
|
|
|
|
/// Subscribe once per media id. Holds NO pre-upload need — observing must never keep an
|
|
/// orphaned upload alive; the need belongs to whatever owns the content.
|
|
private func bindPreuploadIfNeeded(context: AccountContext, media: EngineMedia) {
|
|
guard let mediaId = media.id else {
|
|
return
|
|
}
|
|
if self.boundPreuploadMediaId == mediaId {
|
|
return
|
|
}
|
|
self.boundPreuploadMediaId = mediaId
|
|
self.preuploadProgress = nil
|
|
self.preuploadDisposable.set((context.engine.messages.mediaPreuploadState(id: mediaId)
|
|
|> deliverOnMainQueue).start(next: { [weak self] state in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let progress: Float?
|
|
switch state {
|
|
case let .progress(value):
|
|
progress = value
|
|
case .done, .failed, .none:
|
|
progress = nil
|
|
}
|
|
guard self.preuploadProgress != progress else {
|
|
return
|
|
}
|
|
self.preuploadProgress = progress
|
|
// Refresh the status node DIRECTLY rather than via `state.updated()`. A component
|
|
// re-render re-runs `update`, which re-issues the image fetch unconditionally
|
|
// (`fetchDisposable.set(...)`), so driving per-tick progress through the component
|
|
// would refetch the poster on every tick.
|
|
if let size = self.currentSize {
|
|
self.updateStatusNode(availableSize: size, isSpoiler: self.component?.isSpoiler ?? false)
|
|
}
|
|
}))
|
|
}
|
|
|
|
/// The centre overlay: upload progress while pre-uploading, else a play button for video.
|
|
private func updateStatusNode(availableSize: CGSize, isSpoiler: Bool) {
|
|
guard self.isVideo || self.preuploadProgress != nil else {
|
|
self.statusNode?.view.isHidden = true
|
|
return
|
|
}
|
|
let statusNode: RadialStatusNode
|
|
if let existing = self.statusNode {
|
|
statusNode = existing
|
|
} else {
|
|
statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.6))
|
|
self.addSubview(statusNode.view) // RadialStatusNode is an ASControlNode; host its .view
|
|
self.statusNode = statusNode
|
|
}
|
|
// Chosen every pass rather than once at construction: a medium transitions from uploading
|
|
// to idle in place, and a photo has no resting state at all.
|
|
let state: RadialStatusNodeState
|
|
if let progress = self.preuploadProgress {
|
|
state = .progress(color: .white, lineWidth: nil, value: CGFloat(progress), cancelEnabled: false, animateRotation: true)
|
|
} else {
|
|
state = .play(.white)
|
|
}
|
|
statusNode.transitionToState(state, animated: true, completion: {})
|
|
let statusSize: CGFloat = max(18.0, min(50.0, floor(min(availableSize.width, availableSize.height) * 0.7)))
|
|
statusNode.frame = CGRect(x: floorToScreenPixels((availableSize.width - statusSize) / 2.0), y: floorToScreenPixels((availableSize.height - statusSize) / 2.0), width: statusSize, height: statusSize)
|
|
// A spoilered video hides its play button under the blur (mirrors the message side);
|
|
// upload progress stays visible, since a spoiler is about the recipient, not the sender.
|
|
statusNode.view.isHidden = isSpoiler && self.preuploadProgress == nil
|
|
}
|
|
|
|
/// Only the interactive chrome (the more button) claims a touch; the image/video poster area
|
|
/// returns nil so the touch passes through to the editor, which runs its own tap handling
|
|
/// (caret placement / media-select highlight). Routes through the button's
|
|
/// `GlassBackgroundContainerView`, whose own `hitTest` already returns the button when hit and
|
|
/// nil otherwise. Add any future interactive controls to this override.
|
|
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
let inButton = self.moreButtonBackgroundContainer.convert(point, from: self)
|
|
return self.moreButtonBackgroundContainer.hitTest(inButton, with: event)
|
|
}
|
|
|
|
@objc private func moreButtonPressed() {
|
|
self.component?.onControlTapped?(.more, self.moreButtonBackgroundContainer,
|
|
self.moreButtonBackgroundContainer.bounds)
|
|
}
|
|
|
|
func update(component: RichTextMediaContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
self.component = component
|
|
self.state = state
|
|
|
|
let context = component.context
|
|
self.fetchDisposable.set(nil)
|
|
self.statusNode?.view.removeFromSuperview()
|
|
self.statusNode = nil
|
|
self.isVideo = false
|
|
self.dimensions = nil
|
|
|
|
if case let .image(image) = component.media, let largest = largestImageRepresentation(image.representations) {
|
|
self.dimensions = largest.dimensions
|
|
let imageReference = ImageMediaReference.standalone(media: image)
|
|
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: .other, photoReference: imageReference))
|
|
self.currentBlurredSignal = chatSecretPhoto(account: context.account, userLocation: .other, photoReference: imageReference, ignoreFullSize: true)
|
|
self.fetchDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: .other, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start())
|
|
} else if case let .file(file) = component.media {
|
|
self.dimensions = file.dimensions
|
|
let fileReference = FileMediaReference.standalone(media: file)
|
|
if file.mimeType.hasPrefix("image/") {
|
|
_ = freeMediaFileInteractiveFetched(account: context.account, userLocation: .other, fileReference: fileReference).start()
|
|
self.imageNode.setSignal(instantPageImageFile(account: context.account, userLocation: .other, fileReference: fileReference, fetched: true))
|
|
} else {
|
|
self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: .other, videoReference: fileReference))
|
|
self.isVideo = true
|
|
}
|
|
self.currentBlurredSignal = chatSecretMessageVideo(account: context.account, userLocation: .other, videoReference: fileReference)
|
|
}
|
|
// else: unexpected media kind (.geo / audio reach here only by misuse) — leave the
|
|
// node blank rather than crash.
|
|
|
|
self.imageNode.frame = CGRect(origin: .zero, size: availableSize)
|
|
|
|
let emptyColor = component.context.sharedContext.currentPresentationData.with { $0 }.theme.list.mediaPlaceholderColor
|
|
if let dimensions = self.dimensions {
|
|
let imageSize: CGSize
|
|
let resizeMode: TransformImageResizeMode
|
|
if component.usesAspectFit {
|
|
imageSize = dimensions.cgSize.aspectFitted(availableSize)
|
|
resizeMode = .blurBackground
|
|
} else {
|
|
imageSize = dimensions.cgSize.aspectFilled(availableSize)
|
|
resizeMode = .fill(.black)
|
|
}
|
|
let apply = self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: availableSize, intrinsicInsets: UIEdgeInsets(), resizeMode: resizeMode, emptyColor: emptyColor))
|
|
apply()
|
|
}
|
|
|
|
// Spoiler: a heavily-blurred `.blurBackground` overlay occluding the sharp poster, plus a
|
|
// `MediaDustNode` particle cover on top — both owned by this cell. Non-revealable authoring
|
|
// cover: the dust does not reveal on tap and is non-interactive, so taps fall through to the
|
|
// editor (caret / media-select). Ordered above the poster but BELOW the more/add controls
|
|
// (which were added to `self` in init, so they stay on top and remain tappable).
|
|
if component.isSpoiler, let dimensions = self.dimensions, let signal = self.currentBlurredSignal {
|
|
let blurNode: TransformImageNode
|
|
if let existing = self.blurNode {
|
|
blurNode = existing
|
|
} else {
|
|
blurNode = TransformImageNode()
|
|
blurNode.contentAnimations = []
|
|
self.blurNode = blurNode
|
|
self.insertSubview(blurNode.view, aboveSubview: self.imageNode.view)
|
|
}
|
|
if self.blurBoundMediaId != component.media.id {
|
|
self.blurBoundMediaId = component.media.id
|
|
blurNode.setSignal(signal)
|
|
}
|
|
blurNode.view.isHidden = false
|
|
blurNode.frame = CGRect(origin: .zero, size: availableSize)
|
|
let blurImageSize = dimensions.cgSize.aspectFilled(availableSize)
|
|
let blurApply = blurNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: blurImageSize, boundingSize: availableSize, intrinsicInsets: UIEdgeInsets(), resizeMode: .blurBackground, emptyColor: emptyColor))
|
|
blurApply()
|
|
|
|
let dustNode: MediaDustNode
|
|
if let existing = self.dustNode {
|
|
dustNode = existing
|
|
} else {
|
|
dustNode = MediaDustNode(enableAnimations: context.sharedContext.energyUsageSettings.fullTranslucency)
|
|
dustNode.revealOnTap = false
|
|
dustNode.isUserInteractionEnabled = false
|
|
self.dustNode = dustNode
|
|
self.insertSubview(dustNode.view, aboveSubview: blurNode.view)
|
|
}
|
|
dustNode.view.isHidden = false
|
|
dustNode.view.frame = CGRect(origin: .zero, size: availableSize)
|
|
dustNode.update(size: availableSize, color: .white, transition: .immediate)
|
|
} else {
|
|
self.blurNode?.view.isHidden = true
|
|
self.dustNode?.view.isHidden = true
|
|
}
|
|
|
|
self.currentSize = availableSize
|
|
self.bindPreuploadIfNeeded(context: context, media: component.media)
|
|
self.updateStatusNode(availableSize: availableSize, isSpoiler: component.isSpoiler)
|
|
|
|
let buttonSize = CGSize(width: 36.0, height: 36.0)
|
|
let buttonHorizontalInset: CGFloat = 8.0
|
|
let buttonVerticalInset: CGFloat = 8.0
|
|
|
|
let moreButtonFrame = CGRect(origin: CGPoint(x: buttonHorizontalInset, y: buttonVerticalInset), size: buttonSize)
|
|
|
|
transition.setFrame(view: self.moreButtonBackgroundContainer, frame: moreButtonFrame)
|
|
self.moreButtonBackgroundContainer.update(size: buttonSize, isDark: true, transition: transition)
|
|
|
|
transition.setFrame(view: self.moreButtonBackground, frame: CGRect(origin: CGPoint(), size: moreButtonFrame.size))
|
|
self.moreButtonBackground.update(size: moreButtonFrame.size, cornerRadius: moreButtonFrame.height * 0.5, isDark: true, tintColor: .init(kind: .panel), transition: transition)
|
|
|
|
transition.setFrame(view: self.moreButton, frame: CGRect(origin: CGPoint(), size: moreButtonFrame.size))
|
|
|
|
let buttonIconSize = self.moreButtonIcon.update(
|
|
transition: transition,
|
|
component: AnyComponent(LottieComponent(
|
|
content: LottieComponent.AppBundleContent(name: "anim_baremoredots"),
|
|
color: .white,
|
|
startingPosition: .begin,
|
|
lottieSettings: component.context.lottieRenderingSettings
|
|
)),
|
|
environment: {},
|
|
containerSize: buttonSize
|
|
)
|
|
if let moreButtonIconView = self.moreButtonIcon.view {
|
|
if moreButtonIconView.superview == nil {
|
|
self.moreButtonBackground.contentView.addSubview(moreButtonIconView)
|
|
}
|
|
transition.setFrame(view: moreButtonIconView, frame: buttonIconSize.centered(in: CGRect(origin: CGPoint(), size: buttonSize)))
|
|
}
|
|
|
|
self.moreButtonBackgroundContainer.isHidden = !component.showsMoreButton
|
|
|
|
return availableSize
|
|
}
|
|
}
|
|
|
|
public func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|