mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-06-20 18:24:43 +00:00
1d82735d61
Renders `RichText.image(id:, dimensions:)` runs as real fetched images inside `ChatMessageRichDataBubbleContentNode` / `InstantPageV2View`, replacing the gray `InstantPageV2MediaPlaceholderView`. Source is whatever populates `instantPage.media[id]` (server-pushed webpage InstantPages); the markdown converter is unchanged (`markdownImageParsingEnabled` stays false). Architecture mirrors inline custom emoji: inline images are NOT top-level `InstantPageV2LaidOutItem`s. `InstantPageV2View` walks each text view's `line.imageItems`, resolves each MediaId against `layout.media`, and creates `InstantPageV2InlineImageView` children inside a new `imageContainerView` on each `InstantPageV2TextView` (sibling of `renderContainer`, above the reveal mask, below `emojiContainerView`). Streaming reveal participation: image cells contribute their full width to the per-line character rects (mirrors emoji), so the cost map charges the cursor by the image's width when crossing it. When the cursor crosses the cell, `updateImageReveal` pops the view in (opacity 0→1 + scale 0.1→1.0 over 0.2s ease-out), matching `updateEmojiReveal` exactly. `applyReveal` calls both alongside each other. Non-interactive (V1 parity): `isUserInteractionEnabled = false` lets URL-wrapping `RichText.url(text: .image(...))` route taps through to the underlying text view's URL handler. Key files: - New `InstantPageV2InlineImageView.swift`: lightweight `TransformImageNode`-backed view. Picks the fetch signal by EngineMedia kind (.image → chatMessagePhoto; image-mime .file → instantPageImageFile; video .file → chatMessageVideo single frame). `MetaDisposable` cancels fetches on dealloc. - `InstantPageV2Layout.swift`: `media`/`webpage` fields on the layout struct; image cells contribute to char rects; the gray-placeholder branch in `layoutTextItem` is removed; the dead `media`/`webpage` params on `layoutTextItem` are dropped along with their 13 call sites. - `InstantPageRenderer.swift`: `imageContainerView` on text view (sibling of `renderContainer`, between it and `emojiContainerView`); `InlineImageKey` + `InstantPageInlineImageData` types; `updateInlineImages()` + `updateImageReveal()` on `InstantPageV2View`; wired into `update(layout:theme:animation:)`. - `InstantPageV2RevealCost.swift`: `applyReveal` calls `updateImageReveal` next to `updateEmojiReveal` in both the clear-path branch and the entry-walking branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
5.9 KiB
Swift
135 lines
5.9 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import PhotoResources
|
|
import MediaResources
|
|
|
|
/// Lightweight inline image view for InstantPage V2 — wraps a `TransformImageNode`
|
|
/// to render a single `RichText.image` cell inside a text view.
|
|
///
|
|
/// Owned by `InstantPageV2View` (not an `InstantPageItemView` conformer; not in
|
|
/// the view-factory switch). Hosted inside the parent text view's
|
|
/// `imageContainerView` (sibling of `renderContainer`, above the reveal mask,
|
|
/// below `emojiContainerView`), so the streaming reveal can wipe text glyphs
|
|
/// while the image pops in independently. Non-interactive — taps pass through
|
|
/// to the text view, so a URL-wrapping `RichText.url(text: .image(...))`
|
|
/// continues to route taps to the URL handler.
|
|
final class InstantPageV2InlineImageView: UIView {
|
|
let fileId: Int64
|
|
private let imageNode: TransformImageNode
|
|
private let media: EngineMedia
|
|
private let theme: InstantPageTheme
|
|
private let fetchedDisposable = MetaDisposable()
|
|
|
|
init(media: EngineMedia,
|
|
webpage: TelegramMediaWebpage?,
|
|
frame: CGRect,
|
|
context: AccountContext,
|
|
userLocation: MediaResourceUserLocation,
|
|
theme: InstantPageTheme) {
|
|
self.media = media
|
|
self.theme = theme
|
|
self.fileId = media.id?.id ?? 0
|
|
self.imageNode = TransformImageNode()
|
|
|
|
super.init(frame: frame)
|
|
self.isUserInteractionEnabled = false
|
|
self.addSubview(self.imageNode.view)
|
|
|
|
self.bindSignal(webpage: webpage, context: context, userLocation: userLocation)
|
|
self.applyLayout(size: frame.size)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
deinit {
|
|
self.fetchedDisposable.dispose()
|
|
}
|
|
|
|
private func bindSignal(webpage: TelegramMediaWebpage?,
|
|
context: AccountContext,
|
|
userLocation: MediaResourceUserLocation) {
|
|
// Without a webpage we can't form a `WebpageReference` for the standard
|
|
// chat-message signals, so the image node stays at its empty colour.
|
|
guard let webpage = webpage else { return }
|
|
let webPageRef = WebpageReference(webpage)
|
|
|
|
switch self.media {
|
|
case let .image(image):
|
|
let imageReference = ImageMediaReference.webPage(webPage: webPageRef, media: image)
|
|
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox,
|
|
userLocation: userLocation,
|
|
photoReference: imageReference))
|
|
// Non-interactive: always auto-fetch so the image arrives without a tap.
|
|
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context,
|
|
userLocation: userLocation,
|
|
photoReference: imageReference,
|
|
displayAtSize: nil,
|
|
storeToDownloadsPeerId: nil).start())
|
|
case let .file(file):
|
|
let fileReference = FileMediaReference.webPage(webPage: webPageRef, media: file)
|
|
if file.mimeType.hasPrefix("image/") {
|
|
self.fetchedDisposable.set(freeMediaFileInteractiveFetched(account: context.account,
|
|
userLocation: userLocation,
|
|
fileReference: fileReference).start())
|
|
self.imageNode.setSignal(instantPageImageFile(account: context.account,
|
|
userLocation: userLocation,
|
|
fileReference: fileReference,
|
|
fetched: true))
|
|
} else {
|
|
// Video / animated file: render a single still frame. No play overlay.
|
|
self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox,
|
|
userLocation: userLocation,
|
|
videoReference: fileReference))
|
|
}
|
|
default:
|
|
// RichText.image's MediaId resolves to .image or .file in practice; other
|
|
// EngineMedia kinds (geo, webpage, story, ...) leave the image node blank.
|
|
break
|
|
}
|
|
}
|
|
|
|
private func applyLayout(size: CGSize) {
|
|
guard size.width > 0, size.height > 0 else { return }
|
|
self.imageNode.frame = CGRect(origin: .zero, size: size)
|
|
|
|
let intrinsic: CGSize
|
|
switch self.media {
|
|
case let .image(image):
|
|
if let largest = largestImageRepresentation(image.representations) {
|
|
intrinsic = largest.dimensions.cgSize
|
|
} else {
|
|
intrinsic = size
|
|
}
|
|
case let .file(file):
|
|
intrinsic = file.dimensions?.cgSize ?? size
|
|
default:
|
|
intrinsic = size
|
|
}
|
|
|
|
let imageSize = intrinsic.aspectFilled(size)
|
|
let arguments = TransformImageArguments(
|
|
corners: ImageCorners(),
|
|
imageSize: imageSize,
|
|
boundingSize: size,
|
|
intrinsicInsets: UIEdgeInsets(),
|
|
emptyColor: nil
|
|
)
|
|
let apply = self.imageNode.asyncLayout()(arguments)
|
|
apply()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
if self.imageNode.frame.size != self.bounds.size {
|
|
self.applyLayout(size: self.bounds.size)
|
|
}
|
|
}
|
|
}
|