Files
Telegram-iOS/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift
T
isaacisaacClaude Opus 4.7
1d82735d61 InstantPage V2: render inline RichText images
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>
2026-05-28 14:21:25 +02:00

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)
}
}
}