Skip to content

Commit 76f577e

Browse files
isaacclaude
andcommitted
feat(richtext): media spoilers (photo/video) in the rich-text composer
Per-item spoiler support for photos/videos in the rich-text editor / ChatInputContent composer, round-tripping editor → draft → send → wire → receiver and reusing the MediaDustNode dust + blur effect. Model & round-trip: - isSpoiler threads through MediaItem (editor Core) ↔ ChatInputMediaItem ↔ InstantPageBlock.image/.video (Postbox + flatBuffers), the MTProto wire (pageBlockPhoto flags.1 / pageBlockVideo flags.2 — no Api regen), and both send converters + the Document↔ChatInputContent bridge. Back-compat: absent ⇒ false. Authoring: - Each media's per-cell "•••" menu carries an animated Spoiler toggle (Enable/DisableSpoiler) alongside Delete, in both the chat composer and the extended composer (RichTextAttachmentScreen). No tap-select edit menu. Render: - In-editor: each cell blurs its poster (chatSecretPhoto/chatSecretMessageVideo) under a static dust cover. - Sent/received (InstantPage V2) + long-press-Send preview: a revealable dust + blur overlay above an always-sharp node; a tap plays the MediaDustNode expanding-mask reveal of the sharp image (matching ChatMessageInteractiveMediaNode). Docs updated (docs/richtext-composer.md, docs/instantpage-richtext.md, RichTextEditor/CLAUDE.md). Editor unit tests added/updated (Core + UIKit, green on iPhone 17 Pro K3); TextFormatTests + full app build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c0b636b commit 76f577e

38 files changed

Lines changed: 739 additions & 144 deletions

File tree

‎docs/instantpage-richtext.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,45 @@ remains produced only by real web Instant View articles.
141141
- **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.
142142
- **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.
143143

144+
## InstantPage V2 media spoiler (revealable dust)
145+
146+
A `pageBlockPhoto`/`pageBlockVideo` (and thus a collage cell) can carry a **spoiler** flag — the medium is
147+
hidden behind an animated "dust" cover until the recipient taps it, mirroring the regular
148+
`MediaSpoilerMessageAttribute` path in `ChatMessageInteractiveMediaNode`. This is how a rich message
149+
(`RichTextMessageAttribute` → InstantPage) carries a media spoiler; the composer-authoring side is in
150+
`docs/richtext-composer.md` §4.
151+
152+
### Where things live
153+
154+
| Concern | Location |
155+
|---|---|
156+
| model flag | `InstantPageBlock.image`/`.video` gain `spoiler: Bool` (`SyncCore_InstantPage.swift`); Postbox key `"sp"`, flatBuffers `Models/InstantPageBlock.fbs` `spoiler:bool (id:4)` |
157+
| wire | `ApiUtils/InstantPage.swift` reads/ORs `pageBlockPhoto` `flags.1` / `pageBlockVideo` `flags.2`**no `TelegramApi` change** (the bit rides the existing `flags` Int32; constructor ids `1759c560`/`7c8fe7b6` unchanged) |
158+
| laid-out item | `InstantPageV2MediaImageItem`/`VideoItem` gain `spoiler: Bool` (`InstantPageV2Layout.swift`); single-media + collage item-constructing cases thread it |
159+
| render | `InstantPageV2MediaViews.swift``MediaSpoilerDustOverlay` hosts a `MediaDustNode` (import `InvisibleInkDustNode`) in both `InstantPageV2MediaImageView`/`VideoView` |
160+
161+
### Non-obvious invariants
162+
163+
- **The dust cover is NON-interactive; reveal is driven through the wrapped node's own tap.** The overlay
164+
(`containerNode` + `dustNode`) is `isUserInteractionEnabled = false`, so taps fall through to the sibling
165+
`InstantPageImageNode` below it. Each view's `openMedia` closure is **gated**: while `overlay.concealed`,
166+
the first tap calls `overlay.reveal()` (which sets `concealed = false` synchronously and drives
167+
`MediaDustNode.tap(at:)` → the wipe animation → the `revealed` callback removes the cover) and returns
168+
**without** opening the gallery; once revealed, taps fall through to `handleOpenMediaTap` (gallery). This
169+
mirrors `ExtendedMediaOverlayNode.reveal(animated:)` but with a non-interactive cover instead of an
170+
interactive button.
171+
- **Reuse resets reveal state by media id.** A positionally-reused media view (`stableId = .media(index)`,
172+
reconciled through `update(item:)``updateSpoiler`) keyed on `EngineMedia.Id`: a different id or
173+
`spoiler == false` tears down the cover; the same spoiler medium keeps its (possibly already-revealed)
174+
state — so scrolling can't bleed a stale reveal onto a different photo, and a re-layout of the same photo
175+
doesn't re-hide it. **No `InstantPageRenderer.reuse(existingView:)` change** was needed — it already routes
176+
through `update(item:)`.
177+
- **Collage cells inherit spoiler for free.** `layoutCollage` flattens inner `.image`/`.video` into ordinary
178+
top-level `.mediaImage`/`.mediaVideo` items (see the collage section above), threading each inner block's
179+
`spoiler` — so an album with one spoiler cell just works, with no collage-specific spoiler code.
180+
- **The long-press-Send options preview** renders through the same `InstantPageV2View`, so a spoiler'd media
181+
shows the dust cover in the preview bubble automatically.
182+
144183
## InstantPage V2 text item height (true font line box)
145184

