From 1d82735d6169cc7b50e9759936dd0638aed614b1 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 28 May 2026 14:21:25 +0200 Subject: [PATCH 1/2] InstantPage V2: render inline RichText images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Sources/InstantPageRenderer.swift | 144 ++++++++++++++++++ .../InstantPageV2InlineImageView.swift | 134 ++++++++++++++++ .../Sources/InstantPageV2Layout.swift | 84 +++++----- .../Sources/InstantPageV2RevealCost.swift | 2 + 4 files changed, 316 insertions(+), 48 deletions(-) create mode 100644 submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index f98b2e6574..26c85c8650 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -80,6 +80,27 @@ public final class InstantPageV2RenderContext { } } +// MARK: - Inline image view data + +private struct InlineImageKey: Hashable { + let fileId: Int64 + let occurrenceIndex: Int +} + +/// Per-inline-image state owned by `InstantPageV2View`. +/// `textView` is a weak reference back to the parent so `updateImageReveal` can +/// look up the current per-text-view character count. +private final class InstantPageInlineImageData { + let view: InstantPageV2InlineImageView + weak var textView: InstantPageV2TextView? + var charIndexInItem: Int = 0 + var revealed: Bool = false + + init(view: InstantPageV2InlineImageView) { + self.view = view + } +} + // MARK: - Emoji layer data private final class InstantPageEmojiLayerData { @@ -108,6 +129,7 @@ public final class InstantPageV2View: UIView { public let renderContext: InstantPageV2RenderContext? private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InstantPageEmojiLayerData] = [:] + private var inlineImageViews: [InlineImageKey: InstantPageInlineImageData] = [:] private var emojiEnableLooping: Bool = true /// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping. @@ -234,6 +256,7 @@ public final class InstantPageV2View: UIView { self.itemViewStableIds = newStableIds self.currentLayout = layout self.currentTheme = theme + self.updateInlineImages() self.updateInlineEmoji() let enableSpoilerAnimations = self.renderContext.map { $0.context.sharedContext.energyUsageSettings.fullTranslucency } ?? true for view in self.itemViews { @@ -332,6 +355,82 @@ public final class InstantPageV2View: UIView { self.updateEmojiReveal(animated: false) } + func updateInlineImages() { + guard let renderContext = self.renderContext, + let layout = self.currentLayout, + let theme = self.currentTheme + else { return } + let context = renderContext.context + + var nextIndexById: [Int64: Int] = [:] + var validKeys: Set = [] + + for view in self.itemViews { + guard let textView = view as? InstantPageV2TextView else { continue } + let textItem = textView.item.textItem + let boundsWidth = textItem.frame.size.width + for line in textItem.lines { + if line.imageItems.isEmpty { continue } + let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment) + for imageItem in line.imageItems { + let index = nextIndexById[imageItem.id.id] ?? 0 + nextIndexById[imageItem.id.id] = index + 1 + let key = InlineImageKey(fileId: imageItem.id.id, occurrenceIndex: index) + guard let media = layout.media[imageItem.id] else { continue } + validKeys.insert(key) + + let localX = lineFrame.minX + (imageItem.frame.minX - line.frame.minX) + let itemFrame = CGRect( + x: localX + v2TextViewClippingInset, + y: imageItem.frame.minY + v2TextViewClippingInset, + width: imageItem.frame.width, + height: imageItem.frame.height + ) + + let data: InstantPageInlineImageData + if let existing = self.inlineImageViews[key] { + data = existing + if data.view.superview !== textView.imageContainerView { + textView.imageContainerView.addSubview(data.view) + } + } else { + let newView = InstantPageV2InlineImageView( + media: media, + webpage: layout.webpage, + frame: itemFrame, + context: context, + userLocation: .other, + theme: theme + ) + // Image starts hidden; updateImageReveal pops it in when the streaming + // cursor crosses its char-index. For non-streaming pages (no + // `TypingDraftMessageAttribute`), `revealed = true` (via the fallback in + // updateImageReveal) sets alpha = 1 unconditionally on the next call. + newView.alpha = 0.0 + data = InstantPageInlineImageData(view: newView) + self.inlineImageViews[key] = data + textView.imageContainerView.addSubview(newView) + } + + data.view.frame = itemFrame + data.textView = textView + data.charIndexInItem = imageItem.range.location + } + } + } + + var removeKeys: [InlineImageKey] = [] + for (key, data) in self.inlineImageViews where !validKeys.contains(key) { + removeKeys.append(key) + data.view.removeFromSuperview() + } + for key in removeKeys { + self.inlineImageViews.removeValue(forKey: key) + } + + self.updateImageReveal(animated: false) + } + func updateEmojiReveal(animated: Bool) { for (_, data) in self.inlineStickerItemLayers { let revealed: Bool @@ -368,6 +467,41 @@ public final class InstantPageV2View: UIView { self.updateEmojiVisibility() } + func updateImageReveal(animated: Bool) { + for (_, data) in self.inlineImageViews { + let revealed: Bool + if let textView = data.textView, let count = textView.currentRevealCharacterCount { + revealed = data.charIndexInItem < count + } else { + revealed = true + } + if data.revealed == revealed { + continue + } + data.revealed = revealed + if revealed { + if animated { + data.view.layer.opacity = 1.0 + let opacityAnim = CABasicAnimation(keyPath: "opacity") + opacityAnim.fromValue = 0.0 + opacityAnim.toValue = 1.0 + opacityAnim.duration = 0.2 + data.view.layer.add(opacityAnim, forKey: "inlineImageRevealOpacity") + let scaleAnim = CABasicAnimation(keyPath: "transform.scale") + scaleAnim.fromValue = 0.1 + scaleAnim.toValue = 1.0 + scaleAnim.duration = 0.2 + scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut) + data.view.layer.add(scaleAnim, forKey: "inlineImageRevealScale") + } else { + data.view.layer.opacity = 1.0 + } + } else { + data.view.layer.opacity = 0.0 + } + } + } + func updateEmojiVisibility() { for (_, data) in self.inlineStickerItemLayers { let onScreen: Bool @@ -689,6 +823,11 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { private let renderContainer: UIView private let renderView: TextRenderView + /// Sibling of `renderContainer`, holds inline `InstantPageV2InlineImageView`s + /// owned by the parent `InstantPageV2View`. Sits BELOW `emojiContainerView` + /// (so a colocated emoji renders above an image) and ABOVE `renderContainer` + /// (so the reveal mask wipes text without clipping images). + let imageContainerView: UIView = UIView() let emojiContainerView: UIView = UIView() let spoilerContainerView: UIView = UIView() private var dustNode: InvisibleInkDustNode? @@ -726,6 +865,10 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { self.renderView.frame = self.bounds self.renderContainer.addSubview(self.renderView) + self.imageContainerView.frame = self.bounds + self.imageContainerView.isUserInteractionEnabled = false + self.addSubview(self.imageContainerView) + self.emojiContainerView.frame = self.bounds self.emojiContainerView.isUserInteractionEnabled = false self.addSubview(self.emojiContainerView) @@ -745,6 +888,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView { self.item = item self.renderView.item = item self.renderView.setNeedsDisplay() + self.imageContainerView.frame = self.bounds self.emojiContainerView.frame = self.bounds self.spoilerContainerView.frame = self.bounds self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers diff --git a/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift b/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift new file mode 100644 index 0000000000..d0cffb82ee --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageV2InlineImageView.swift @@ -0,0 +1,134 @@ +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) + } + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 94b02ffee0..5d548a2b80 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -14,11 +14,23 @@ public struct InstantPageV2Layout { public let items: [InstantPageV2LaidOutItem] /// Snapshot of the `index` values of every `.details` item present in `items`, captured at layout time. public let detailsIndices: [Int] + /// Media dictionary inherited from the page's `LayoutContext.media`. Used by + /// `InstantPageV2View.updateInlineImages()` to resolve each text view's + /// `line.imageItems` MediaIds at update time. Nested layouts (details body, + /// table cells, table title) carry the parent's same map. + public let media: [EngineMedia.Id: EngineMedia] + /// Webpage carried for the same reason — `updateInlineImages()` needs it to + /// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May + /// be nil for non-webpage-anchored layouts; in that case the lookup proceeds + /// but no fetch signal can be bound (image view simply isn't created). + public let webpage: TelegramMediaWebpage? - public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int]) { + public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int], media: [EngineMedia.Id: EngineMedia] = [:], webpage: TelegramMediaWebpage? = nil) { self.contentSize = contentSize self.items = items self.detailsIndices = detailsIndices + self.media = media + self.webpage = webpage } /// Returns every `InstantPageMedia` produced by this layout (or its nested sub-layouts) @@ -516,7 +528,7 @@ private func layoutBlockSequence( contentSize.width = min(maxX, boundingWidth) } - return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices) + return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices, media: context.media, webpage: context.webpage) } private func layoutBlock( @@ -920,8 +932,6 @@ private func layoutDetails( attributedStringForRichText(title, styleStack: titleStyleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron offset: CGPoint(x: horizontalInset, y: 12.0), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1023,8 +1033,6 @@ private func layoutTable( attrStr, boundingWidth: cellWidthLimit - totalCellPadding, offset: CGPoint(), - media: context.media, - webpage: context.webpage, minimizeWidth: true, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -1035,8 +1043,6 @@ private func layoutTable( attrStr, boundingWidth: cellWidthLimit - totalCellPadding, offset: CGPoint(), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ).0 { @@ -1304,7 +1310,7 @@ private func layoutTable( // Translate all items in the sub-layout by the inset. let delta = CGPoint(x: horizOffset, y: vertOffset) let translatedItems = sl.items.map { $0.offsetBy(delta) } - sl = InstantPageV2Layout(contentSize: sl.contentSize, items: translatedItems, detailsIndices: sl.detailsIndices) + sl = InstantPageV2Layout(contentSize: sl.contentSize, items: translatedItems, detailsIndices: sl.detailsIndices, media: sl.media, webpage: sl.webpage) subLayout = sl } @@ -1508,8 +1514,6 @@ private func layoutCaptionAndCredit( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: y), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1537,8 +1541,6 @@ private func layoutCaptionAndCredit( boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: rtl ? .right : .natural, offset: CGPoint(x: horizontalInset, y: y), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1679,8 +1681,6 @@ private func layoutSimpleText( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1701,8 +1701,6 @@ private func layoutHeading( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1726,8 +1724,6 @@ private func layoutParagraph( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1798,8 +1794,6 @@ private func layoutAuthorDate( boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: alignment, offset: CGPoint(x: horizontalInset, y: 0.0), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1862,8 +1856,6 @@ private func layoutCodeBlock( attributedString, boundingWidth: innerWidth, offset: CGPoint(x: 0.0, y: 0.0), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, opaqueBackground: true, computeRevealCharacterRects: context.computeRevealCharacterRects @@ -1946,8 +1938,6 @@ private func layoutBlockQuote( boundingWidth: textBoundingWidth, alignment: textAlignment, offset: CGPoint(x: textX, y: contentHeight), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -1969,8 +1959,6 @@ private func layoutBlockQuote( boundingWidth: textBoundingWidth, alignment: textAlignment, offset: CGPoint(x: textX, y: contentHeight), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -2126,8 +2114,6 @@ private func layoutList( attrStr, boundingWidth: textWidth, offset: CGPoint(x: textX, y: contentHeight), - media: context.media, - webpage: context.webpage, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) @@ -2411,8 +2397,10 @@ private func attributedStringForPreformattedText(_ text: RichText, language: Str // MARK: - V2 text-item layout (ported from V1 InstantPageTextItem.swift layoutTextItemWithString) // // V0 difference from V1: -// * Inline image runs produce `.mediaPlaceholder(kind: .image, frame:, cornerRadius: 0)` items -// instead of `InstantPageImageItem`. +// * Inline image runs are NOT emitted as items here. They are discovered at view-update time +// by `InstantPageV2View.updateInlineImages()`, which walks each text view's `line.imageItems` +// and creates `InstantPageV2InlineImageView`s attached to the text view's `imageContainerView` +// (the pop-in animation mirrors the inline custom-emoji ownership model). // * Inline formula runs produce `.formula(InstantPageV2FormulaItem(...))` items carrying the // rendered math image (see `InstantPageV2FormulaView`); the line's `formulaItems` field // already provides the attachment + frame. @@ -2485,8 +2473,6 @@ func layoutTextItem( horizontalInset: CGFloat = 0.0, alignment: NSTextAlignment = .natural, offset: CGPoint, - media: [EngineMedia.Id: EngineMedia] = [:], - webpage: TelegramMediaWebpage? = nil, minimizeWidth: Bool = false, fitToWidth: Bool = false, maxNumberOfLines: Int = 0, @@ -2814,6 +2800,17 @@ func layoutTextItem( rects[localIndex] = CGRect(x: x, y: 0.0, width: emoji.size, height: emoji.size) } } + for image in pendingImages { + let localIndex = image.range.location - lineRange.location + if localIndex >= 0 && localIndex < rects.count { + let x = CTLineGetOffsetForStringIndex(line, image.range.location, nil) + // Image cell sits bottom-on-baseline (frame loop: y = baselineY - image.size.height). + // Baseline-relative cell: y = 0, height = image.size.height. The full width feeds + // the reveal cost map so the streaming cursor is charged the image's width when + // crossing it — same as an emoji cell. + rects[localIndex] = CGRect(x: x, y: 0.0, width: image.size.width, height: image.size.height) + } + } lineCharacterRects = rects } else { lineCharacterRects = nil @@ -2870,21 +2867,12 @@ func layoutTextItem( let effectiveOffset = offset for line in textItem.lines { let lineFrame = v2FrameForLine(line, boundingWidth: boundingWidth, alignment: alignment) - if let _ = webpage { - for imageItem in line.imageItems { - if media[imageItem.id] != nil { - let placeholderFrame = imageItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y) - let placeholder = InstantPageV2MediaPlaceholderItem( - frame: placeholderFrame, - kind: .image, - cornerRadius: 0.0 - ) - additionalItems.append(.mediaPlaceholder(placeholder)) - if placeholderFrame.minY < topInset { topInset = placeholderFrame.minY } - if placeholderFrame.maxY > height { bottomInset = max(bottomInset, placeholderFrame.maxY - height) } - } - } - } + // Inline images (RichText.image) are NOT emitted as top-level items here. They are + // discovered at view-update time by InstantPageV2View.updateInlineImages(), which + // walks each text view's `line.imageItems` and creates an InstantPageV2InlineImageView + // attached to the text view's imageContainerView. The pop-in animation reuses the + // emoji-style ownership model. See the inline-image design doc: + // docs/superpowers/specs/2026-05-28-instantpage-v2-inline-image-design.md. for formulaItem in line.formulaItems { let formulaFrame = formulaItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y) let item = InstantPageV2FormulaItem( diff --git a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift index b9fa1c5dd6..6e255c8e32 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift @@ -351,6 +351,7 @@ public extension InstantPageV2View { clearRevealOn(view: view, animated: animated) } self.updateEmojiReveal(animated: animated) + self.updateImageReveal(animated: animated) return } @@ -370,6 +371,7 @@ public extension InstantPageV2View { } } self.updateEmojiReveal(animated: animated) + self.updateImageReveal(animated: animated) } } From c13118921d1fa16e995c1c5bf320d07e5abca252 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 28 May 2026 14:26:40 +0200 Subject: [PATCH 2/2] Sync watch app --- .../Messages/MessageListView.swift | 10 +++- .../tgwatch Watch App/Messages/ReplyBar.swift | 52 ++++++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift index 06c28e3d42..cd691449f0 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/MessageListView.swift @@ -250,8 +250,16 @@ struct MessageListView: View { } } .padding(.horizontal, 4) - .padding(.vertical, 4) + .padding(.top, 4) + // 19pt bottom content inset so the ReplyBar's "+" and + // pill sit 19pt above the *physical* screen edge when + // the chat is scrolled to the tail. Combined with + // .ignoresSafeArea(edges: .bottom) on the ScrollView + // below — without that, the system bottom safe-area + // adds extra space and the total inset overshoots. + .padding(.bottom, 19) } + .ignoresSafeArea(edges: .bottom) .defaultScrollAnchor(.bottom) .environment(store) .task { diff --git a/Telegram/WatchApp/tgwatch Watch App/Messages/ReplyBar.swift b/Telegram/WatchApp/tgwatch Watch App/Messages/ReplyBar.swift index 2daf4adf39..8e19ccbb47 100644 --- a/Telegram/WatchApp/tgwatch Watch App/Messages/ReplyBar.swift +++ b/Telegram/WatchApp/tgwatch Watch App/Messages/ReplyBar.swift @@ -4,17 +4,25 @@ struct ReplyBar: View { let onAttachTap: () -> Void let onTextTap: () -> Void + // Circular "+" button and reply-field capsule share a single pill + // diameter so they line up vertically and visually mirror the + // watchOS-26 system nav back chevron. The "+" is inset 11pt from the + // chat-content's leading edge so that, combined with the ScrollView's + // outer .padding(.horizontal, 4), it sits 15pt from the screen edge — + // aligned with the system back-chevron in a pushed view. + private static let pillHeight: CGFloat = 35 + var body: some View { HStack(spacing: 6) { Button(action: onAttachTap) { Image(systemName: "plus") - .font(.system(size: 14)) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .contentShape(Rectangle()) - .glassEffect() + .font(.system(size: 16, weight: .semibold)) + .frame(width: Self.pillHeight, height: Self.pillHeight) + .contentShape(Circle()) + .glassEffect(in: Circle()) } .buttonStyle(.plain) + .padding(.leading, 11) .accessibilityIdentifier("replyBarAttach") Button(action: onTextTap) { @@ -25,7 +33,7 @@ struct ReplyBar: View { Spacer(minLength: 0) } .padding(.horizontal, 12) - .padding(.vertical, 8) + .frame(height: Self.pillHeight) .contentShape(Rectangle()) .glassEffect() } @@ -35,3 +43,35 @@ struct ReplyBar: View { } } } + +#if DEBUG +// Side-by-side reference for the round "+" button. SnapshotPreviews doesn't +// render NavigationStack chrome, so the watchOS-26 system back chevron is +// mocked (same glass-Circle shape) at its in-app position (15pt leading, +// 6pt top) for a direct visual compare against the "+" button at the +// ReplyBar's in-app position (15pt leading, 19pt bottom). Frame matches +// the 46mm canvas (208×248 logical pts). +private struct ReplyBarGeometryPreviewHost: View { + var body: some View { + ZStack { + Color.black + + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .frame(width: 35, height: 35) + .glassEffect(in: Circle()) + .padding(.leading, 15) + .padding(.top, 6) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + ReplyBar(onAttachTap: {}, onTextTap: {}) + .padding(.horizontal, 4) + .padding(.bottom, 19) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .frame(width: 208, height: 248) + } +} + +#Preview("Reply bar vs nav back") { ReplyBarGeometryPreviewHost() } +#endif