You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: docs/instantpage-richtext.md
+39Lines changed: 39 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -141,6 +141,45 @@ remains produced only by real web Instant View articles.
141
141
-**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.
142
142
-**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.
143
143
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
+
144
183
## InstantPage V2 text item height (true font line box)
145
184
146
185
`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`).
0 commit comments