146185
`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`).

‎docs/richtext-composer.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,28 @@ All inline / structural features round-trip losslessly through the native compos
198198
hosts; "Add another photo/video" is wired in the **article editor**'s more-menu only — the composer's
199199
in-place Add is a deferred follow-up (the composer already renders/edits multi-media from sent albums
200200
and drafts). Design + plan: `docs/superpowers/{specs,plans}/2026-07-08-richtext-multi-media-container*`.
201+
**Media spoilers (added 2026-07-08).** A photo/video can be marked a Telegram-style spoiler (dust-covered
202+
until tapped), per **item**: `MediaItem.isSpoiler` (editor Core) ↔ `ChatInputMediaItem.isSpoiler`
203+
(both additive, optional-Codable, absent ⇒ `false`, so all existing docs/drafts/messages decode
204+
unchanged) ↔ a `spoiler: Bool` associated value on `InstantPageBlock.image`/`.video`. **Authoring:**
205+
tap-select a single medium → the edit menu's **"Spoiler"** item (`imageSelectionMenu`, toggles via
206+
`toggleSelectedMediaSpoiler`); for an album, each cell's **"•••"** menu carries a per-cell "Spoiler"
207+
(threaded through `MediaControlRequest.isSpoiler`/`toggleSpoiler``MediaControlContext` → the panel's
208+
ContextUI menu). Both route to `RichTextEditorView.toggleMediaSpoiler(itemIndex:)`
209+
`DocumentCanvasView.toggleMediaSpoiler(blockID:itemIndex:)` (one undo step, in-place `MediaBlockBox`
210+
rebuild like `deleteMediaItem`). **In-editor render is a NON-revealable authoring cover**`MediaItemNodeView`
211+
hosts a `MediaDustNode` (via `InvisibleInkDustNode`) per spoiler cell, `revealOnTap = false`, non-interactive
212+
(taps fall through to selection); the flag reaches it through `MediaProviderItem.isSpoiler` and is folded into
213+
the `syncMediaItemViews` items-signature so a toggle re-provides the cell. **On the wire, no Api
214+
regeneration:** the server schema already defines `pageBlockPhoto#1759c560 spoiler:flags.1` /
215+
`pageBlockVideo#7c8fe7b6 spoiler:flags.2`, so the bit is read/OR'd purely in `ApiUtils/InstantPage.swift`
216+
(like `autoplay`/`loop`). The flag round-trips through Postbox Codable (`"sp"` key), the flatBuffers
217+
`InstantPageBlock` path (`Models/InstantPageBlock.fbs` + the hand-written encode/decode — so it survives every
218+
InstantPage persistence path, not just Postbox), and BOTH send converters (`ChatInputContentInstantPage`
219+
composer + `InstantPageBuilder` article). **Sent/received message render** (revealable dust with first-tap
220+
reveal → then gallery) is in `instantpage-richtext.md`. Design + plan:
221+
`docs/superpowers/{specs,plans}/2026-07-08-richtext-media-spoiler*`. **All sim tests run on the iPhone 17 Pro
222+
K3 sim.**
201223
- **Location maps:** a picked location is a `Block.media` with **`MediaKind.location`** whose `mediaID` resolves to
202224
a `TelegramMediaMap`. A map is an **id-less** `Media`, so the host mints a deterministic `"map:lat:long"` key
203225
(not the usual `namespace:id`). It renders inline as a map snapshot through the same `MediaItemNodeView` seam —

