From ab68a1af8299077ba9b9f012c2ae99f82f2040a3 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 2 Jun 2026 13:50:40 +0200 Subject: [PATCH] InstantPage V2: render collage & slideshow blocks Port V1's collage and slideshow InstantPage blocks into the V2 renderer (previously grey-box placeholders). - Collage: layoutCollage computes the mosaic (MosaicLayout, the grouped-message engine) over inner image/video sizes and flattens it into existing top-level .mediaImage/.mediaVideo items + a caption, so gallery / reveal-cost / registry / hidden-media all handle the cells with no collage-specific code. Right-edge cells bleed 4pt for the bubble's rounded clip. - Slideshow: a new .slideshow laid-out item + InstantPageV2SlideshowView, an eager paged carousel (UIScrollView + PageControlNode) of InstantPageImageNode pages, wired through frame/offsetBy/collectMedias/stableId/reuse/makeItemView and the reveal-cost non-text list. - Gallery transitions generalized onto InstantPageItemView via instantPageTransitionNode(for:)/instantPageUpdateHiddenMedia(_:) (default nil/no-op; explicit per-class witnesses on the 4 static media views, the slideshow forwards to its live pages) so the multi-media slideshow can participate alongside single-media views. Docs: document both in docs/instantpage-richtext.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/instantpage-richtext.md | 24 ++- .../Sources/InstantPageRenderer.swift | 36 ++-- .../Sources/InstantPageV2Layout.swift | 159 ++++++++++++++- .../Sources/InstantPageV2MediaViews.swift | 38 +++- .../Sources/InstantPageV2RevealCost.swift | 2 +- .../Sources/InstantPageV2SlideshowView.swift | 183 ++++++++++++++++++ 6 files changed, 415 insertions(+), 27 deletions(-) create mode 100644 submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift diff --git a/docs/instantpage-richtext.md b/docs/instantpage-richtext.md index 8c5dbce027..31aceb8acc 100644 --- a/docs/instantpage-richtext.md +++ b/docs/instantpage-richtext.md @@ -63,7 +63,7 @@ Every V2 block-media kind **except `.audio`** lays out **flush** with the bubble | File | Responsibility | |---|---| -| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `instantPageV2MediaFrame(naturalSize:flush:cornerRadius:boundingWidth:horizontalInset:)` — the shared frame helper; `instantPageV2MediaEdgeBleed` constant; the `flush: Bool` parameter on `layoutTypedMediaWithCaption` (image/video/webEmbed-cover/map) and `layoutMediaWithCaption` (audio/webEmbed-placeholder/postEmbed/collage/slideshow/channelBanner/relatedArticles). | +| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `instantPageV2MediaFrame(naturalSize:flush:cornerRadius:boundingWidth:horizontalInset:)` — the shared frame helper; `instantPageV2MediaEdgeBleed` constant; the `flush: Bool` parameter on `layoutTypedMediaWithCaption` (image/video/webEmbed-cover/map) and `layoutMediaWithCaption` (audio/webEmbed-placeholder/postEmbed/channelBanner/relatedArticles). (Collage/slideshow no longer route through `layoutMediaWithCaption` — see the collage & slideshow section.) | | `submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift`, `…/InstantPageRenderer.swift` (`InstantPageV2MediaPlaceholderView`) | Renderer — **no change needed**: every media view + the placeholder view already does `clipsToBounds = item.cornerRadius > 0.0`, so cornerRadius 0 means the view doesn't self-clip; the bubble's `containerNode` clips. | | `…/Chat/ChatMessageRichDataBubbleContentNode/…` | The clipping container: `containerNode` (`clipsToBounds = true`, `cornerRadius = layoutConstants.image.defaultCornerRadius` ≈ 15–16pt) is what rounds flush media at the bubble edge. | @@ -76,6 +76,28 @@ Every V2 block-media kind **except `.audio`** lays out **flush** with the bubble - **Audio is the lone exception** and routes through the non-flush branch of `instantPageV2MediaFrame` (inset by `horizontalInset`, caller's cornerRadius), reproducing the legacy behavior exactly. - **`.map` blocks get a 600×300 (2:1) fallback when the sender omits dimensions.** AI/server-sent `.map` blocks can arrive with `dimensions == 0×0` (the wire `w`/`h` are *required* `Int32`, but the sender may put 0; our `pageBlockMap` parse and both serializers — Postbox `sw`/`sh`, FlatBuffers `required dimensions` — preserve whatever arrives, so the zero originates upstream). A zero `naturalSize.height` hits `instantPageV2MediaFrame`'s `else` branch and returns a **height-0** frame: the map collapses to no space, the caption slides up into it, and the V1 node's pin (positioned at `size.height*0.5 − 10 − pinSize/2`) floats over the caption. **The `.map` arm in `InstantPageV2Layout.swift` substitutes `PixelDimensions(600, 300)` whenever `width <= 0 || height <= 0`, and feeds that `effectiveDimensions` to BOTH the layout `naturalSize` AND the `InstantPageMapAttribute`** — the latter is essential because a `MapSnapshotMediaResource(width:0,height:0)` makes `MKMapSnapshotter` render nothing, so fixing only the frame would yield a correctly-sized *blank* box. Real web-article maps (the V1 renderer) always carry real dimensions, so V1 never trips this; the fallback is deliberately scoped to the V2 `.map` arm rather than V1 or the wire/parse layer. +## InstantPage V2 collage & slideshow blocks + +`InstantPageBlock.collage` and `.slideshow` (grouped photos/videos with a caption — only ever produced by **real web Instant View articles**; nothing on the markdown/AI path emits them) render in V2 by porting V1. Collage flattens into the existing media-item machinery; slideshow is a dedicated interactive carousel. + +### Where things live + +| File | Responsibility | +|---|---| +| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `layoutCollage(...)` — mosaic via `chatMessageBubbleMosaicLayout` (the `MosaicLayout` module, same engine grouped messages use), emitting one existing `.mediaImage`/`.mediaVideo` item per cell. `layoutSlideshow(...)` + the `InstantPageV2SlideshowItem` laid-out item (+ its `frame`/`offsetBy`/`collectMedias` arms). | +| `submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift` | The carousel view: a paged `UIScrollView` of `InstantPageImageNode` pages + a `PageControlNode`, with all pages created **eagerly**. | +| `…/InstantPageRenderer.swift` | `InstantPageItemView.instantPageTransitionNode(for:)` / `instantPageUpdateHiddenMedia(_:)` (gallery hooks, nil/no-op defaults); `transitionArgsFor`/`applyHiddenMedia` dispatch through them. The `.slideshow` arms in `InstantPageV2ItemKind`/`stableId`/`reuse`/`makeItemView`. | +| `…/InstantPageV2RevealCost.swift` | `.slideshow` is a non-text reveal entry (collage cells already are, being top-level media items). | + +### Non-obvious invariants + +- **Collage is a flatten, not a container.** `layoutCollage` computes the mosaic, then emits each cell as an ordinary top-level `.mediaImage`/`.mediaVideo` item (cornerRadius 0) into the parent layout — exactly as V1 does (`flattenedItemsWithOrigin`). Consequence: gallery enumeration (`allMedias`), the media registry, hidden-media, the reveal-cost map, and view reuse all handle collage cells **for free**, with no collage-specific code in any of those subsystems. There is **no** `.collage` laid-out item or view. +- **Right-edge collage cells bleed 4pt** (`instantPageV2MediaEdgeBleed`, applied only to `MosaicItemPosition.right` cells) for the same bubble-rounded-clip reason as full-width single media; interior gaps are the mosaic's 1pt spacing; outer corners are rounded by the bubble's `containerNode`. +- **Slideshow IS a container** (it's swipeable), so it gets its own laid-out item + view, unlike collage. Adding the `.slideshow` case to `InstantPageV2LaidOutItem` forces a `.slideshow` arm in every no-`default` switch over it: `frame`, `offsetBy`, `stableId`, `reuse`, `makeItemView`, and the reveal-cost `computeEntries` (plus `collectMedias`, which has a `default` but needs the arm to enumerate slideshow medias for the gallery). +- **Slideshow pages are created eagerly, deviating from V1's lazy central±1 paging.** In a chat bubble a slideshow is a handful of images, so eager creation avoids V1's index bookkeeping and makes the gallery transition source available for **every** page (even off-screen). Height = the tallest image `fitted(boundingWidth × 1200)`; only `.image` inner blocks render (matches V1 — videos become empty pages). +- **The slideshow registers under EVERY contained media index, and re-registers on an in-window rebuild.** Its stableId is positional (`.positional(.slideshow, position)`, not `.media(index)` like the static media views), so it can be reused for a *different* slideshow at the same block position; `rebuildPages()` re-runs `registerMedias()` (guarded by `window != nil`) so the new indices land in the registry. The gallery hooks iterate the live page nodes and match by `InstantPageMedia` identity, so registering one view under N indices is idempotent. +- **The 4 static media views answer the gallery hooks with explicit per-class witnesses, NOT a shared protocol-extension override** — an extension-only implementation is statically dispatched and would silently bind to the nil default when invoked through the `InstantPageItemView`-typed registry wrapper. + ## InstantPage V2 text item height (true font line box) `layoutTextItem` (`InstantPageV2Layout.swift`) sizes a `.text` item to the **true font line height**, not the cap box. A single-line item measures exactly `fontAscent + fontDescentBelowBaseline` (`A + D`); the old behavior was the cap box `fontLineHeight = floor(fontAscent + fontDescent)` (`A − D`). diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index b8c0f3fe5e..1093b02258 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import AsyncDisplayKit import Display import CheckNode import SwiftSignalKit @@ -35,7 +36,7 @@ public enum InstantPageV2StableItemId: Hashable { } public enum InstantPageV2ItemKind: Hashable { - case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula + case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula, slideshow } // MARK: - Render context @@ -679,6 +680,10 @@ public final class InstantPageV2View: UIView { guard let v = existingView as? InstantPageV2ThinkingView else { return nil } v.update(item: thinking, theme: theme) return v + case let .slideshow(slideshow): + guard let v = existingView as? InstantPageV2SlideshowView, let rc = self.renderContext else { return nil } + v.update(item: slideshow, theme: theme, renderContext: rc) + return v } } @@ -700,6 +705,7 @@ public final class InstantPageV2View: UIView { case .anchor: return .positional(.anchor, position) case .formula: return .positional(.formula, position) case .thinking: return .thinking(position) + case .slideshow: return .positional(.slideshow, position) } } @@ -724,13 +730,8 @@ public final class InstantPageV2View: UIView { guard let wrapperBox = self.trueRegistryRoot.mediaRegistry[media.index], let wrapper = wrapperBox.value else { return nil } - let imageNode: InstantPageImageNode? = - (wrapper as? InstantPageV2MediaImageView)?.wrappedNode - ?? (wrapper as? InstantPageV2MediaVideoView)?.wrappedNode - ?? (wrapper as? InstantPageV2MediaMapView)?.wrappedNode - ?? (wrapper as? InstantPageV2MediaCoverImageView)?.wrappedNode - guard let imageNode else { return nil } - guard let transitionNode = imageNode.transitionNode(media: media) else { return nil } + guard let itemView = wrapper as? InstantPageItemView else { return nil } + guard let transitionNode = itemView.instantPageTransitionNode(for: media) else { return nil } return GalleryTransitionArguments(transitionNode: transitionNode, addToTransitionSurface: addToTransitionSurface) } @@ -739,10 +740,7 @@ public final class InstantPageV2View: UIView { func applyHiddenMedia(_ hidden: InstantPageMedia?) { for (_, weakBox) in self.trueRegistryRoot.mediaRegistry { guard let wrapper = weakBox.value else { continue } - if let v = wrapper as? InstantPageV2MediaImageView { v.wrappedNode.updateHiddenMedia(media: hidden) } - if let v = wrapper as? InstantPageV2MediaVideoView { v.wrappedNode.updateHiddenMedia(media: hidden) } - if let v = wrapper as? InstantPageV2MediaMapView { v.wrappedNode.updateHiddenMedia(media: hidden) } - if let v = wrapper as? InstantPageV2MediaCoverImageView { v.wrappedNode.updateHiddenMedia(media: hidden) } + (wrapper as? InstantPageItemView)?.instantPageUpdateHiddenMedia(hidden) } } @@ -800,6 +798,12 @@ public final class InstantPageV2View: UIView { return InstantPageV2FormulaView(item: formula, theme: theme) case let .thinking(thinking): return InstantPageV2ThinkingView(item: thinking, theme: theme) + case let .slideshow(slideshow): + if let renderContext = self.renderContext { + return InstantPageV2SlideshowView(item: slideshow, renderContext: renderContext, theme: theme) + } else { + return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: slideshow.frame, kind: .slideshow, cornerRadius: 0.0), theme: theme) + } } } @@ -853,10 +857,18 @@ protocol InstantPageItemView: UIView { var itemFrame: CGRect { get } /// Recursion hook for nested layouts (details body, table cells, table title). var subLayoutView: InstantPageV2View? { get } + /// Gallery open: the transition source for `media` if this view (or a descendant) shows it. + /// Default nil (non-media views). Media views forward to their wrapped `InstantPageImageNode`; + /// the slideshow forwards to its matching page. + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? + /// Gallery hidden-media tick: hide/show the source for `media`. Default no-op. + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) } extension InstantPageItemView { var subLayoutView: InstantPageV2View? { return nil } + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil } + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { } } // MARK: - Text view (port of V1 InstantPageTextItem.drawInTile) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 499417f6b1..62b8675dc7 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -6,6 +6,7 @@ import TelegramPresentationData import TelegramUIPreferences import TextFormat import TelegramStringFormatting +import MosaicLayout // MARK: - Public layout data types @@ -49,6 +50,7 @@ public struct InstantPageV2Layout { case let .mediaVideo(m): result.append(m.media) case let .mediaMap(m): result.append(m.media) case let .mediaCoverImage(m): result.append(m.media) + case let .slideshow(s): result.append(contentsOf: s.medias) case let .details(d): if let inner = d.innerLayout { collectMedias(in: inner.items, into: &result) @@ -86,6 +88,7 @@ public enum InstantPageV2LaidOutItem { case mediaCoverImage(InstantPageV2MediaCoverImageItem) case formula(InstantPageV2FormulaItem) case thinking(InstantPageV2ThinkingItem) + case slideshow(InstantPageV2SlideshowItem) public var frame: CGRect { switch self { @@ -105,6 +108,7 @@ public enum InstantPageV2LaidOutItem { case let .mediaCoverImage(item): return item.frame case let .formula(item): return item.frame case let .thinking(item): return item.frame + case let .slideshow(item): return item.frame } } @@ -129,6 +133,7 @@ public enum InstantPageV2LaidOutItem { case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item) case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item) case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item) + case var .slideshow(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .slideshow(item) } } } @@ -290,6 +295,18 @@ public struct InstantPageV2MediaPlaceholderItem { public let cornerRadius: CGFloat } +public struct InstantPageV2SlideshowItem { + public var frame: CGRect + public let medias: [InstantPageMedia] + public let webPage: TelegramMediaWebpage + + public init(frame: CGRect, medias: [InstantPageMedia], webPage: TelegramMediaWebpage) { + self.frame = frame + self.medias = medias + self.webPage = webPage + } +} + public struct InstantPageV2DetailsItem { public var frame: CGRect public let index: Int @@ -822,17 +839,13 @@ private func layoutBlock( isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) - case let .collage(_, caption): - return layoutMediaWithCaption(kind: .collage, - naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption, - isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) + case let .collage(items, caption): + return layoutCollage(items: items, caption: caption, isCover: isCover, + boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) - case let .slideshow(_, caption): - return layoutMediaWithCaption(kind: .slideshow, - naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption, - isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) + case let .slideshow(items, caption): + return layoutSlideshow(items: items, caption: caption, + boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .channelBanner(channel): if channel == nil { return [] } @@ -1763,6 +1776,132 @@ private func layoutTypedMediaWithCaption( return result } +/// Lays out an `InstantPageBlock.collage(items:caption:)`. Mirrors V1 +/// (InstantPageLayout.swift:692-727): compute a mosaic over the inner image/video sizes, then emit +/// one existing typed media item per cell at its mosaic frame, flush (cornerRadius 0) so the bubble's +/// rounded clip handles the outer corners and the 1pt mosaic spacing handles the interior gaps. A +/// single caption renders below the whole mosaic. Cells are top-level `.mediaImage`/`.mediaVideo` +/// items, so gallery / reveal / registry / hidden-media all work with no extra code. +private func layoutCollage( + items innerBlocks: [InstantPageBlock], + caption: InstantPageCaption, + isCover: Bool, + boundingWidth: CGFloat, + horizontalInset: CGFloat, + context: inout LayoutContext +) -> [InstantPageV2LaidOutItem] { + // 1. One size per inner block (zero for unresolved — V1 still reserves a mosaic slot). + var itemSizes: [CGSize] = [] + for block in innerBlocks { + switch block { + case let .image(id, _, _, _): + if case let .image(image) = context.media[id], let largest = largestImageRepresentation(image.representations) { + itemSizes.append(CGSize(width: CGFloat(largest.dimensions.width), height: CGFloat(largest.dimensions.height))) + } else { + itemSizes.append(CGSize()) + } + case let .video(id, _, _, _): + if case let .file(file) = context.media[id], let dimensions = file.dimensions { + itemSizes.append(CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height))) + } else { + itemSizes.append(CGSize()) + } + default: + itemSizes.append(CGSize()) + } + } + + // 2. Mosaic geometry — the same engine V1 uses. + let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes) + + // 3. One typed media item per resolvable cell, at its mosaic frame. + var result: [InstantPageV2LaidOutItem] = [] + let webpage = context.webpage + for (i, block) in innerBlocks.enumerated() { + guard i < mosaic.count else { break } + let (cellFrame, position) = mosaic[i] + // Right-edge cells bleed 4pt so the bubble's rounded clip leaves no trailing sliver. + var frame = cellFrame + if position.contains(.right) { + frame.size.width += instantPageV2MediaEdgeBleed + } + switch block { + case let .image(id, blockCaption, url, webpageId): + guard case let .image(image) = context.media[id] else { continue } + let mediaIndex = context.mediaIndexCounter + context.mediaIndexCounter += 1 + let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) } + let media = InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit) + result.append(.mediaImage(InstantPageV2MediaImageItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: []))) + case let .video(id, blockCaption, _, _): + guard case let .file(file) = context.media[id] else { continue } + let mediaIndex = context.mediaIndexCounter + context.mediaIndexCounter += 1 + let media = InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: blockCaption.text, credit: blockCaption.credit) + result.append(.mediaVideo(InstantPageV2MediaVideoItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: []))) + default: + continue + } + } + + // 4. Caption below the mosaic. + let (captionItems, captionHeight) = layoutCaptionAndCredit(caption, offset: mosaicSize.height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) + result.append(contentsOf: captionItems) + + // Cover-caption padding parity with layoutTypedMediaWithCaption. + if isCover && captionHeight > 0.0 { + if let lastIndex = result.lastIndex(where: { if case .text = $0 { return true } else { return false } }) { + if case var .text(lastText) = result[lastIndex] { + lastText.frame = CGRect(origin: lastText.frame.origin, size: CGSize(width: lastText.frame.width, height: lastText.frame.height + 14.0)) + result[lastIndex] = .text(lastText) + } + } + } + + return result +} + +/// Lays out an `InstantPageBlock.slideshow(items:caption:)`. Mirrors V1 +/// (InstantPageLayout.swift:809-843): collect the inner image medias, size the block to the tallest +/// image fitted into the bounding width (cap 1200), emit a single full-width slideshow carousel item, +/// caption below. Only `.image` inner blocks contribute (matches V1). +private func layoutSlideshow( + items innerBlocks: [InstantPageBlock], + caption: InstantPageCaption, + boundingWidth: CGFloat, + horizontalInset: CGFloat, + context: inout LayoutContext +) -> [InstantPageV2LaidOutItem] { + var medias: [InstantPageMedia] = [] + var height: CGFloat = 0.0 + for block in innerBlocks { + switch block { + case let .image(id, blockCaption, url, webpageId): + if case let .image(image) = context.media[id], let imageSize = largestImageRepresentation(image.representations)?.dimensions { + let mediaIndex = context.mediaIndexCounter + context.mediaIndexCounter += 1 + let filledSize = imageSize.cgSize.fitted(CGSize(width: boundingWidth, height: 1200.0)) + height = max(height, filledSize.height) + let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) } + medias.append(InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit)) + } + default: + break + } + } + + var result: [InstantPageV2LaidOutItem] = [] + result.append(.slideshow(InstantPageV2SlideshowItem( + frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: height), + medias: medias, + webPage: context.webpage + ))) + + let (captionItems, _) = layoutCaptionAndCredit(caption, offset: height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) + result.append(contentsOf: captionItems) + return result +} + private func layoutMediaWithCaption( kind: InstantPageV2MediaPlaceholderKind, naturalSize: CGSize, diff --git a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift index bb00d9aad5..314f78fb7b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2MediaViews.swift @@ -19,7 +19,7 @@ private final class WrapperRef { // Hosts a V1 `InstantPageImageNode` inside a V2 UIView wrapper. The caller sizes its own // frame from `item.frame` and adds the returned node's view as a subview. -private func makeMediaWrapper( +func makeMediaWrapper( frame: CGRect, media: InstantPageMedia, webPage: TelegramMediaWebpage, @@ -68,14 +68,14 @@ private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? { // its `rootMediaRegistryHost` chain transitively (nested details blocks can leave an inner // body's host pointing at an intermediate body — see `trueRegistryRoot`). No-op if the wrapper // isn't yet attached to a V2View ancestor. -private func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) { +func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) { guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return } v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper) } // Routes a tap on `tapped` through `openInstantPageMedia`, sourcing sibling medias from the // root V2View's `currentLayout`. No-op if the wrapper isn't currently in a V2View tree. -private func handleOpenMediaTap( +func handleOpenMediaTap( tapped: InstantPageMedia, wrapper: UIView, renderContext: InstantPageV2RenderContext @@ -164,6 +164,14 @@ final class InstantPageV2MediaImageView: UIView, InstantPageItemView { let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings self.wrappedNode.update(strings: strings, theme: theme) } + + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return self.wrappedNode.transitionNode(media: media) + } + + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { + self.wrappedNode.updateHiddenMedia(media: media) + } } final class InstantPageV2MediaVideoView: UIView, InstantPageItemView { @@ -219,6 +227,14 @@ final class InstantPageV2MediaVideoView: UIView, InstantPageItemView { let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings self.wrappedNode.update(strings: strings, theme: theme) } + + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return self.wrappedNode.transitionNode(media: media) + } + + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { + self.wrappedNode.updateHiddenMedia(media: media) + } } final class InstantPageV2MediaMapView: UIView, InstantPageItemView { @@ -274,6 +290,14 @@ final class InstantPageV2MediaMapView: UIView, InstantPageItemView { let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings self.wrappedNode.update(strings: strings, theme: theme) } + + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return self.wrappedNode.transitionNode(media: media) + } + + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { + self.wrappedNode.updateHiddenMedia(media: media) + } } final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView { @@ -329,4 +353,12 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView { let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings self.wrappedNode.update(strings: strings, theme: theme) } + + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return self.wrappedNode.transitionNode(media: media) + } + + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { + self.wrappedNode.updateHiddenMedia(media: media) + } } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift index dc6cb22efa..82adf21c2b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift @@ -334,7 +334,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int // positions are identical whether or not thinking blocks are present, so adding/ // removing a thinking block never jumps the answer's reveal position. entries.append(.thinking(start: cursor)) - case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder, + case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder, .slideshow, .divider, .listMarker, .blockQuoteBar, .shape, .anchor: let start = cursor cursor += itemWidthCost(item) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift b/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift new file mode 100644 index 0000000000..9e0dafa35f --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift @@ -0,0 +1,183 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import AccountContext +import TelegramCore +import TelegramPresentationData + +// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode / +// InstantPageSlideshowPagerNode (InstantPageSlideshowItemNode.swift), simplified to create all pages +// eagerly (slideshows are short; this avoids V1's central±1 index bookkeeping and makes the gallery +// transition source available for every page). Each image page hosts an `InstantPageImageNode` exactly +// like the static media views; non-image medias render an empty page (matches V1). +final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate { + private(set) var item: InstantPageV2SlideshowItem + var itemFrame: CGRect { return self.item.frame } + + private let renderContext: InstantPageV2RenderContext + private var theme: InstantPageTheme + + private let scrollView: UIScrollView + private let pageControlNode: PageControlNode + + // One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes` + // holds only the real image nodes; it may be shorter than `pageViews` if a non-image media appears + // (which `layoutSlideshow` currently filters out). Nothing relies on positional correspondence. + private var pageViews: [UIView] = [] + private var pageImageNodes: [InstantPageImageNode] = [] + + init(item: InstantPageV2SlideshowItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) { + self.item = item + self.renderContext = renderContext + self.theme = theme + self.scrollView = UIScrollView() + self.pageControlNode = PageControlNode(dotColor: .white, inactiveDotColor: UIColor(white: 1.0, alpha: 0.5)) + + super.init(frame: item.frame) + + self.backgroundColor = theme.panelSecondaryColor // structural + self.clipsToBounds = true // structural + + self.scrollView.isPagingEnabled = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.scrollsToTop = false + if #available(iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + self.scrollView.delegate = self + self.addSubview(self.scrollView) // structural + + self.pageControlNode.isUserInteractionEnabled = false + self.addSubview(self.pageControlNode.view) // structural + + self.rebuildPages() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private func rebuildPages() { + for view in self.pageViews { + view.removeFromSuperview() + } + self.pageViews = [] + self.pageImageNodes = [] + + let renderContext = self.renderContext + // The image node owns this closure, and is owned (transitively) by self — capture weakly. + let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in + guard let self else { return } + handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext) + } + + for media in self.item.medias { + let pageView = UIView() + pageView.clipsToBounds = true + if case .image = media.media { + let node = makeMediaWrapper( + frame: CGRect(origin: .zero, size: self.item.frame.size), + media: media, + webPage: self.item.webPage, + attributes: [], + renderContext: self.renderContext, + theme: self.theme, + openMedia: openMedia, + longPressMedia: { _ in } + ) + pageView.addSubview(node.view) + self.pageImageNodes.append(node) + } + // Non-image medias (none in practice — layoutSlideshow filters to images) get an empty page + // to keep page indices aligned with the page control. + self.scrollView.addSubview(pageView) + self.pageViews.append(pageView) + } + + self.pageControlNode.pagesCount = self.item.medias.count + self.pageControlNode.setPage(0.0) + // Re-register media indices when rebuilding while already on-window (positional reuse with + // changed content); no-ops before the view is attached, where didMoveToWindow handles it. + self.registerMedias() + self.setNeedsLayout() + } + + private func registerMedias() { + guard self.window != nil else { return } + // Register under every contained media index so transitionArgsFor(media) can find this view. + for media in self.item.medias { + registerInRootRegistry(wrapper: self, mediaIndex: media.index) + } + } + + override func didMoveToWindow() { + super.didMoveToWindow() + self.registerMedias() + } + + override func layoutSubviews() { + super.layoutSubviews() + let size = self.bounds.size + guard size.width > 0.0, size.height > 0.0 else { return } + + self.scrollView.frame = CGRect(origin: .zero, size: size) + for (i, pageView) in self.pageViews.enumerated() { + pageView.frame = CGRect(x: CGFloat(i) * size.width, y: 0.0, width: size.width, height: size.height) + } + for node in self.pageImageNodes { + node.frame = CGRect(origin: .zero, size: size) + } + self.scrollView.contentSize = CGSize(width: CGFloat(self.pageViews.count) * size.width, height: size.height) + + self.pageControlNode.layer.transform = CATransform3DIdentity + self.pageControlNode.frame = CGRect(x: 0.0, y: size.height - 20.0, width: size.width, height: 20.0) + let maxWidth = size.width - 36.0 + let pageControlSize = self.pageControlNode.calculateSizeThatFits(size) + if pageControlSize.width > maxWidth, pageControlSize.width > 0.0 { + let scale = maxWidth / pageControlSize.width + self.pageControlNode.layer.transform = CATransform3DMakeScale(scale, scale, 1.0) + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let width = self.bounds.size.width + guard width > 0.0, !self.item.medias.isEmpty else { return } + let page = Int((scrollView.contentOffset.x + width / 2.0) / width) + let clamped = max(0, min(self.item.medias.count - 1, page)) + self.pageControlNode.setPage(CGFloat(clamped)) + } + + func update(item: InstantPageV2SlideshowItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) { + let mediasChanged = self.item.medias.map { $0.index } != item.medias.map { $0.index } + self.item = item + self.theme = theme + self.backgroundColor = theme.panelSecondaryColor + if mediasChanged { + self.rebuildPages() + } else { + let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings + for node in self.pageImageNodes { + node.update(strings: strings, theme: theme) + } + } + self.setNeedsLayout() + } + + // MARK: InstantPageItemView gallery hooks + + func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + for node in self.pageImageNodes { + if let transition = node.transitionNode(media: media) { + return transition + } + } + return nil + } + + func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { + for node in self.pageImageNodes { + node.updateHiddenMedia(media: media) + } + } +}