Skip to content

Commit 1cfb55b

Browse files
isaacclaude
andcommitted
feat(richtext): dedicated media add (+) button
A glass + button in the top-right of the shared media view (article editor, photo/video only) opens the picker to append another medium; the per-cell more menu is now Delete-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c7d84be commit 1cfb55b

4 files changed

Lines changed: 100 additions & 30 deletions

File tree

‎submodules/TelegramUI/Components/RichTextAttachmentScreen/Sources/RichTextAttachmentScreen.swift‎

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -292,17 +292,6 @@ final class RichTextAttachmentScreenComponent: Component {
292292
}
293293
}
294294

295-
/// Image/video only — audio and location/map blocks are single-item and can't grow into a mosaic.
296-
private func isGroupableImageOrVideo(_ media: Media) -> Bool {
297-
if media is TelegramMediaImage {
298-
return true
299-
}
300-
if let file = media as? TelegramMediaFile {
301-
return file.isVideo
302-
}
303-
return false
304-
}
305-
306295
private func presentLinkPrompt() {
307296
guard let component = self.component else {
308297
return
@@ -458,20 +447,6 @@ final class RichTextAttachmentScreenComponent: Component {
458447
switch request.control {
459448
case .more:
460449
var items: [ContextMenuItem] = []
461-
if let tapped = self.attachedMedia[request.mediaID],
462-
self.isGroupableImageOrVideo(tapped), let addMore = request.addMore {
463-
items.append(.action(ContextMenuActionItem(
464-
text: "Add",
465-
icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) },
466-
action: { [weak self] _, f in
467-
f(.default)
468-
self?.pickMedia { mediaID, naturalSize, kind, _ in
469-
guard kind == .image || kind == .video else { return } // mosaic is photo/video only
470-
addMore(mediaID, naturalSize, kind)
471-
}
472-
}
473-
)))
474-
}
475450
items.append(.action(ContextMenuActionItem(
476451
text: "Delete",
477452
textColor: .destructive,
@@ -483,7 +458,11 @@ final class RichTextAttachmentScreenComponent: Component {
483458
self?.environment?.controller()?.presentInGlobalOverlay(controller)
484459
}
485460
case .add:
486-
break // the "+" button is not built yet
461+
guard let addMore = request.addMore else { break }
462+
self.pickMedia { mediaID, naturalSize, kind, _ in
463+
guard kind == .image || kind == .video else { return } // mosaic is photo/video only
464+
addMore(mediaID, naturalSize, kind)
465+
}
487466
case .delete:
488467
request.delete()
489468
}

���submodules/TelegramUI/Components/RichTextEditorMediaView/BUILD‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ swift_library(
1111
],
1212
deps = [
1313
"//submodules/AccountContext",
14+
"//submodules/AppBundle:AppBundle",
1415
"//submodules/TelegramCore",
1516
"//submodules/InstantPageUI",
1617
"//submodules/TelegramUI/Components/RichTextEditor:RichTextEditorUIKit",

‎submodules/TelegramUI/Components/RichTextEditorMediaView/CLAUDE.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ above).
9494

9595
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}`.
9696

97+
## Dedicated add button (photo/video containers, added 2026-07-08)
98+
99+
`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).
100+
101+
`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.
102+
103+
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}`.
104+
97105
## Future iterations (planned, not yet implemented)
98106

99107
- **Higher-quality video poster.** Video currently shows the file's small embedded thumbnail (via
@@ -120,3 +128,5 @@ in-tree: `docs/superpowers/{specs/2026-07-08-richtext-multi-media-container-desi
120128
Media aspect handling landed 2026-07-08 (full app build green) — single media now aspect-fit + blur,
121129
mosaic cells crop-to-fill, height capped at `min(1000, canvasWidth)`, box + renderer in lockstep.
122130
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}`.
131+
132+
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}`.

‎submodules/TelegramUI/Components/RichTextEditorMediaView/Sources/MediaItemNodeView.swift‎

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import Foundation
22
import UIKit
33
import ComponentFlow
4+
import Display
5+
import AppBundle
46
import AccountContext
57
import TelegramCore
68
import InstantPageUI
79
import RichTextEditorUIKit
810
import MosaicLayout
11+
import GlassBackgroundComponent
912

1013
/// Adapts the rich-text editor's `RichTextMediaItemView` seam to concrete media renderers, by kind:
1114
/// audio (music/voice `.file`) → `StandaloneInstantPageAudioView` (a playable row); location (`.geo`)
@@ -32,6 +35,18 @@ public final class MediaItemNodeView: UIView, RichTextMediaItemView {
3235
private let mosaicContext: AccountContext? // set for photo/video; nil for audio/location
3336
private let showsControls: Bool // forwarded to each photo/video cell's glass "more" button
3437

38+
// A dedicated "+" add button (article editor, photo/video containers only). Glass chrome mirroring the
39+
// per-cell ⋯ button (`RichTextMediaContentComponent.View`'s `moreButton`/`moreButtonBackgroundContainer`/
40+
// `moreButtonBackground`); top-right corner. Fires `.add` (container-level, itemIndex nil).
41+
private var addButtonContainer: GlassBackgroundContainerView?
42+
private var addButton: HighlightTrackingButton?
43+
private var addButtonBackground: GlassBackgroundView?
44+
private var addButtonIconView: UIImageView?
45+
46+
/// True when this view should show the add button: article editor (`showsControls`) + a photo/video
47+
/// container (`mosaicContext != nil` — never audio/location).
48+
private var showsAddButton: Bool { self.showsControls && self.mosaicContext != nil }
49+
3550
public init(context: AccountContext,
3651
items: [(media: EngineMedia, naturalSize: CGSize)],
3752
audioColorOverride: InstantPageAudioColorOverride? = nil,
@@ -89,6 +104,62 @@ public final class MediaItemNodeView: UIView, RichTextMediaItemView {
89104
if self.mosaicContext != nil {
90105
self.updateMosaic(size: size)
91106
}
107+
self.layoutAddButton(size: size)
108+
}
109+
110+
/// Builds the add button's glass chrome on first use (article-editor photo/video containers only).
111+
/// Mirrors `RichTextMediaContentComponent.View`'s more-button setup exactly: background inside the
112+
/// button, button inside the container's `contentView`, container added last (above everything else).
113+
private func ensureAddButton() {
114+
guard self.addButtonContainer == nil else { return }
115+
let container = GlassBackgroundContainerView()
116+
let background = GlassBackgroundView()
117+
let button = HighlightTrackingButton()
118+
background.isUserInteractionEnabled = false
119+
button.addSubview(background)
120+
container.contentView.addSubview(button)
121+
self.addSubview(container)
122+
let iconView = UIImageView(image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: .white))
123+
iconView.contentMode = .center
124+
button.addSubview(iconView)
125+
button.addTarget(self, action: #selector(self.addButtonPressed), for: .touchUpInside)
126+
button.highligthedChanged = { [weak button] highlighted in
127+
guard let button else { return }
128+
let transition: ComponentTransition = highlighted ? .immediate : .easeInOut(duration: 0.25)
129+
transition.setAlpha(view: button, alpha: highlighted ? 0.6 : 1.0)
130+
}
131+
self.addButtonContainer = container
132+
self.addButton = button
133+
self.addButtonBackground = background
134+
self.addButtonIconView = iconView
135+
}
136+
137+
@objc private func addButtonPressed() {
138+
guard let container = self.addButtonContainer else { return }
139+
self.onControlTapped?(.add, nil, container, container.bounds)
140+
}
141+
142+
/// Positions the add button top-right and shows/hides it (`showsAddButton`). Brought above the mosaic
143+
/// cells every pass since cell hosts are inserted after it may have been created.
144+
private func layoutAddButton(size: CGSize) {
145+
guard self.showsAddButton else {
146+
self.addButtonContainer?.isHidden = true
147+
return
148+
}
149+
self.ensureAddButton()
150+
guard let container = self.addButtonContainer, let button = self.addButton,
151+
let background = self.addButtonBackground, let iconView = self.addButtonIconView else { return }
152+
container.isHidden = false
153+
let buttonSize = CGSize(width: 36.0, height: 36.0)
154+
let inset: CGFloat = 8.0
155+
let frame = CGRect(x: size.width - inset - buttonSize.width, y: inset, width: buttonSize.width, height: buttonSize.height) // top-right
156+
container.frame = frame
157+
container.update(size: buttonSize, isDark: true, transition: .immediate)
158+
background.frame = CGRect(origin: .zero, size: buttonSize)
159+
background.update(size: buttonSize, cornerRadius: buttonSize.height * 0.5, isDark: true, tintColor: .init(kind: .panel), transition: .immediate)
160+
button.frame = CGRect(origin: .zero, size: buttonSize)
161+
iconView.frame = CGRect(origin: .zero, size: buttonSize)
162+
self.bringSubviewToFront(container) // above the mosaic cells
92163
}
93164

94165
/// Lays out photo/video cells, reusing an existing cell wherever `MosaicCellDiff` finds a matching pooled
@@ -170,18 +241,27 @@ public final class MediaItemNodeView: UIView, RichTextMediaItemView {
170241
if self.mosaicContext != nil {
171242
self.updateMosaic(size: self.bounds.size)
172243
}
244+
self.layoutAddButton(size: self.bounds.size)
173245
}
174246

175247
/// The editor treats media as non-interactive EXCEPT for the interactive controls the photo/video
176-
/// renderer exposes (the more button). Only a hit that resolves to such a control claims the touch;
177-
/// everything else — the poster area, and the audio/location branches entirely — returns nil so the
178-
/// touch falls through to the editor's own tap handling. Guarded by the standard visibility/point-inside
179-
/// checks so a culled (hidden) media view never claims a touch.
248+
/// renderer exposes (the per-cell more button, and — article editor only — this container's own add
249+
/// button). Only a hit that resolves to such a control claims the touch; everything else — the poster
250+
/// area, and the audio/location branches entirely — returns nil so the touch falls through to the
251+
/// editor's own tap handling. Guarded by the standard visibility/point-inside checks so a culled
252+
/// (hidden) media view never claims a touch. The add button is checked FIRST since it visually sits
253+
/// above the mosaic cells in the top-right corner.
180254
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
181255
guard !self.isHidden, self.isUserInteractionEnabled, self.alpha > 0.01,
182256
self.point(inside: point, with: event) else {
183257
return nil
184258
}
259+
if self.showsAddButton, let container = self.addButtonContainer, !container.isHidden {
260+
let inContainer = container.convert(point, from: self)
261+
if let hit = container.hitTest(inContainer, with: event) {
262+
return hit
263+
}
264+
}
185265
if self.mosaicContext != nil {
186266
for (_, cell) in self.mosaicCells {
187267
let inHost = cell.host.convert(point, from: self)

0 commit comments

Comments
 (0)