‎submodules/BrowserUI/Sources/BrowserMarkdown.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1411,7 +1411,8 @@ private func markdownBlocks(from node: MarkdownIntentNode, context: MarkdownConv
14111411
id: image.mediaId,
14121412
caption: image.caption,
14131413
url: image.linkUrl,
1414-
webpageId: nil
1414+
webpageId: nil,
1415+
spoiler: false
14151416
)
14161417
]
14171418
}

‎submodules/BrowserUI/Sources/BrowserReadability.swift‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,8 @@ private func parseImage(_ input: [String: Any], _ media: inout [EngineMedia.Id:
750750
id: id,
751751
caption: caption,
752752
url: nil,
753-
webpageId: nil
753+
webpageId: nil,
754+
spoiler: false
754755
)
755756
}
756757

@@ -820,8 +821,8 @@ private func parseFigure(_ input: [String: Any], _ media: inout [EngineMedia.Id:
820821
guard var block else {
821822
return nil
822823
}
823-
if let caption, case let .image(id, _, url, webpageId) = block {
824-
block = .image(id: id, caption: InstantPageCaption(text: caption, credit: .empty), url: url, webpageId: webpageId)
824+
if let caption, case let .image(id, _, url, webpageId, spoiler) = block {
825+
block = .image(id: id, caption: InstantPageCaption(text: caption, credit: .empty), url: url, webpageId: webpageId, spoiler: spoiler)
825826
}
826827
return block
827828
}

‎submodules/GalleryData/Sources/GalleryData.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ public enum ChatMessageGalleryControllerData {
3333

3434
private func instantPageBlockMedia(pageId: EngineMedia.Id, block: InstantPageBlock, media: [EngineMedia.Id: EngineRawMedia], counter: inout Int) -> [InstantPageGalleryEntry] {
3535
switch block {
36-
case let .image(id, caption, _, _):
36+
case let .image(id, caption, _, _, _):
3737
if let m = media[id] {
3838
let result = [InstantPageGalleryEntry(index: Int32(counter), pageId: pageId, media: InstantPageMedia(index: counter, media: EngineMedia(m), url: nil, caption: caption.text, credit: caption.credit), caption: caption.text, credit: caption.credit, location: InstantPageGalleryEntryLocation(position: Int32(counter), totalCount: 0))]
3939
counter += 1
4040
return result
4141
}
42-
case let .video(id, caption, _, _):
42+
case let .video(id, caption, _, _, _):
4343
if let m = media[id] {
4444
let result = [InstantPageGalleryEntry(index: Int32(counter), pageId: pageId, media: InstantPageMedia(index: counter, media: EngineMedia(m), url: nil, caption: caption.text, credit: caption.credit), caption: caption.text, credit: caption.credit, location: InstantPageGalleryEntryLocation(position: Int32(counter), totalCount: 0))]
4545
counter += 1

‎submodules/InstantPageUI/Sources/InstantPageImageNode.swift‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExt
8383

8484
private var themeUpdated: Bool = false
8585
private var externalMediaDimensionsUpdated: Bool = false
86+
87+
// Spoiler support: a separate heavily-blurred (`.blurBackground`) overlay node that occludes the sharp
88+
// `imageNode` while concealed. A distinct node is required — `TransformImageArguments.==` ignores
89+
// `resizeMode`, so flipping the main node's resizeMode alone would early-out and never re-render; and a
90+
// separate node also gives an instant reveal (remove it → the always-sharp `imageNode` shows). The
91+
// enclosing V2 media view drives the dust cover + reveal timing. Default off (no effect on web IV).
92+
private var contentBlurredSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
8693

8794
init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute], interactive: Bool, roundCorners: Bool, fit: Bool, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, imageReferenceForMedia: ((TelegramMediaImage) -> ImageMediaReference)? = nil, fileReferenceForMedia: ((TelegramMediaFile) -> FileMediaReference)? = nil, getPreloadedResource: @escaping (String) -> Data?) {
8895
self.context = context
@@ -119,7 +126,8 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExt
119126
} else {
120127
let imageReference = imageReferenceForMedia?(image) ?? ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image)
121128
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference))
122-
129+
self.contentBlurredSignal = chatSecretPhoto(account: context.account, userLocation: sourceLocation.userLocation, photoReference: imageReference, ignoreFullSize: true)
130+
123131
if !interactive || shouldDownloadMediaAutomatically(settings: context.sharedContext.currentAutomaticMediaDownloadSettings, peerType: sourceLocation.peerType, networkType: MediaAutoDownloadNetworkType(context.account.immediateNetworkType), authorPeerId: nil, contactsPeerIds: Set(), media: image) {
124132
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start())
125133
}
@@ -158,6 +166,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExt
158166
} else {
159167
self.imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, videoReference: fileReference))
160168
}
169+
self.contentBlurredSignal = chatSecretMessageVideo(account: context.account, userLocation: sourceLocation.userLocation, videoReference: fileReference)
161170
if file.isVideo {
162171
self.statusNode.transitionToState(.play(.white), animated: false, completion: {})
163172
self.pinchContainerNode.contentNode.addSubnode(self.statusNode)
@@ -178,6 +187,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExt
178187
} else if case let .webpage(webPage) = media.media, case let .Loaded(content) = webPage.content, let image = content.image {
179188
let imageReference = imageReferenceForMedia?(image) ?? ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image)
180189
self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference))
190+
self.contentBlurredSignal = chatSecretPhoto(account: context.account, userLocation: sourceLocation.userLocation, photoReference: imageReference, ignoreFullSize: true)
181191
self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(context: context, userLocation: sourceLocation.userLocation, photoReference: imageReference, displayAtSize: nil, storeToDownloadsPeerId: nil).start())
182192
self.statusNode.transitionToState(.play(.white), animated: false, completion: {})
183193
self.pinchContainerNode.contentNode.addSubnode(self.statusNode)
@@ -213,9 +223,38 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExt
213223
}
214224
}
215225

