Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 KiB
RichTextEditorMediaView — module guide
Host-side renderers for media (photo / video / location / audio) embedded in the RichText editor
(submodules/TelegramUI/Components/RichTextEditor, behind the debugRichText flag). The editor owns a
narrow seam — RichTextMediaItemView (a UIView with update(size:)) — and resolves each .media
block to one of these views via a host-registered provider, then positions/sizes/culls it
(DocumentCanvasView+Media.syncMediaItemViews). This module supplies the concrete views; it depends on
AccountContext/TelegramCore (so it can't live inside the editor package, which is account-free).
Types
MediaItemNodeView— theRichTextMediaItemViewadapter + 4-way dispatcher, keyed on the editor'sMediaKind(threaded through the resolved-items tuple askind:) — not by sniffing the resolvedMedia. Sniffing would route an image-mime.filepicked from the Files tab to the photo pool, whereas the editor calls it a document. Dispatched by media kind, created once per media occurrence at three call sites (RichTextAttachmentScreen,ChatControllerNode,ChatInterfaceStateInputPanels). Accepts acornerRadius: CGFloat = 0init param: when > 0 and the audio branch was NOT taken, setslayer.cornerRadius+masksToBoundson the container (child fills bounds, so the rounded rect clips it). Photo/video/location round to 10pt at the two composer call sites; audio stays square (check isaudioView == nil);RichTextAttachmentScreen(article editor) leaves the default 0 and stays square. Container-aware (added 2026-07-08): aconvenience init(context:items:…)taking[(media: EngineMedia, naturalSize: CGSize)]composes a mosaic of N single-cellRichTextMediaContentComponents for a photo/video container (items.count >= 2);items.count == 1delegates straight to the existing per-kind single-mediainitabove, so a container of 1 renders identically to before. See "Mosaic containers" below.- audio (
.file&& (isMusic||isVoice)) →StandaloneInstantPageAudioView(a playable, themeable row;audioColorOverridethemes it to the editor accent/text scheme). - document (
kind == .document) →StandaloneInstantPageDocumentView(a static file row — authoring mode: no fetch control, inert tap;documentColorOverridethemes it like audio's). Stays square (excluded fromcornerRadius) and non-interactive, so editor taps pass through to caret placement / tap-select. - location (
.geo) →StandaloneInstantPageImageView+ anInstantPageMapAttribute(600×300, zoom 15) — the InstantPage.geosnapshot+pin path. - photo / video (everything else, incl. image-mime
.file) →RichTextMediaContentComponenthosted in aComponentHostView<Empty>.
- audio (
RichTextMediaContentComponent— the composable ComponentFlow renderer for one still/video poster. Owns its ownTransformImageNode+ media-fetch signal + aspect-fill layout + (video) a staticRadialStatusNode.play(.white)overlay, decoupled fromInstantPageImageNode. Signal sources match the old shared path:.image→chatMessagePhoto(+chatMessagePhotoInteractiveFetched); image-mime.file→instantPageImageFile(+freeMediaFileInteractiveFetched); video.file→chatMessageVideo.
Load-bearing invariants
RichTextMediaContentComponent.==compares identity only (context ===+media.id) plusisSpoiler. The editor callsupdate(size:)every layout pass, so equality must hold across resizes.- Every
updatere-issues the image fetch —fetchDisposable.set(…)runs unconditionally. ThedidBind/boundMediaId/currentSizefields declared in theVieware dead (assigned nowhere before 2026-08-25); an earlier version of this file claimed they guard the fetch, and that was wrong. So==is the ONLY thing limiting refetches: anything you add to it refetches the poster every time it changes.isSpoileris acceptable because a spoiler toggle is a rare user action; a per-frame value would not be. - Do not route frequently-changing state through the component. Pre-upload progress was tried that
way and failed twice over: the host cannot deliver it (see the seam note below), and putting it in
==would refetch on every tick. It is now owned by theView, which subscribes toengine.messages.mediaPreuploadState(id:)itself — like its own fetch — and updates the status node directly, never throughstate.updated()(which would re-enterupdateand refetch). usesAspectFitis the counter-example — it DOES vary with layout, so it stays out of==and is re-set by the host every pass.
- Every
- The media-view provider seam is ONE-SHOT — the host cannot push per-frame state into a live view.
DocumentCanvasView+Media.syncMediaItemViewscalls the provider only when a block'sitemsSignature(displayMode | mediaID#kind#WxH#spoiler) changes; otherwise it reuses the hosted view untouched. Anything not in that signature — upload progress, download state, playback — will never reach a view through the provider. Such state must be subscribed to by the view itself. - Interaction is control-scoped via
hitTest— the poster passes through to the editor. The component now carries interactive chrome (a glass "more" button); to make it tappable WITHOUT stealing the editor's own taps (caret placement / media-select highlight), the whole media path is a hit-test pass-through:RichTextMediaContentComponent.View.hitTestreturns the button ONLY when the touch lands on it (routed through the button'sGlassBackgroundContainerView, whose ownhitTestis already control-or-nil), elsenil. Add any future controls to this override — do NOT rely onisUserInteractionEnabledalone.MediaItemNodeView.hitTestforwards into the photo/videocontentHostand returns the control, ornilfor the poster area and for the audio/location branches entirely (those stay non-interactive as before). Standard visibility/point(inside:)guards keep a culled (hidden) media view from claiming a touch.- Editor side (
RichTextEditormodule):mediaOverlayis aMediaPassthroughOverlayView(interactive, buthitTestreturnsnilunless a media view claims the touch), and the canvas's recognizers yield to a claimed touch viagestureRecognizer(_:shouldReceive:)(→ the media control gets a clean, un-cancelled touch). The contract is: "if the media view returns anything fromhitTest, leave it be; otherwise run the editor's default tap logic." The button ACTION (moreButtonPressed) is still an empty stub — the plumbing lands first.
- Aspect-FILL (
dimensions.cgSize.aspectFilled(availableSize)) — the editor already sizes the host rect to the media aspect (so fill == exact fit), and fill is the right primitive for future mosaic cropping.emptyColorplaceholder =theme.list.mediaPlaceholderColor. - View-frame ownership (repo-wide rule): the component lays out against
bounds; the editor owns the view's frame viaupdate(size:).MediaItemNodeViewonly frames its single child to its ownbounds. - Audio is caption-less in the editor model; location/audio rendering is byte-unchanged from the pre-component implementation (only the photo/video branch was rewritten).
Mosaic containers (photo/video, implemented 2026-07-08)
A media block can hold multiple photos/videos (items.count >= 2, one shared caption — model + send/edit
detail in docs/richtext-composer.md §4 and docs/instantpage-richtext.md). MediaItemNodeView's
items: initializer runs the same MosaicLayout engine grouped album messages use
(chatMessageBubbleMosaicLayout, now a shared dual-build package) to place one RichTextMediaContentComponent
cell per item at its mosaic frame — the single-cell component itself is unchanged (see its description
above).
- Cell-level view reuse is load-bearing (
MosaicCellLayout.swift,MosaicCellDiff): on everyupdate, cells are diffed byEngineMedia.Id— not rebuilt — via a multiset greedy match (handles the same media appearing twice in one container, order-independent). A surviving cell is re-update(size:)d to its new mosaic frame with the sameRichTextMediaContentComponentidentity, so the component's identity-only==(context ===+media.id, see the invariant above) keeps its fetch bound once; only a newly-addedmediaIDcreates a cell, only a removed one is torn down. Rebuilding cells on every mutation would re-issue every surviving image's fetch (flicker + wasted work) — exactly what that identity-only equality was designed to prevent. onControlTappedis 4-arg:(RichTextMediaControlKind, itemIndex: Int?, anchorView: UIView, sourceRect: CGRect). A per-cell tap reports its ownitemIndex; the container-level more button reportsnil(whole-block, matching the pre-container behavior).
Media aspect handling (added 2026-07-08)
Height capping: editor media slot height is capped at min(1000, canvasWidth) where canvasWidth = media area width including horizontal bleed — "media is never taller than its own display width, up to 1000pt". Applied in MediaBlockBox.imageDisplaySize (single) and MediaBlockBox.mosaicSize (mosaic total); mirrored in MediaItemNodeView.updateMosaic, both packing chatMessageBubbleMosaicLayout(maxSize: (W, min(1000,W))). These two MUST stay in lockstep (box reserves the slot, renderer fills it).
Single media aspect handling: a lone photo/video now renders aspect-fit + resizeMode: .blurBackground (RichTextMediaContentComponent.usesAspectFit). Portrait/panorama shows whole, centered, with a blurred backdrop filling letterbox/pillarbox gap (chat's PhotoResources.blurBackground path), instead of cropped. Landscape filling its slot pays no blur (boundingSize == imageSize).
Mosaic cells remain crop-to-fill (aspectFilled, usesAspectFit == false); total mosaic height is capped by scale-to-fit + horizontal centering when width-driven pack exceeds the cap (MediaItemNodeView.updateMosaic).
Non-identity state in usesAspectFit: it is a var NOT in RichTextMediaContentComponent.== (equality stays context === + media.id so the fetch binds once). MediaItemNodeView sets it every layout pass (single → true, mosaic cell → false), so a cell reused across a 1↔2 transition switches mode.
Spec/plan retained in-tree: docs/superpowers/{specs/2026-07-08-richtext-media-fit-blur-height-cap-design.md,plans/2026-07-08-richtext-media-fit-blur-height-cap.md}.
Dedicated add button (photo/video containers, added 2026-07-08)
MediaItemNodeView hosts a dedicated glass "+" add button in the top-right corner (36×36, 8pt inset), styled like the per-cell ⋯ button (GlassBackgroundContainerView + GlassBackgroundView + a Chat/Context Menu/Add icon; needs import AppBundle + the //submodules/AppBundle BUILD dep for UIImage(bundleImageName:)). Shown ONLY when showsControls && mosaicContext != nil (article editor + photo/video containers — never the composer, which passes showsControls: false, nor audio/location). Tapping fires onControlTapped(.add, itemIndex: nil, …) through the existing seam → MediaControlRequest(control: .add); RichTextAttachmentScreen's .add case opens the picker + request.addMore(...). The per-cell ⋯ menu is now Delete-only (Add moved to the dedicated button).
hitTest returns the add button first (guarded on !isHidden) so it's tappable while the poster passes through to the editor. The interaction seam (hit-test pass-through, component-level return-or-nil, recognizer yielding) is documented in the Load-bearing invariants section above.
Spec/plan in-tree: docs/superpowers/{specs/2026-07-08-richtext-media-add-button-design.md,plans/2026-07-08-richtext-media-add-button.md}.
Future iterations (planned, not yet implemented)
- Higher-quality video poster. Video currently shows the file's small embedded thumbnail (via
chatMessageVideo— same source as the oldInstantPageImageNodepath; runtime-confirmed low-res 2026-06-29). A future pass should load a larger representation or a generated frame for the poster. - Interaction: the hit-test plumbing is in place (taps reach the component's controls — see the
interaction invariant above); still to wire are the actual actions — the "more" button menu
(
moreButtonPressedis an empty stub) and tap / long-press (open / preview / context menu). - Mosaic — implemented, see "Mosaic containers" above. The single-medium component was built composable (no editor-seam coupling) specifically so the mosaic wrapper could reuse it directly, which is exactly what landed.
- Slideshow — implemented (2026-07-17), see "Mosaic ↔ slideshow toggle" below. A multi-media block
(
items.count >= 2) can be toggled between the mosaic grid and a swipeable slideshow carousel; grouping no longer always renders as a mosaic.
Mosaic ↔ slideshow toggle (photo/video containers, added 2026-07-17)
A per-block MediaBlock.displayMode (.mosaic / .slideshow, default .mosaic, mirrored on the
composer currency ChatInputMedia.displayMode) drives whether a items.count >= 2 container renders as
the packed MosaicLayout grid (sent as InstantPage .collage) or a swipeable carousel with paging dots
(sent as .slideshow — the InstantPage V2 slideshow renderer already existed; the editor now produces
it). Article editor only (showsControls && mosaicContext != nil && count >= 2); the composer stays
mosaic-only.
- In-editor render (
MediaItemNodeView):updatePhotoVideoLayoutdispatches toupdateMosaicorupdateSlideshowby mode. Both reuse the SAME pooledmosaicCellsviaapplyCells(into:frames:…)— the slideshow path re-parents the cell hosts into aHorizontalPagingScrollView(one full-width page each) + aDisplay.PageControlNodeaty = height - 20;teardownSlideshowChromemoves cells back toselfbefore removing the scroll view so no fetch is torn down (no re-flash across a toggle).HorizontalPagingScrollViewbegins its pan only for predominantly-horizontal drags so the editor's vertical scroll still wins.hitTestorder: add/toggle buttons → per-cell controls → the scroll view (for paging). - Toggle button: a 36×36 glass button left of "+", shown only for a
count >= 2article-editor container; icon isrectangle.stack(while mosaic) /square.grid.2x2(while slideshow) — it depicts the layout you switch TO. FiresRichTextMediaControlKind.toggleLayoutthrough the media-control seam →MediaControlRequest.toggleLayout→DocumentCanvasView.toggleMediaDisplayMode(blockID:)(one undo step, rebuild-in-place liketoggleMediaSpoiler). The composer's control switch handles.toggleLayoutas a no-op. - Box height:
MediaBlockBox.slideshowSize(maxWidth:)= tallest item fitted to width, capped atmin(1000, width);nodeSize/caret stay caption-derived. - Reuse signature:
syncMediaItemViewsfoldsdisplayMode.rawValueinto the media-view items signature, so a toggle re-provides the view; the provider seam is 4-param(items, blockID, displayMode, existing).
Spec/plan in-tree: docs/superpowers/{specs/2026-07-17-richtext-media-layout-toggle-design.md,plans/2026-07-17-richtext-media-layout-toggle.md}.
History
The photo/video renderer was extracted from the shared InstantPageImageNode path into the standalone
RichTextMediaContentComponent on 2026-06-29 (build-green + runtime-verified on iPhone 17). Location and
audio were intentionally left on the StandaloneInstantPage* views. (Per the RichTextEditor convention,
the per-phase design spec/plan are not retained in-tree — this file is the durable record.)
Multi-media (mosaic) containers landed 2026-07-08 — full app build green. Design + plan are retained
in-tree: docs/superpowers/{specs/2026-07-08-richtext-multi-media-container-design.md,plans/2026-07-08-richtext-multi-media-container.md}.
Media aspect handling landed 2026-07-08 (full app build green) — single media now aspect-fit + blur,
mosaic cells crop-to-fill, height capped at min(1000, canvasWidth), box + renderer in lockstep.
Spec/plan in-tree: docs/superpowers/{specs/2026-07-08-richtext-media-fit-blur-height-cap-design.md,plans/2026-07-08-richtext-media-fit-blur-height-cap.md}.
Dedicated media add button landed 2026-07-08 (top-right corner Glass-styled +, article editor mosaic containers only; per-cell menu Delete-only; full app build green). Spec/plan in-tree: docs/superpowers/{specs/2026-07-08-richtext-media-add-button-design.md,plans/2026-07-08-richtext-media-add-button.md}.