216-
func updateIsVisible(_ isVisible: Bool) {
226+
func updateIsVisible(_ isVisible: Bool) {
217227
}
218-
228+
229+
/// Builds a heavily-blurred (`blurred: true` secret-media signal) `TransformImageNode` for a spoiler
230+
/// cover. The caller (the V2 media view's `MediaSpoilerDustOverlay`) HOSTS it above this node so the
231+
/// dust's reveal mask clears both blur + dust together, exposing the always-sharp `imageNode` — matching
232+
/// `ExtendedMediaOverlayNode`. Layout is via `layoutSpoilerBlurredNode(_:size:)`. Nil for media with no
233+
/// image signal (e.g. maps). The sharp `imageNode` itself is never blurred.
234+
func makeSpoilerBlurredNode() -> TransformImageNode? {
235+
guard let contentBlurredSignal = self.contentBlurredSignal else {
236+
return nil
237+
}
238+
let node = TransformImageNode()
239+
node.contentAnimations = []
240+
node.setSignal(contentBlurredSignal)
241+
return node
242+
}
243+
244+
/// Lays out a node from `makeSpoilerBlurredNode()` to fill `size` (same aspect-fill + corner-radius math
245+
/// as the sharp `imageNode`); `.blurBackground` + the `blurred: true` signal draw a full blur with no
246+
/// sharp foreground. Idempotent (a `TransformImageNode` no-ops on unchanged args).
247+
func layoutSpoilerBlurredNode(_ node: TransformImageNode, size: CGSize) {
248+
node.frame = CGRect(origin: CGPoint(), size: size)
249+
guard let dimensions = self.effectiveMediaDimensions() else {
250+
return
251+
}
252+
let imageSize = dimensions.cgSize.aspectFilled(size)
253+
let radius: CGFloat = self.roundCorners ? floor(min(imageSize.width, imageSize.height) / 2.0) : 0.0
254+
let apply = node.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: imageSize, boundingSize: size, intrinsicInsets: UIEdgeInsets(), resizeMode: .blurBackground, emptyColor: .clear))
255+
apply()
256+
}
257+
219258
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
220259
}
221260

@@ -364,7 +403,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExt
364403
self.currentSize = size
365404
self.themeUpdated = false
366405
self.externalMediaDimensionsUpdated = false
367-
406+
368407
self.pinchContainerNode.frame = CGRect(origin: CGPoint(), size: size)
369408
self.pinchContainerNode.update(size: size, transition: .immediate)
370409
self.imageNode.frame = CGRect(origin: CGPoint(), size: size)
@@ -417,7 +456,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExt
417456
}
418457
}
419458
}
420-
459+
421460
func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
422461
if instantPageMediaMatchesNodeIdentity(media, self.media) {
423462
let imageNode = self.imageNode

‎submodules/InstantPageUI/Sources/InstantPageLayout.swift‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
608608
}
609609
contentSize.height += verticalInset
610610
return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items)
611-
case let .image(id, caption, url, webpageId):
611+
case let .image(id, caption, url, webpageId, _):
612612
if case let .image(image) = media[id], let largest = largestImageRepresentation(image.representations) {
613613
let imageSize = largest.dimensions
614614
var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0))
@@ -648,7 +648,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
648648
} else {
649649
return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: [])
650650
}
651-
case let .video(id, caption, autoplay, _):
651+
case let .video(id, caption, autoplay, _, _):
652652
if case let .file(file) = media[id], let dimensions = file.dimensions {
653653
let imageSize = dimensions
654654
var filledSize = imageSize.cgSize.aspectFitted(CGSize(width: boundingWidth - safeInset * 2.0, height: 1200.0))
@@ -695,11 +695,11 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
695695
for subItem in innerItems {
696696
var size = CGSize()
697697
switch subItem {
698-
case let .image(id, _, _, _):
698+
case let .image(id, _, _, _, _):
699699
if case let .image(image) = media[id], let largest = largestImageRepresentation(image.representations) {
700700
size = largest.dimensions.cgSize
701701
}
702-
case let .video(id, _, _, _):
702+
case let .video(id, _, _, _, _):
703703
if case let .file(file) = media[id], let dimensions = file.dimensions {
704704
size = dimensions.cgSize
705705
}
@@ -814,7 +814,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
814814

815815
for subBlock in subItems {
816816
switch subBlock {
817-
case let .image(id, caption, url, webpageId):
817+
case let .image(id, caption, url, webpageId, _):
818818
if case let .image(image) = media[id], let imageSize = largestImageRepresentation(image.representations)?.dimensions {
819819
let mediaIndex = mediaIndexCounter
820820
mediaIndexCounter += 1

0 commit comments

Comments
 (0)