Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

2250 lines
125 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import AccountContext
import AttachmentUI
import ViewControllerComponent
import TelegramPresentationData
import PresentationDataUtils
import GlassBarButtonComponent
import GlassBackgroundComponent
import BundleIconComponent
import MultilineTextComponent
import EdgeEffect
import RichTextEditorCore
import RichTextEditorUIKit
import RichTextButtonIcons
import RichTextEditorMediaView
import InstantPageUI
import ContextUI
import Postbox
import TelegramCore
import CheckNode
import GlassControls
import ChatRichTextEditorComposer
import ChatTextLinkEditUI
import TextFormat
import UndoUI
import SwiftSignalKit
import ChatSendMessageActionUI
/// `RichTextChecklistMarkerView` host wrapper backing a checklist item's checkbox with a `CheckNode`
/// (an `ASDisplayNode`, so we host its `.view` — this is a `UIView`, not a node). The editor frames this
/// view in the marker gutter and calls `setChecked(_:animated:)` when the item toggles. A private copy
/// lives in each editor host (cross-module; duplication is expected).
private final class HostChecklistCheckboxView: UIView, RichTextChecklistMarkerView {
private let checkNode: CheckNode
init(theme: CheckNodeTheme, checked: Bool) {
self.checkNode = CheckNode(theme: theme, content: .check(isRectangle: true))
super.init(frame: .zero)
self.checkNode.isUserInteractionEnabled = false
self.addSubview(self.checkNode.view)
self.checkNode.setSelected(checked, animated: false)
}
required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() {
super.layoutSubviews()
self.checkNode.frame = self.bounds
}
func setChecked(_ checked: Bool, animated: Bool) {
self.checkNode.setSelected(checked, animated: animated)
}
}
private final class RichTextSendButtonComponent: Component {
let theme: PresentationTheme
let isEnabled: Bool
let isLocked: Bool
let action: () -> Void
let longPressAction: (() -> Void)?
init(theme: PresentationTheme, isEnabled: Bool, isLocked: Bool, action: @escaping () -> Void, longPressAction: (() -> Void)?) {
self.theme = theme
self.isEnabled = isEnabled
self.isLocked = isLocked
self.action = action
self.longPressAction = longPressAction
}
static func ==(lhs: RichTextSendButtonComponent, rhs: RichTextSendButtonComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.isEnabled != rhs.isEnabled {
return false
}
if lhs.isLocked != rhs.isLocked {
return false
}
if (lhs.longPressAction == nil) != (rhs.longPressAction == nil) {
return false
}
return true
}
final class View: UIView {
private let button = ComponentView<Empty>()
private let buttonMaskView = UIView()
private let buttonCutoutMaskView = UIImageView()
private let lockBackgroundView = GlassBackgroundView()
private let lockIconView = UIImageView()
private var longPressGestureRecognizer: UILongPressGestureRecognizer?
private var component: RichTextSendButtonComponent?
override init(frame: CGRect) {
self.buttonMaskView.backgroundColor = .white
if let filter = CALayer.luminanceToAlpha() {
self.buttonMaskView.layer.filters = [filter]
}
self.lockBackgroundView.isUserInteractionEnabled = false
self.lockIconView.isUserInteractionEnabled = false
super.init(frame: frame)
self.clipsToBounds = false
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.longPressed(_:)))
longPressGestureRecognizer.isEnabled = false
self.longPressGestureRecognizer = longPressGestureRecognizer
self.addGestureRecognizer(longPressGestureRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func longPressed(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began, let component = self.component else {
return
}
component.longPressAction?()
}
func update(component: RichTextSendButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.longPressGestureRecognizer?.isEnabled = component.longPressAction != nil
let buttonSize = self.button.update(
transition: transition,
component: AnyComponent(GlassControlGroupComponent(
theme: component.theme,
preferClearGlass: false,
background: component.isEnabled ? .activeTint(inset: false) : .color(component.theme.chat.inputPanel.panelControlDisabledColor),
items: [
GlassControlGroupComponent.Item(id: 0, content: .icon("Chat/Input/Text/SendIcon"), action: component.isEnabled ? component.action : nil)
],
minWidth: 44.0
)),
environment: {},
containerSize: availableSize
)
if let buttonView = self.button.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
transition.setFrame(view: buttonView, frame: CGRect(origin: .zero, size: buttonSize))
if component.isLocked {
var animateIn = false
if buttonView.mask !== self.buttonMaskView {
buttonView.mask = self.buttonMaskView
if !transition.animation.isImmediate {
animateIn = true
}
}
let maskOverflow: CGFloat = 36.0
self.buttonMaskView.frame = CGRect(origin: CGPoint(x: -maskOverflow, y: -maskOverflow), size: CGSize(width: buttonSize.width + maskOverflow * 2.0, height: buttonSize.height + maskOverflow * 2.0))
let lockDiameter: CGFloat = 24.0
let lockOffset: CGFloat = -7.0
let cutoutInset: CGFloat = 2.0 - UIScreenPixel
let cutoutDiameter = lockDiameter + cutoutInset * 2.0
let lockFrame = CGRect(x: lockOffset, y: lockOffset, width: lockDiameter, height: lockDiameter)
let cutoutFrame = lockFrame.insetBy(dx: -cutoutInset, dy: -cutoutInset)
if self.buttonCutoutMaskView.image?.size.height != cutoutDiameter {
self.buttonCutoutMaskView.image = generateStretchableFilledCircleImage(diameter: cutoutDiameter, color: .black)
}
if self.buttonCutoutMaskView.superview == nil {
self.buttonMaskView.addSubview(self.buttonCutoutMaskView)
}
self.buttonCutoutMaskView.frame = cutoutFrame.offsetBy(dx: maskOverflow, dy: maskOverflow)
if self.lockBackgroundView.superview == nil {
self.addSubview(self.lockBackgroundView)
}
self.bringSubviewToFront(self.lockBackgroundView)
self.lockBackgroundView.update(size: lockFrame.size, cornerRadius: lockDiameter * 0.5, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: .custom(style: .default, color: component.theme.list.itemCheckColors.fillColor)), isInteractive: false, transition: transition)
transition.setFrame(view: self.lockBackgroundView, frame: lockFrame)
transition.setAlpha(view: self.lockBackgroundView, alpha: 1.0)
if self.lockIconView.image == nil {
self.lockIconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/SmallLock"), color: .white)
}
if self.lockIconView.superview == nil {
self.lockBackgroundView.contentView.addSubview(self.lockIconView)
}
if let image = self.lockIconView.image {
let iconFrame = CGRect(
origin: CGPoint(
x: floorToScreenPixels((lockDiameter - image.size.width) * 0.5),
y: floorToScreenPixels((lockDiameter - image.size.height) * 0.5)
),
size: image.size
)
transition.setFrame(view: self.lockIconView, frame: iconFrame)
}
if animateIn {
self.lockBackgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.lockBackgroundView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2)
self.buttonCutoutMaskView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
}
} else {
if transition.animation.isImmediate {
if buttonView.mask === self.buttonMaskView {
buttonView.mask = nil
}
self.buttonCutoutMaskView.removeFromSuperview()
self.lockBackgroundView.removeFromSuperview()
} else {
self.lockBackgroundView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.lockBackgroundView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false, completion: { _ in
if buttonView.mask === self.buttonMaskView {
buttonView.mask = nil
}
self.buttonCutoutMaskView.removeFromSuperview()
self.lockBackgroundView.removeFromSuperview()
self.lockBackgroundView.layer.removeAllAnimations()
self.buttonCutoutMaskView.layer.removeAllAnimations()
})
self.buttonCutoutMaskView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
}
}
}
return buttonSize
}
}
func makeView() -> View {
return View(frame: .zero)
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class RichTextAttachmentScreenSendContextActions {
public let peerId: EnginePeer.Id
public let send: (RichTextAttachmentScreen.Document, [String: Media], [Int64: TelegramMediaFile], Bool, ChatSendMessageActionSheetController.SendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void
public let schedule: (RichTextAttachmentScreen.Document, [String: Media], [Int64: TelegramMediaFile], Bool, ChatSendMessageActionSheetController.SendParameters?) -> Void
public init(
peerId: EnginePeer.Id,
send: @escaping (RichTextAttachmentScreen.Document, [String: Media], [Int64: TelegramMediaFile], Bool, ChatSendMessageActionSheetController.SendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void,
schedule: @escaping (RichTextAttachmentScreen.Document, [String: Media], [Int64: TelegramMediaFile], Bool, ChatSendMessageActionSheetController.SendParameters?) -> Void
) {
self.peerId = peerId
self.send = send
self.schedule = schedule
}
}
public class RichTextAttachmentScreen: ViewControllerComponentContainer, AttachmentContainable {
public typealias Document = RichTextEditorCoreDocument
public enum Mode {
case standalone(savedDraft: Document?, media: [String: Media], emojiFiles: [Int64: TelegramMediaFile])
case edit(initialDocument: Document?, media: [String: Media], emojiFiles: [Int64: TelegramMediaFile])
}
public enum RichTextAttachment {
case image(ImageMediaReference)
case file(FileMediaReference)
case location(TelegramMediaMap)
}
public struct MediaRequest {
public struct ImageOrVideo {
public let limit: Int
public init(limit: Int) {
self.limit = limit
}
}
public let imageOrVideo: ImageOrVideo?
public let music: Bool
public let file: Bool
public let location: Bool
public init(imageOrVideo: ImageOrVideo?, music: Bool, file: Bool, location: Bool) {
self.imageOrVideo = imageOrVideo
self.music = music
self.file = file
self.location = location
}
}
public var requestAttachmentMenuExpansion: () -> Void = {}
public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in }
public var parentController: () -> ViewController? = { return nil }
public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in }
public var updateTabBarVisibility: (Bool, ContainedViewLayoutTransition) -> Void = { _, _ in }
public var cancelPanGesture: () -> Void = { }
public var isContainerPanning: () -> Bool = { return false }
public var isContainerExpanded: () -> Bool = { return false }
public var isMinimized: Bool = false
public var mediaPickerContext: AttachmentMediaPickerContext?
public var isPanGestureEnabled: (() -> Bool)? {
return { [weak self] in
guard let self, let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
return true
}
return componentView.isPanGestureEnabled()
}
}
private let context: AccountContext
private let sendMessage: (Document, [String: Media], [Int64: TelegramMediaFile], Bool) -> Void
private let syncContent: ((Document, [String: Media], [Int64: TelegramMediaFile]) -> Void)?
private let sendContextActions: RichTextAttachmentScreenSendContextActions?
public convenience init(
context: AccountContext,
mode: Mode,
sendMessage: @escaping (Document, [String: Media], [Int64: TelegramMediaFile]) -> Void,
syncContent: ((Document, [String: Media], [Int64: TelegramMediaFile]) -> Void)? = nil,
sendContextActions: RichTextAttachmentScreenSendContextActions? = nil,
preuploadPeerId: EnginePeer.Id? = nil,
presentAttachmentMenu: ((_ request: RichTextAttachmentScreen.MediaRequest, @escaping ([RichTextAttachmentScreen.RichTextAttachment]) -> Void) -> Void)?,
presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?
) {
self.init(
context: context,
mode: mode,
sendMessage: { document, media, emojiFiles, _ in
sendMessage(document, media, emojiFiles)
},
syncContent: syncContent,
sendContextActions: sendContextActions,
preuploadPeerId: preuploadPeerId,
presentAttachmentMenu: presentAttachmentMenu,
presentFormulaEditor: presentFormulaEditor
)
}
public init(
context: AccountContext,
mode: Mode,
sendMessage: @escaping (Document, [String: Media], [Int64: TelegramMediaFile], Bool) -> Void,
syncContent: ((Document, [String: Media], [Int64: TelegramMediaFile]) -> Void)? = nil,
sendContextActions: RichTextAttachmentScreenSendContextActions? = nil,
/// Peer whose chat this content will be sent to. Media attached here pre-uploads against it;
/// nil disables pre-upload (no peer to run `messages.uploadMedia` against).
preuploadPeerId: EnginePeer.Id? = nil,
presentAttachmentMenu: ((_ request: RichTextAttachmentScreen.MediaRequest, @escaping ([RichTextAttachmentScreen.RichTextAttachment]) -> Void) -> Void)?,
presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?,
pastedMarkdownParser: ((AccountContext, String) -> ChatInputContent?)? = nil
) {
self.context = context
self.sendMessage = sendMessage
self.syncContent = syncContent
self.sendContextActions = sendContextActions
let overNavigationContainer = SparseContainerView()
super.init(context: context, component: RichTextAttachmentScreenComponent(
context: context,
mode: mode,
sendContextActions: sendContextActions,
preuploadPeerId: preuploadPeerId,
overNavigationContainer: overNavigationContainer,
presentAttachmentMenu: presentAttachmentMenu,
presentFormulaEditor: presentFormulaEditor,
pastedMarkdownParser: pastedMarkdownParser
), navigationBarAppearance: .transparent, theme: .default)
self._hasGlassStyle = true
// Glass style: the Cancel/Done buttons are rendered by the View into
// overNavigationContainer, so the nav item only needs an empty placeholder.
self.navigationItem.setLeftBarButton(UIBarButtonItem(customView: UIView()), animated: false)
if let navigationBar = self.navigationBar {
navigationBar.customOverBackgroundContentView.insertSubview(overNavigationContainer, at: 0)
}
self.attemptNavigation = { [weak self] _ in
guard let self, let syncContent = self.syncContent, let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
return true
}
syncContent(componentView.currentDocument, componentView.currentMedia, componentView.currentEmojiFiles)
return true
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func resetForReuse() {
guard let syncContent = self.syncContent, let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
return
}
syncContent(componentView.currentDocument, componentView.currentMedia, componentView.currentEmojiFiles)
}
fileprivate func donePressed() {
guard let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
return
}
if !componentView.isSendRichFormattingLocked {
self.complete(withoutFormatting: false)
return
}
let strings = self.context.sharedContext.currentPresentationData.with { $0 }.strings
let controller = textAlertController(context: self.context, title: strings.RichText_RemoveFormattingTitle, text: strings.RichText_RemoveFormattingText, actions: [
TextAlertAction(type: .defaultAction, title: strings.RichText_SubscribeToPremium, action: { [weak self] in
guard let self else {
return
}
let premiumController = self.context.sharedContext.makePremiumIntroController(context: self.context, source: .richText, forceDark: false, dismissed: nil)
if let parentController = self.parentController() {
parentController.push(premiumController)
} else {
self.push(premiumController)
}
}),
TextAlertAction(type: .genericAction, title: strings.RichText_SendWithoutFormatting, action: { [weak self] in
self?.complete(withoutFormatting: true)
}),
TextAlertAction(type: .genericAction, title: strings.Common_Cancel, action: {
})
], actionLayout: .vertical)
self.present(controller, in: .window(.root))
}
fileprivate func complete(withoutFormatting: Bool) {
guard let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
self.dismiss()
return
}
self.sendMessage(componentView.currentDocument, componentView.currentMedia, componentView.currentEmojiFiles, withoutFormatting)
self.dismiss()
}
fileprivate func close() {
guard let syncContent = self.syncContent, let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
self.dismiss()
return
}
syncContent(componentView.currentDocument, componentView.currentMedia, componentView.currentEmojiFiles)
self.dismiss()
}
fileprivate func displayLongPressSendMenu(sourceSendButton: UIView) {
guard let sendContextActions = self.sendContextActions else {
return
}
let context = self.context
Task { @MainActor [weak self, weak sourceSendButton] in
guard let self, let sourceSendButton else {
return
}
let peerId = sendContextActions.peerId
let previousSupportedOrientations = self.supportedOrientations
let availableMessageEffects = await (context.availableMessageEffects |> take(1)).get()
let hasPremium = await (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> map { peer -> Bool in
guard case let .user(user) = peer else {
return false
}
return user.isPremium
}).get()
let peerStatus = await (context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Presence(id: peerId)
)).get()
guard let peer = await (context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)).get() else {
return
}
let initialData = await ChatSendMessageContextScreen.initialData(context: context, currentMessageEffectId: nil).get()
var sendWhenOnlineAvailable = false
if let peerStatus, case let .present(until) = peerStatus.status {
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
if currentTime > until {
sendWhenOnlineAvailable = true
}
}
if peerId.namespace == Namespaces.Peer.CloudUser && peerId.id._internalGetInt64Value() == 777000 {
sendWhenOnlineAvailable = false
}
let messageActionsController = makeChatSendMessageActionSheetController(
initialData: initialData,
context: context,
updatedPresentationData: nil,
peerId: peerId,
params: .sendMessage(SendMessageActionSheetControllerParams.SendMessage(
isScheduledMessages: false,
mediaPreview: nil,
mediaCaptionIsAbove: nil,
messageEffect: (nil, { _ in }),
attachment: false,
canSendWhenOnline: sendWhenOnlineAvailable,
forwardMessageIds: [],
canMakePaidContent: false,
currentPrice: nil,
hasTimers: false,
sendPaidMessageStars: nil,
isMonoforum: peer.isMonoForum
)),
hasEntityKeyboard: false,
gesture: nil,
sourceSendButton: sourceSendButton,
textInputSource: nil,
emojiViewProvider: nil,
completion: { [weak self] in
self?.supportedOrientations = previousSupportedOrientations
},
sendMessage: { [weak self] mode, parameters in
guard let self, let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
return
}
sendContextActions.send(componentView.currentDocument, componentView.currentMedia, componentView.currentEmojiFiles, false, mode, parameters)
self.dismiss()
},
schedule: { [weak self] params in
guard let self, let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
return
}
sendContextActions.schedule(componentView.currentDocument, componentView.currentMedia, componentView.currentEmojiFiles, false, params)
self.dismiss()
},
editPrice: { _ in
},
openPremiumPaywall: { [weak self] c in
guard let self else {
return
}
if let parentController = self.parentController() {
parentController.push(c)
} else {
self.push(c)
}
},
reactionItems: nil,
availableMessageEffects: availableMessageEffects,
isPremium: hasPremium
)
self.present(messageActionsController, in: .window(.root))
}
}
}
final class RichTextAttachmentScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
// Held for the next step: the RichTextEditor demo will read context and add
// its editor view into the View's content container.
let context: AccountContext
let mode: RichTextAttachmentScreen.Mode
let sendContextActions: RichTextAttachmentScreenSendContextActions?
let overNavigationContainer: UIView
let preuploadPeerId: EnginePeer.Id?
let presentAttachmentMenu: ((_ request: RichTextAttachmentScreen.MediaRequest, @escaping ([RichTextAttachmentScreen.RichTextAttachment]) -> Void) -> Void)?
let presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?
let pastedMarkdownParser: ((AccountContext, String) -> ChatInputContent?)?
init(context: AccountContext, mode: RichTextAttachmentScreen.Mode, sendContextActions: RichTextAttachmentScreenSendContextActions?, preuploadPeerId: EnginePeer.Id?, overNavigationContainer: UIView, presentAttachmentMenu: ((_ request: RichTextAttachmentScreen.MediaRequest, @escaping ([RichTextAttachmentScreen.RichTextAttachment]) -> Void) -> Void)?, presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?, pastedMarkdownParser: ((AccountContext, String) -> ChatInputContent?)?) {
self.context = context
self.mode = mode
self.sendContextActions = sendContextActions
self.overNavigationContainer = overNavigationContainer
self.preuploadPeerId = preuploadPeerId
self.presentAttachmentMenu = presentAttachmentMenu
self.presentFormulaEditor = presentFormulaEditor
self.pastedMarkdownParser = pastedMarkdownParser
}
static func ==(lhs: RichTextAttachmentScreenComponent, rhs: RichTextAttachmentScreenComponent) -> Bool {
return true
}
final class View: UIView {
private let navigationTapView: UIView
private let title = ComponentView<Empty>()
private let leftNavActionsBar = ComponentView<Empty>()
private let rightNavActionsBar = ComponentView<Empty>()
private let editor = RichTextEditorView()
// Frosted fade at the screen top (mirrors ComposePollScreen) — scrolling content dissolves into the
// nav region. Overlaid above the editor; the nav buttons live in the separate over-nav container.
private let topEdgeEffectView = EdgeEffectView()
/// The current editor document, read by the controller's `donePressed`.
var currentDocument: Document {
return self.editor.document
}
/// Picked media keyed by the opaque `mediaID` handed to the editor. Read by `donePressed`.
private var attachedMedia: [String: Media] = [:]
/// Held for as long as this screen is open. Reconciled from the LIVE document, so removing a
/// medium (or undoing its insertion) releases its need and the upload is grace-cancelled.
private var preuploadNeeds: MediaPreuploadNeeds?
/// One progress subscription per medium currently in the document.
private var preuploadObservers: [EngineMedia.Id: Disposable] = [:]
deinit {
for (_, disposable) in self.preuploadObservers {
disposable.dispose()
}
}
/// Every `mediaID` the document references, including inside tables, quotes and details.
private static func mediaIDs(in blocks: [Block], into result: inout [String]) {
for block in blocks {
switch block {
case let .media(mediaBlock):
for item in mediaBlock.items {
result.append(item.mediaID)
}
case let .blockQuote(quote):
mediaIDs(in: quote.children, into: &result)
case let .details(details):
mediaIDs(in: details.children, into: &result)
case let .table(table):
for row in table.rows {
for cell in row.cells {
mediaIDs(in: cell.blocks, into: &result)
}
}
case .paragraph, .code, .pullQuote, .buttonRow:
break
}
}
}
/// Bring the set of uploading media in line with what the document currently references,
/// and (re)bind a progress subscription for each. Cheap enough to run on every edit: it is a
/// walk of the block tree plus dictionary work, no conversion.
private func reconcilePreupload() {
guard let component = self.component, let peerId = component.preuploadPeerId else {
return
}
var ids: [String] = []
Self.mediaIDs(in: self.editor.document.blocks, into: &ids)
var media: [EngineMedia] = []
var live = Set<EngineMedia.Id>()
for mediaID in ids {
guard let value = self.attachedMedia[mediaID], let id = value.id, !live.contains(id) else {
continue
}
live.insert(id)
media.append(EngineMedia(value))
}
let needs: MediaPreuploadNeeds
if let existing = self.preuploadNeeds {
needs = existing
} else {
needs = component.context.engine.messages.makeMediaPreuploadNeeds()
self.preuploadNeeds = needs
}
needs.update(peerId: peerId, media: media)
for (id, disposable) in self.preuploadObservers where !live.contains(id) {
disposable.dispose()
self.preuploadObservers.removeValue(forKey: id)
}
for id in live where self.preuploadObservers[id] == nil {
self.preuploadObservers[id] = (component.context.engine.messages.mediaPreuploadState(id: id)
|> deliverOnMainQueue).start(next: { [weak self] state in
self?.applyPreuploadState(state, for: id)
})
}
}
/// Apply one medium's pre-upload state.
///
/// On `.done` the local `Media` is REPLACED BY the cloud one **at the key the document
/// already references** — the editor addresses media by an opaque `mediaID` string, so the
/// value swap promotes the medium everywhere downstream with no document mutation, no undo
/// entry and no relayout. Re-deriving the key from the cloud media would be a bug: promotion
/// changes the `MediaId`, so the new key is one the document does not reference and the
/// medium would silently vanish on read-back.
private func applyPreuploadState(_ state: EngineMediaPreuploadState?, for id: EngineMedia.Id) {
guard let component = self.component else {
return
}
switch state {
case .progress:
// Progress display is owned by RichTextMediaContentComponent, which subscribes to the
// same state itself. This observer exists ONLY for the promotion write-back below.
return
case let .done(cloudMedia):
let raw = cloudMedia._asMedia()
guard let key = self.attachedMedia.first(where: { $0.value.id == id })?.key,
let localMedia = self.attachedMedia[key] else {
return
}
// Let the already-downloaded local bytes serve the cloud resource, so the promoted
// medium does not flash a placeholder.
if let localResource = preuploadPrimaryResource(localMedia), let cloudResource = preuploadPrimaryResource(raw) {
component.context.engine.resources.moveResourceData(
from: EngineMediaResource.Id(localResource.id),
to: EngineMediaResource.Id(cloudResource.id),
synchronous: true
)
}
self.attachedMedia[key] = raw
case .failed, .none:
return
}
// Guarded like `editor.onChange`: this can land during a layout pass (the first progress
// value often arrives synchronously on subscribe), and re-entering `update` from inside
// it is what the composer's requestLayout re-entry guard exists to prevent.
guard !self.isUpdating else {
return
}
self.componentState?.updated(transition: .immediate)
}
/// The picked media map, keyed by the editor's `mediaID`. Read by the controller's `donePressed`.
var currentMedia: [String: Media] {
return self.attachedMedia
}
/// The custom-emoji file store (seeded + user-inserted), keyed by fileId. Read by the controller's
/// `donePressed` so each emoji run's `TelegramMediaFile` is re-attached when converting back.
var currentEmojiFiles: [Int64: TelegramMediaFile] {
return self.emojiKeyboard?.currentEmojiFiles ?? [:]
}
var isSendRichFormattingLocked: Bool {
guard let component = self.component, !component.context.isPremium else {
return false
}
let content = chatInputContent(fromDocument: self.currentDocument, media: self.currentMedia, emojiFiles: self.currentEmojiFiles)
return !content.isEmpty && !content.isEntityExpressible(options: [.quotesRequireRichContent])
}
private var component: RichTextAttachmentScreenComponent?
private var environment: EnvironmentType?
private let actionBar = ComponentView<Empty>()
private let aiButton = ComponentView<Empty>()
private let sendButton = ComponentView<Empty>()
private let sendButtonExtractedContainer = ContextExtractedContentContainingView()
private var emojiKeyboard: RichTextEmojiKeyboardController?
private var componentState: EmptyComponentState?
private var isUpdating = false
private var lastTabBarVisible: Bool?
private var didShowPremiumToast = false
/// The `PresentationTheme` last mapped into the editor. `PresentationTheme` is a shared `final
/// class`, so reference inequality is the cheap change-signal — guarding the editor `theme` setter
/// (which does an unconditional reload+redraw) against firing on every keystroke (`onChange` →
/// `componentState.updated` → `update`).
private var appliedTheme: PresentationTheme?
override init(frame: CGRect) {
self.navigationTapView = UIView()
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func isPanGestureEnabled() -> Bool {
return !viewTreeContainsFirstResponder(view: self.editor)
}
private func textCell(_ id: String, _ lines: [String]) -> Cell {
Cell(id: BlockID(id), blocks: lines.enumerated().map { i, t in
.paragraph(ParagraphBlock(id: BlockID("\(id)p\(i)"), runs: [TextRun(text: t)]))
})
}
// MARK: Phase 5b — image picker + link prompt
/// Picks one medium via the host attachment menu, registers its raw `Media` in `attachedMedia` (so the
/// media-view provider can resolve it), and hands back the editor-facing `(mediaID, naturalSize, kind,
/// caption)`. Callers decide what to do with it (insert a new block, or append to an existing one).
private func pickMedia(request: RichTextAttachmentScreen.MediaRequest, completion: @escaping (_ items: [(mediaID: String, naturalSize: CGSize, kind: MediaKind, caption: [TextRun])]) -> Void) {
guard let component = self.component else {
return
}
component.presentAttachmentMenu?(request, { [weak self] attachments in
guard let self else {
return
}
var results: [(mediaID: String, naturalSize: CGSize, kind: MediaKind, caption: [TextRun])] = []
for attachment in attachments {
let media: Media
let kind: MediaKind
let naturalSize: CGSize
switch attachment {
case let .image(imageReference):
let image = imageReference.media
media = image
kind = .image
naturalSize = image.representations.last?.dimensions.cgSize ?? CGSize(width: 1, height: 1)
case let .file(fileReference):
let file = fileReference.media
if file.isVideo {
media = file
kind = .video
naturalSize = file.dimensions?.cgSize ?? CGSize(width: 1, height: 1)
} else if file.isMusic || file.isVoice {
// Audio (music from the Audio picker; voice only via edit round-trips). The block is a
// fixed-height row, so naturalSize is ignored by MediaBlockBox — pass a 1x1 placeholder.
media = file
kind = .audio
naturalSize = CGSize(width: 1.0, height: 1.0)
} else {
// Everything else from the Files tab is a document row — including an image-mime
// file, matching that tab's "send as file" meaning. Also a fixed-height row, so
// naturalSize is ignored by MediaBlockBox; pass the same 1x1 placeholder as audio.
media = file
kind = .document
naturalSize = CGSize(width: 1.0, height: 1.0)
}
case let .location(map):
// A map is id-less, so mint a deterministic key from its coordinates; the venue title (if any)
// seeds the caption (a raw dropped pin has no venue -> empty caption). Self-contained, since
// the shared `media.id` path below can't key an id-less medium.
let mediaID = "map:\(map.latitude):\(map.longitude)"
self.attachedMedia[mediaID] = map
let caption: [TextRun] = map.venue?.title.isEmpty == false ? [TextRun(text: map.venue!.title)] : []
results.append((mediaID, CGSize(width: 600.0, height: 300.0), .location, caption))
continue
}
guard let mediaId = media.id else { continue }
let mediaID = "\(mediaId.namespace):\(mediaId.id)"
self.attachedMedia[mediaID] = media
results.append((mediaID, naturalSize, kind, []))
}
// Called here as well as from onChange: attachedMedia is populated in this closure,
// so the need is held from the earliest possible moment. The onChange reconcile that
// follows the insert is then a no-op for these ids.
self.reconcilePreupload()
completion(results)
})
}
private func presentImagePicker() {
self.pickMedia(request: RichTextAttachmentScreen.MediaRequest(
imageOrVideo: RichTextAttachmentScreen.MediaRequest.ImageOrVideo(limit: 10),
music: true,
file: true,
location: true
)) { [weak self] items in
guard let self else { return }
if items.count == 1 {
let item = items[0]
self.editor.insertMedia(mediaID: item.mediaID, naturalSize: item.naturalSize, kind: item.kind, caption: item.caption)
} else if items.count >= 2 {
// Multi-select only ever surfaces photos/videos (the gallery tab is the only
// multiselect-enabled source; music/location are single). So a >=2 batch is always a
// valid photo/video album -> one mosaic MediaBlock, inserted as one undo step.
let mediaItems = items.map { item in
MediaItem(
mediaID: item.mediaID,
kind: item.kind,
naturalSize: Size2D(width: Double(item.naturalSize.width), height: Double(item.naturalSize.height))
)
}
let block = MediaBlock(id: BlockID.generate(), items: mediaItems)
self.editor.insertDocument(Document(blocks: [.media(block)]))
}
}
}
private func presentLinkPrompt() {
guard let component = self.component, let environment = self.environment,
let controller = environment.controller() as? RichTextAttachmentScreen else {
return
}
// Reuse the shared chat link-editing UI (title/selected-text label/URL field + webpage preview),
// reading the current selection + existing link from the editor's native API and applying the
// result back through it — mirrors the chat composer's `openLinkEditing` rich-text branch.
let selectedText = self.editor.selectedText()
let existingLink = self.editor.currentLink()
let linkController = chatTextLinkEditController(
context: component.context,
text: environment.strings.TextFormat_AddLinkText(selectedText).string,
link: existingLink,
apply: { [weak self] link, _ in
guard let self, let link else { return }
self.editor.becomeFirstResponder()
if link.isEmpty {
self.editor.removeLink()
} else {
self.editor.setLink(link)
}
}
)
controller.present(linkController, in: .window(.root))
}
/// Presents the list-marker picker (None / Bullet / Numbered / Checklist), applying the choice to
/// the paragraphs the current caret or selection touches. Shared by the caret-case "add block"
/// bar and the text-only selection bar.
private func presentListMenu(from sourceView: UIView) {
guard let component = self.component, let environment = self.environment else {
return
}
guard let controller = environment.controller() as? RichTextAttachmentScreen else {
return
}
let current = self.editor.currentState().listMarker
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_List_None, icon: { theme in
UIImage()
}, additionalLeftIcon: { theme in
return current == nil ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : UIImage()
}, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.editor.setList(nil)
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_List_Bullet, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatBulletList"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: { theme in
return current == .bullet ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : UIImage()
}, iconPosition: .left, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.editor.setList(.bullet)
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_List_Numbered, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatNumberList"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: { theme in
return current == .ordered ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : UIImage()
}, iconPosition: .left, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.editor.setList(.ordered)
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_List_Checklist, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatChecklist"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: { theme in
return current == .checklist ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : UIImage()
}, iconPosition: .left, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.editor.setList(.checklist)
})))
// "Detail Block" — an INSERT action (no checkmark, unlike the marker toggles above): inserts a
// fresh, expanded folding block at the caret. Not premium-gated (matches its list-menu siblings).
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_List_Detail, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Expand"), color: theme.contextMenu.primaryColor)
}, iconPosition: .left, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.editor.insertDetailsBlock()
})))
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let contextController = makeContextController(
presentationData: presentationData,
source: .reference(RichTextActionContextReferenceSource(sourceView: sourceView, containerView: controller.view)),
items: .single(ContextController.Items(content: .list(items))),
gesture: nil
)
(controller.parentController() ?? controller).presentInGlobalOverlay(contextController)
}
/// Normalizes the caret's / selection's paragraph(s) to plain body text, stripping any block
/// container they sit in: a code block or pull quote is toggled back to body paragraphs; a list
/// marker and every enclosing block-quote level are removed; a heading (or any other non-body
/// style) is down-converted. Backs the "Text" item in `presentAddMenu`.
private func convertToBodyText() {
let live = self.editor.currentState()
// Code blocks and pull quotes are standalone box types, each with its own toggle-off that
// splits back into body paragraphs — so they replace, rather than compose with, the steps below.
if live.isCodeBlock {
self.editor.makeCodeBlock()
return
}
if live.isPullQuote {
self.editor.makePullQuote()
return
}
// Unwrap every enclosing block-quote level FIRST: `setList` only mutates top-level boxes, so a
// quoted list item must be promoted to a top-level paragraph before its list marker can be cleared.
var guardCount = 0
while self.editor.currentState().blockQuoteDepth > 0 && guardCount < 32 {
self.editor.unwrapBlockQuoteLevel()
guardCount += 1
}
if self.editor.currentState().listMarker != nil {
self.editor.setList(nil)
}
self.editor.setParagraphStyle(.body)
}
/// When the editor is unfocused (no caret), an Add-menu format/insert action should target the END of
/// the document, not the default offset-0 start (which touches no paragraph, so the command no-ops).
/// Focuses the editor and drops the caret at the end so the following command applies there. No-op when
/// a caret already exists — the user's position is preserved.
private func focusEditorAtDocumentEndIfNeeded(hasCursor: Bool) {
guard !hasCursor else { return }
self.editor.becomeFirstResponder()
self.editor.moveCaretToDocumentEnd()
}
private func presentAddMenu(from sourceView: UIView) {
guard let component = self.component, let environment = self.environment, let controller = environment.controller() as? RichTextAttachmentScreen else {
return
}
let editorState = self.editor.currentState()
// Captured at menu-open time (before presenting can change first-responder state): whether the user
// has a live caret. When false, the Add-menu actions target the document end instead of offset 0.
let hasCursor = viewTreeContainsFirstResponder(view: self.editor)
var items: [ContextMenuItem] = []
if !editorState.hasSelection {
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuHeading, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatHeading"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: component.context.isPremium ? nil : { _ in
return UIImage(bundleImageName: "Premium/ContextStar")
}, action: { [weak self] c, _ in
guard let self, let environment = self.environment else {
c?.dismiss(completion: nil)
return
}
let live = self.editor.currentState()
var subItems: [ContextMenuItem] = []
subItems.append(.action(ContextMenuActionItem(text: environment.strings.ChatList_Context_Back, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.contextMenu.primaryColor)
}, iconPosition: .left, action: { c, _ in
c?.popItems()
})))
subItems.append(.separator)
for level in 0 ..< 6 {
let fontSize: CGFloat
switch level {
case 0:
fontSize = 24
case 1:
fontSize = 21
case 2:
fontSize = 19
case 3:
fontSize = 18
case 4:
fontSize = 17
case 5:
fontSize = 16
default:
fontSize = 24
}
let mappedStyle: ParagraphStyleName
switch level {
case 0:
mappedStyle = .heading1
case 1:
mappedStyle = .heading2
case 2:
mappedStyle = .heading3
case 3:
mappedStyle = .heading4
case 4:
mappedStyle = .heading5
case 5:
mappedStyle = .heading6
default:
mappedStyle = .heading1
}
subItems.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuHeadingItem("\(level + 1)").string, textFont: .custom(font: Font.with(size: fontSize, design: .serif, weight: .semibold), height: nil, verticalOffset: nil), icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatHeading\(level + 1)"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: { theme in
return live.paragraphStyle == mappedStyle ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : UIImage()
}, iconPosition: .left, action: { [weak self] _, f in
guard let self else {
f(.default)
return
}
if hasCursor {
self.editor.setParagraphStyle(mappedStyle)
} else {
// No caret: append a NEW empty paragraph with this heading style at the end and
// focus it, rather than converting an existing paragraph (a no-op at offset 0).
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
self.editor.insertDocument(Document(blocks: [.paragraph(ParagraphBlock(id: BlockID.generate(), style: mappedStyle))]))
}
f(.default)
})))
}
c?.pushItems(items: .single(ContextController.Items(content: .list(subItems))))
})))
}
// "Text" normalizes to a plain body paragraph. Offer it whenever there is no selection (so the
// caret's paragraph can always be reset — e.g. a heading down-converted), or when the selection
// is not already plain body text: it sits in a block container (quote / code / list / pull quote)
// or carries a non-body paragraph style (a heading). Tapping it strips that container/style.
let isPlainBody = !editorState.isCodeBlock
&& !editorState.isPullQuote
&& editorState.listMarker == nil
&& editorState.blockQuoteDepth == 0
&& (editorState.paragraphStyle == nil || editorState.paragraphStyle == .body)
if !editorState.hasSelection || !isPlainBody {
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuText, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatText"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
guard let self else {
c?.dismiss(completion: nil)
return
}
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
self.convertToBodyText()
c?.dismiss(completion: nil)
})))
}
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuQuote, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatQuote"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
guard let self else {
c?.dismiss(completion: nil)
return
}
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
let live = self.editor.currentState()
if live.blockQuoteDepth > 0 {
self.editor.unwrapBlockQuoteLevel()
} else {
self.editor.wrapInBlockQuote()
}
c?.dismiss(completion: nil)
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuPullquote, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatPullquote"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: component.context.isPremium ? nil : { _ in
return UIImage(bundleImageName: "Premium/ContextStar")
}, action: { [weak self] c, _ in
guard let self else {
c?.dismiss(completion: nil)
return
}
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
let live = self.editor.currentState()
if live.blockQuoteDepth > 0 {
self.editor.unwrapBlockQuoteLevel()
}
if live.isPullQuote {
} else {
self.editor.makePullQuote()
}
c?.dismiss(completion: nil)
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuCode, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatCode"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: component.context.isPremium ? nil : { _ in
return UIImage(bundleImageName: "Premium/ContextStar")
}, action: { [weak self] c, _ in
guard let self else {
c?.dismiss(completion: nil)
return
}
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
let live = self.editor.currentState()
if live.blockQuoteDepth > 0 {
self.editor.unwrapBlockQuoteLevel()
}
if live.isCodeBlock {
} else {
self.editor.makeCodeBlock()
}
c?.dismiss(completion: nil)
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuFormula, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/FormatFormula"), color: theme.contextMenu.primaryColor)
}, additionalLeftIcon: component.context.isPremium ? nil : { _ in
return UIImage(bundleImageName: "Premium/ContextStar")
}, action: { [weak self] c, _ in
guard let self else {
c?.dismiss(completion: nil)
return
}
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
self.component?.presentFormulaEditor?(nil, { [weak self] latex in
guard let self else {
return
}
self.editor.insertFormula(latex: latex)
DispatchQueue.main.async { [weak self] in
self?.editor.becomeFirstResponder()
}
})
c?.dismiss(completion: nil)
})))
// Buttons. With a SELECTION the item converts it into one inline pill (the Link flow's
// analogue); with no selection it drops a block row, whose sheet the user opens by tapping
// the pill.
if editorState.hasSelection {
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuInlineButton, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
c?.dismiss(completion: nil)
self?.editor.makeSelectionInlineButton()
})))
} else {
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_MenuButtonRow, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
c?.dismiss(completion: nil)
guard let self else {
return
}
// No caret: drop the row at the document end, not at offset 0 — same rule as insertTable.
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
self.editor.insertButtonRow()
})))
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let contextController = makeContextController(
presentationData: presentationData,
source: .reference(RichTextActionContextReferenceSource(sourceView: sourceView, containerView: controller.view)),
items: .single(ContextController.Items(content: .list(items))),
gesture: nil
)
(controller.parentController() ?? controller).presentInGlobalOverlay(contextController)
}
private func presentActionMenu(from sourceView: UIView, items: [ContextMenuItem], actionsPosition: ContextControllerReferenceViewInfo.ActionsPosition = .top) {
guard let component = self.component else { return }
guard let selfController = self.environment?.controller() else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let controller = makeContextController(
presentationData: presentationData,
source: .reference(RichTextActionContextReferenceSource(sourceView: sourceView, containerView: selfController.view, actionsPosition: actionsPosition)),
items: .single(ContextController.Items(content: .list(items))),
gesture: nil
)
selfController.presentInGlobalOverlay(controller)
}
/// Maps the app theme to the editor's render colors. Every value is `PresentationTheme`-derived —
/// no OS-semantic `UIColor` survives on this path. accent/table derivations mirror the chat
/// composer (`ChatTextInputPanelNode.makeRichTextThemeColors`); text uses `list.item*` because the
/// screen's surface is `list.plainBackgroundColor`.
private static func mapEditorTheme(_ theme: PresentationTheme) -> RichTextEditorTheme {
let codeFill = theme.list.itemAccentColor.withMultipliedAlpha(0.1)
// A code BLOCK's band takes the highlighted-table-cell fill, not an accent tint — one
// local so the two cannot drift (the renderer binds them the same way, via
// `tableHeaderColor`). Inline code keeps `codeFill`: a run-level pill inside body text
// is a different surface from a full-width block band.
let tableHighlightFill = theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.05)
let shadowCursorColor: UIColor
if theme.overallDarkAppearance {
shadowCursorColor = UIColor(white: 1.0, alpha: 0.4)
} else {
shadowCursorColor = UIColor(white: 0.0, alpha: 0.3)
}
return RichTextEditorTheme(
primaryText: theme.list.itemPrimaryTextColor,
secondaryText: theme.list.itemSecondaryTextColor,
placeholder: theme.list.itemPlaceholderTextColor,
accent: theme.list.itemAccentColor,
tableBorder: theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1),
tableHeaderBackground: tableHighlightFill,
codeBackground: tableHighlightFill,
listMarker: theme.list.itemPrimaryTextColor,
inlineCodeBackground: codeFill,
markedTextUnderline: theme.list.itemPrimaryTextColor,
spoilerDust: theme.list.itemSecondaryTextColor,
containerPlaceholder: theme.list.itemPlaceholderTextColor.mixedWith(theme.list.itemAccentColor, alpha: 0.15).withMultipliedBrightnessBy(theme.overallDarkAppearance ? 1.1 : 0.9),
shadowCursor: shadowCursorColor,
quoteAuthorText: theme.list.itemAccentColor,
quoteAuthorPlaceholder: theme.list.itemPlaceholderTextColor.mixedWith(theme.list.itemAccentColor, alpha: 0.15).withMultipliedBrightnessBy(theme.overallDarkAppearance ? 1.1 : 0.9),
// Pill colours, mirroring `instantPageButtonColors`: the neutral pill takes the panel
// fill + accent label, and danger/success carry the LABEL colour (the editor derives
// their 15% fills itself, as the renderer does).
buttonNeutralFill: theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.08),
buttonNeutralLabel: theme.list.itemAccentColor,
buttonDanger: theme.list.itemDestructiveColor,
buttonSuccess: theme.list.itemDisclosureActions.constructive.fillColor
)
}
func presentPremiumToast() {
guard let component = self.component, let controller = self.environment?.controller() as? RichTextAttachmentScreen else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let overlayController = UndoOverlayController(
presentationData: presentationData,
content: .premiumPaywall(title: nil, text: presentationData.strings.RichText_PremiumToastText, customUndoText: nil, timeout: nil, linkAction: nil),
elevatedLayout: true,
action: { action in
if case .info = action {
}
return true
}
)
(controller.parentController() ?? controller).presentInGlobalOverlay(overlayController)
}
func update(component: RichTextAttachmentScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
let environment = environment[EnvironmentType.self].value
self.componentState = state
self.environment = environment
self.isUpdating = true
defer { self.isUpdating = false }
if self.component == nil {
editor.placeholders = RichTextEditorPlaceholders(body: environment.strings.RichText_PlaceholderBody, listEnd: "", listOutdent: "", pullQuote: environment.strings.RichText_PlaceholderQuote, blockQuote: environment.strings.RichText_PlaceholderQuote, codeBlock: environment.strings.RichText_PlaceholderCode, codeLanguage: environment.strings.RichText_PlaceholderCodeLanguage, detailsTitle: environment.strings.RichText_PlaceholderDetailTitle, quoteAuthor: environment.strings.RichText_PlaceholderQuoteAuthor, caption: environment.strings.RichText_PlaceholderCaption)
editor.editMenuStrings = RichTextEditorMenuStrings(
format: environment.strings.TextFormat_Format,
bold: environment.strings.TextFormat_Bold,
italic: environment.strings.TextFormat_Italic,
underline: environment.strings.TextFormat_Underline,
lookUp: environment.strings.Conversation_ContextMenuLookUp,
translate: environment.strings.Conversation_ContextMenuTranslate,
share: environment.strings.Conversation_ContextMenuShare
)
// The screen paints `list.plainBackgroundColor` (below); clear the editor's opaque default
// `.systemBackground` so that themed surface shows through.
editor.canvasBackgroundColor = .clear
// Theme the editor's mapper BEFORE seeding the document: the document setter builds each
// block's attributed string with the mapper's current theme (baking in the foreground
// color), and the `editor.theme` setter only re-maps existing boxes when `bounds.width > 0`
// — which is false here (the editor frame is set later this pass). So seeding pre-existing
// text before theming left it the `.default` black foreground. (The guarded re-apply below
// is a no-op on this first pass since `appliedTheme` is now set, and handles later theme
// changes when the frame — and a working reload width — exists.)
editor.theme = Self.mapEditorTheme(environment.theme)
// Lay text out with the exact numbers the recipient's renderer will use. This document is
// sent as a rich message, so the counterpart surface is the chat bubble — the same metrics
// the composer uses. Set alongside `theme` and BEFORE `editor.document`, per the
// host-ordering invariant: the document setter bakes the current mapper into each block's
// attributed string.
editor.renderMetrics = InstantPageTheme.chatMessageRenderMetrics()
self.appliedTheme = environment.theme
// Quote geometry for the full-page article editor. Defaults == the editor's built-in look;
// tune here to diverge from the chat composer.
editor.quoteStyle = QuoteStyle(leadingInset: 9.0, topInset: 4.0, bottomInset: 4.0)
editor.pullQuoteStyle = PullQuoteStyle()
// Quote collapse/expand affordance icons (same assets as the chat composer / legacy input).
if let collapse = UIImage(bundleImageName: "Media Gallery/Minimize")?.precomposed().withRenderingMode(.alwaysTemplate),
let expand = UIImage(bundleImageName: "Media Gallery/Fullscreen")?.precomposed().withRenderingMode(.alwaysTemplate) {
editor.quoteCollapseIcons = RichTextEditorQuoteCollapseIcons(collapse: collapse, expand: expand)
}
// Detail-block fold chevron — the same vertical arrow the InstantPage V2 renderer uses.
editor.detailsChevronImage = UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow")?.withRenderingMode(.alwaysTemplate)
// Markdown-on-paste: plain pasted text that parses as markdown with formatting/structure is
// spliced as rich content instead of literal text. The parser is injected by the host (only
// the monolith can reach the BrowserUI-backed markdown pipeline); nil falls back to the
// editor's built-in plain-text paste.
editor.plainTextFragmentTransformer = { [weak self] text in
guard let self, let component = self.component, let parser = component.pastedMarkdownParser, let content = parser(component.context, text) else {
return nil
}
return pasteFragmentDocument(fromChatInputContent: content)
}
// A selection-handle ("knob") drag must NOT be hijacked by the interactive keyboard-/modal-
// dismiss gestures. These Display flags can only be set host-side (the editor package can't
// import Display) and are applied to the hit-testable handle views, so the effect is scoped to
// knob interaction — not the whole editor surface.
editor.configureSelectionHandleView = { handle in
handle.disablesInteractiveTransitionGestureRecognizer = true // navigation back-swipe (triggered by a horizontal knob drag)
handle.disablesInteractiveModalDismiss = true
handle.disablesInteractiveKeyboardGestureRecognizer = true
}
// Table row/column structural menu: the editor hands us a framework-agnostic descriptor; we
// present it as a ContextController anchored to the tapped handle (in the editor's canvas).
editor.onRequestTableStructuralMenu = { [weak self] request in
guard let self, let component = self.component else { return }
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
presentTableStructuralMenu(request, presentationData: presentationData) { [weak self] controller in
self?.environment?.controller()?.presentInGlobalOverlay(controller)
}
}
// Media control (more button) menu: the editor hands us an account-free request; we present
// our own menu anchored to the tapped control. `delete` is bound to the exact occurrence.
editor.onRequestMediaControl = { [weak self] request in
guard let self, let component = self.component, let anchor = request.view else { return }
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
switch request.control {
case .more:
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(
text: request.isSpoiler ? presentationData.strings.Attachment_DisableSpoiler : presentationData.strings.Attachment_EnableSpoiler,
icon: { _ in nil },
iconAnimation: ContextMenuActionItem.IconAnimation(name: "anim_spoiler", loop: true),
action: { _, f in f(.default); request.toggleSpoiler() }
)))
items.append(.action(ContextMenuActionItem(
text: presentationData.strings.Common_Delete,
textColor: .destructive,
icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) },
action: { _, f in f(.default); request.delete() }
)))
presentMediaControlMenu(anchorView: anchor, items: items,
presentationData: presentationData) { [weak self] controller in
self?.environment?.controller()?.presentInGlobalOverlay(controller)
}
case .add:
guard let addMore = request.addMore else { break }
self.pickMedia(request: RichTextAttachmentScreen.MediaRequest(
imageOrVideo: RichTextAttachmentScreen.MediaRequest.ImageOrVideo(limit: 1),
music: false,
file: false,
location: false
)) { items in
guard let item = items.first, item.kind == .image || item.kind == .video else { return } // mosaic is photo/video only
addMore(item.mediaID, item.naturalSize, item.kind)
}
case .delete:
request.delete()
case .toggleLayout:
request.toggleLayout?()
}
}
editor.disablesInteractiveTransitionGestureRecognizer = true // navigation back-swipe (triggered by a horizontal knob drag)
editor.disablesInteractiveModalDismiss = true
editor.disablesInteractiveKeyboardGestureRecognizer = true
// Seed the editor with the caller-supplied initial content (e.g. the chat composer's
// current document when expanding); an empty document when none is provided.
var initialContents: Document?
var initialMedia: [String: Media] = [:]
var initialEmojiFiles: [Int64: TelegramMediaFile] = [:]
switch component.mode {
case let .edit(documentValue, mediaValue, emojiFilesValue):
initialContents = documentValue
initialMedia = mediaValue
initialEmojiFiles = emojiFilesValue
case let .standalone(documentValue, mediaValue, emojiFilesValue):
initialContents = documentValue
initialMedia = mediaValue
initialEmojiFiles = emojiFilesValue
}
editor.document = initialContents ?? Document()
// Seed the picked-media store alongside the document (before the media-view provider runs)
// so any media referenced by the initial document resolves on first layout.
self.attachedMedia = initialMedia
let emojiKeyboard = RichTextEmojiKeyboardController(context: component.context, editor: editor, requestLayout: { [weak self] in
guard let self, !self.isUpdating else { return }
self.componentState?.updated(transition: .spring(duration: 0.4))
})
self.emojiKeyboard = emojiKeyboard
// Seed the keyboard's file store with the files of any custom emoji the initial document
// references (the `Document` carries only fileIds) — before the editor's first layout, so a
// custom emoji carried in from the chat composer renders, and its file survives back out.
emojiKeyboard.seedEmojiFiles(initialEmojiFiles)
// The host owns "(language, text) -> colours": `asyncStanaloneSyntaxHighlight` runs libprisma off
// the main queue and returns the same cache model the message path stores, baking the LIGHT
// palette — so what the editor shows is what the sent message will show. The editor cannot do
// this itself; it cannot see TextFormat or libprisma.
editor.registerSyntaxHighlighter { language, text, completion in
let spec = CachedMessageSyntaxHighlight.Spec(language: language, text: text)
let _ = (asyncStanaloneSyntaxHighlight(current: nil, specs: [spec])
|> deliverOnMainQueue).start(next: { result in
let entities = result.values[spec]?.entities ?? []
completion(entities.map { entity in
RichTextSyntaxToken(
range: NSRange(location: entity.range.lowerBound,
length: entity.range.upperBound - entity.range.lowerBound),
color: UIColor(rgb: UInt32(bitPattern: entity.color)))
})
})
}
editor.registerEmojiViewProvider { [weak self] id, size in
return self?.emojiKeyboard?.customEmojiView(forId: id, size: size)
}
editor.registerFormulaRenderer { context in
guard let attachment = instantPageMathAttachment(
latex: context.latex,
fontSize: context.fontSize,
textColor: context.textColor,
mode: .inline
) else {
return nil
}
return RichTextFormulaRenderResult(
image: attachment.rendered.image,
size: attachment.rendered.size,
ascent: attachment.rendered.ascent,
descent: attachment.rendered.descent
)
}
// A pill's type icon is also its geometry — an inline pill grows to hold it — so this
// must be registered before the first reload, alongside the other providers.
editor.registerButtonIconProvider(richTextEditorButtonIcon)
editor.onEditFormulaRequested = { [weak self] latex, completion in
guard let self, let component = self.component else {
return
}
component.presentFormulaEditor?(latex, { [weak self] updatedLatex in
completion(updatedLatex)
DispatchQueue.main.async { [weak self] in
self?.editor.becomeFirstResponder()
}
})
}
// Tapping EITHER pill kind opens the property sheet. `completion(nil)` deletes the pill —
// and its row, when it was the last one.
editor.onEditButtonRequested = { [weak self] button, isBlockPill, completion in
guard let self, let component = self.component else {
return
}
let controller = ButtonEditorScreen(context: component.context, button: button,
isBlockPill: isBlockPill) { [weak self] updated in
completion(updated)
DispatchQueue.main.async { [weak self] in
self?.editor.becomeFirstResponder()
}
}
self.environment?.controller()?.present(controller, in: .window(.root))
}
// The row's "…" menu: Add Button / Alignment (submenu) / Delete Row. Alignment uses the
// project-standard `pushItems` submenu, exactly as the Add menu's Heading item does.
editor.onRequestButtonRowMenu = { [weak self] request in
guard let self, let environment = self.environment, let component = self.component else {
return
}
let strings = environment.strings
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: strings.RichText_ButtonRowAdd, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor)
}, action: { c, _ in
c?.dismiss(completion: nil)
request.addButton()
})))
items.append(.action(ContextMenuActionItem(text: strings.RichText_ButtonRowAlignment, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AlignVCenter"), color: theme.contextMenu.primaryColor)
}, action: { c, _ in
var subItems: [ContextMenuItem] = []
subItems.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Back, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.contextMenu.primaryColor)
}, iconPosition: .left, action: { c, _ in
c?.popItems()
})))
subItems.append(.separator)
let alignments: [(ButtonRowAlignment, String)] = [
(.justify, strings.RichText_ButtonRowAlignJustify),
(.left, strings.RichText_ButtonRowAlignLeft),
(.center, strings.RichText_ButtonRowAlignCenter),
(.right, strings.RichText_ButtonRowAlignRight),
]
for (alignment, title) in alignments {
subItems.append(.action(ContextMenuActionItem(text: title, icon: { _ in nil },
additionalLeftIcon: { theme in
return alignment == request.alignment
? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor)
: UIImage()
}, iconPosition: .left, action: { c, _ in
c?.dismiss(completion: nil)
request.setAlignment(alignment)
})))
}
c?.pushItems(items: .single(ContextController.Items(content: .list(subItems))))
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: strings.RichText_ButtonRowDelete, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { c, _ in
c?.dismiss(completion: nil)
request.deleteRow()
})))
// Anchor to the "…" rect, NOT to the editor view: `presentActionMenu` uses the whole
// source view as the reference, so passing `self.editor` positioned the menu against
// the entire editor and it landed offscreen. Same transient-anchor technique the
// table structural menu uses (`presentTableStructuralMenu`): a zero-interaction view
// at `request.sourceRect` inside the canvas, removed when the controller dismisses.
guard let anchorParent = request.view else {
return
}
guard let selfController = self.environment?.controller() else {
return
}
let anchor = UIView(frame: request.sourceRect)
anchor.isUserInteractionEnabled = false
anchorParent.addSubview(anchor)
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let controller = makeContextController(
presentationData: presentationData,
source: .reference(RichTextActionContextReferenceSource(sourceView: anchor, containerView: selfController.view, actionsPosition: .bottom)),
items: .single(ContextController.Items(content: .list(items))),
gesture: nil
)
controller.dismissed = { [weak anchor] in anchor?.removeFromSuperview() }
selfController.presentInGlobalOverlay(controller)
}
editor.registerMediaViewProvider { [weak self] items, _, displayMode, existing in
guard let self, let component = self.component else { return nil }
// Theme an audio row to the editor's accent/text scheme (same `list.item*` sources as
// `mapEditorTheme` / the table); ignored for image/map media.
let theme = component.context.sharedContext.currentPresentationData.with { $0 }.theme
let audioColors = InstantPageAudioColorOverride(
control: theme.list.itemAccentColor,
controlForeground: theme.list.itemCheckColors.foregroundColor,
title: theme.list.itemPrimaryTextColor,
description: theme.list.itemSecondaryTextColor
)
// Same `list.item*` sources as the audio row; ignored for image/map media.
let documentColors = InstantPageDocumentColorOverride(
control: theme.list.itemAccentColor,
controlForeground: theme.list.itemCheckColors.foregroundColor,
title: theme.list.itemPrimaryTextColor,
description: theme.list.itemSecondaryTextColor
)
let resolved: [(media: EngineMedia, naturalSize: CGSize, isSpoiler: Bool, kind: MediaKind)] = items.compactMap { item in
guard let media = self.attachedMedia[item.mediaID] else { return nil }
return (EngineMedia(media), item.naturalSize, item.isSpoiler, item.kind)
}
guard !resolved.isEmpty else { return nil }
// In-place update: reuse the existing container (surviving photo/video cells keep their bound
// fetch, no re-flash) across add-more / delete-one; else build a fresh one.
if let view = existing as? MediaItemNodeView {
view.updateResolvedItems(resolved, displayMode: displayMode)
return view
}
return MediaItemNodeView(context: component.context, items: resolved,
audioColorOverride: audioColors,
documentColorOverride: documentColors,
displayMode: displayMode)
}
// Host the checklist checkbox with a `CheckNode` themed from the standard app checkbox palette
// (`list.itemCheckColors`), mirroring `instantPageChecklistMarkerTheme`. Reads `appliedTheme`
// (the live `PresentationTheme`) lazily; nil before the first theme apply (harmless — the editor
// falls back to its glyph marker until a checkbox is provided).
editor.registerChecklistMarkerViewProvider { [weak self] checked, _ in
guard let self, let theme = self.appliedTheme else { return nil }
let c = theme.list.itemCheckColors
let nodeTheme = CheckNodeTheme(backgroundColor: c.fillColor, strokeColor: c.foregroundColor, borderColor: c.strokeColor, overlayBorder: false, hasInset: false, hasShadow: false)
return HostChecklistCheckboxView(theme: nodeTheme, checked: checked)
}
editor.onBecameFirstResponder = { [weak self] in
guard let self else {
return
}
if let controller = self.environment?.controller() as? RichTextAttachmentScreen {
controller.requestAttachmentMenuExpansion()
}
}
// The editor no longer drives its own layout/keyboard insets; it just tells us when anything
// changes so we re-run this update (which calls editor.update(size:insets:)). The isUpdating
// guard only defends the SYNCHRONOUS loop (update → editor.update → onChange → update);
// editor.update/performLayout don't fire onChange synchronously, so it can't loop. Async
// onChange (user edits/caret moves) skips the guard and correctly schedules a re-layout.
editor.onChange = { [weak self] in
guard let self, !self.isUpdating else { return }
self.componentState?.updated(transition: .spring(duration: 0.4))
self.reconcilePreupload()
}
self.addSubview(editor)
self.topEdgeEffectView.isUserInteractionEnabled = false
self.addSubview(self.topEdgeEffectView)
}
self.component = component
let isSendRichFormattingLocked = self.isSendRichFormattingLocked
if !self.didShowPremiumToast && isSendRichFormattingLocked {
self.didShowPremiumToast = true
DispatchQueue.main.async { [weak self] in
self?.presentPremiumToast()
}
}
if self.appliedTheme !== environment.theme {
self.appliedTheme = environment.theme
self.editor.theme = Self.mapEditorTheme(environment.theme)
}
let barButtonSize = CGSize(width: 44.0, height: 44.0)
let editorState = self.editor.currentState()
if self.navigationTapView.superview == nil {
component.overNavigationContainer.addSubview(self.navigationTapView)
}
transition.setFrame(view: self.navigationTapView, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: environment.navigationHeight)))
let leftNavActionsBarSize = self.leftNavActionsBar.update(
transition: transition,
component: AnyComponent(GlassControlGroupComponent(
theme: environment.theme,
preferClearGlass: false,
background: .panel,
items: [
GlassControlGroupComponent.Item(id: 0, content: .icon("Navigation/Close"), action: { [weak self] in
guard let self, let controller = self.environment?.controller() as? RichTextAttachmentScreen else {
return
}
controller.close()
})
], minWidth: 44.0)
),
environment: {},
containerSize: CGSize(width: availableSize.width, height: barButtonSize.height)
)
let leftNavActionsBarFrame = CGRect(origin: CGPoint(x: environment.safeInsets.left + 16.0, y: 16.0), size: leftNavActionsBarSize)
if let leftNavActionsBarView = self.leftNavActionsBar.view {
if leftNavActionsBarView.superview == nil {
component.overNavigationContainer.addSubview(leftNavActionsBarView)
}
transition.setFrame(view: leftNavActionsBarView, frame: leftNavActionsBarFrame)
}
let rightNavActionsBarSize = self.rightNavActionsBar.update(
transition: transition,
component: AnyComponent(GlassControlGroupComponent(
theme: environment.theme,
preferClearGlass: false,
background: .panel,
items: [
GlassControlGroupComponent.Item(id: 0, content: .icon("Media Editor/Undo"), action: editorState.canUndo ? { [weak self] in
self?.editor.undo()
} : nil),
GlassControlGroupComponent.Item(id: 1, content: .icon("Media Editor/Redo"), action: editorState.canRedo ? { [weak self] in
self?.editor.redo()
} : nil)
], minWidth: 44.0)
),
environment: {},
containerSize: CGSize(width: availableSize.width, height: barButtonSize.height)
)
let rightNavActionsBarFrame = CGRect(origin: CGPoint(x: availableSize.width - (environment.safeInsets.left + 16.0) - rightNavActionsBarSize.width, y: 16.0), size: rightNavActionsBarSize)
if let rightNavActionsBarView = self.rightNavActionsBar.view {
if rightNavActionsBarView.superview == nil {
component.overNavigationContainer.addSubview(rightNavActionsBarView)
}
transition.setFrame(view: rightNavActionsBarView, frame: rightNavActionsBarFrame)
}
if case .standalone = component.mode {
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(
MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.RichText_TitleArticle,
font: Font.semibold(17.0),
textColor: environment.theme.rootController.navigationBar.primaryTextColor
))
)
),
environment: {},
containerSize: CGSize(width: 200.0, height: 40.0)
)
// The title is centered, but must clear both the left button cluster (Close + undo + redo) and the
// Done button. When the natural center would overlap either side, re-center it in the gap between
// the cluster's trailing edge and Done (needed on narrow screens where the centered title would
// otherwise sit under the redo pill).
let leftClusterMaxX = leftNavActionsBarFrame.maxX
var titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: floorToScreenPixels((environment.navigationHeight - titleSize.height) / 2.0) + 3.0), size: titleSize)
if titleFrame.minX < leftClusterMaxX + 16.0 || titleFrame.maxX > rightNavActionsBarFrame.minX - 16.0 {
titleFrame.origin.x = leftClusterMaxX + floorToScreenPixels((rightNavActionsBarFrame.minX - leftClusterMaxX) - titleSize.width) / 2.0
}
if let titleView = self.title.view {
if titleView.superview == nil {
component.overNavigationContainer.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: titleFrame)
}
}
self.backgroundColor = environment.theme.actionSheet.opaqueItemBackgroundColor
let emojiPanelHeight = self.emojiKeyboard?.updatePanel(container: self, availableSize: availableSize, environment: environment, transition: transition) ?? 0.0
let editorTop = environment.navigationHeight
let editorFrame = CGRect(x: environment.safeInsets.left, y: 0.0,
width: availableSize.width - environment.safeInsets.left - environment.safeInsets.right,
height: availableSize.height)
self.editor.frame = editorFrame
var barActions: [RichTextActionBarComponent.Action] = []
let barActionsId: AnyHashable
if editorState.hasSelection {
barActionsId = 1
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("bold"), icon: "RichText/ToolBold",
action: { [weak self] _ in self?.editor.toggleBold() },
isSelected: editorState.bold
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("italic"), icon: "RichText/ToolItalic",
action: { [weak self] _ in self?.editor.toggleItalic() },
isSelected: editorState.italic
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("strike"), icon: "RichText/ToolStrike",
action: { [weak self] _ in self?.editor.toggleStrikethrough() },
isSelected: editorState.strikethrough
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("underline"), icon: "RichText/ToolUnderline",
action: { [weak self] _ in self?.editor.toggleUnderline() },
isSelected: editorState.underline
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("spoiler"), icon: "RichText/ToolSpoiler",
action: { [weak self] _ in self?.editor.toggleSpoiler() },
isSelected: editorState.spoiler
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("link"), icon: "RichText/ToolLink",
action: editorState.hasSelection ? { [weak self] _ in self?.presentLinkPrompt() } : nil,
isSelected: editorState.link != nil
))
// A list/quote/etc. marker can only apply to paragraph text, so offer it only when the selection
// covers no media or table block (see `EditorState.selectionIsTextOnly`).
if editorState.selectionIsTextOnly {
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("list"), icon: "RichText/ToolList",
action: { [weak self] sourceView in self?.presentListMenu(from: sourceView) },
isSelected: editorState.listMarker != nil,
showsPremiumBadge: !component.context.isPremium
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("quote"), icon: "RichText/ToolQuote",
action: { [weak self] sourceView in self?.presentAddMenu(from: sourceView) },
isSelected: editorState.listMarker != nil,
showsPremiumBadge: !component.context.isPremium
))
}
} else {
barActionsId = 0
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("add"), icon: "Chat/Context Menu/Add",
action: editorState.isInTable ? nil : { [weak self] sourceView in
self?.presentAddMenu(from: sourceView)
},
isSelected: false
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("list"), icon: "RichText/ToolList",
action: editorState.isInTable ? nil : { [weak self] sourceView in
self?.presentListMenu(from: sourceView)
},
isSelected: false,
showsPremiumBadge: !component.context.isPremium
))
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("table"), icon: "RichText/ToolTable",
action: { [weak self] sourceView in
guard let self, let environment = self.environment else { return }
var items: [ContextMenuItem] = []
if self.editor.currentState().isInTable {
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_Table_Copy, icon: { _ in nil }, action: { [weak self] _, f in
f(.default)
self?.editor.copyCurrentTable()
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_Table_ConvertToText, icon: { _ in nil }, action: { [weak self] _, f in
f(.default)
self?.editor.convertCurrentTableToText()
})))
// One state read for both items: `currentState()` walks the whole TableBlock.
let tableState = self.editor.currentState()
let tableIsCompact = tableState.isTableCompact
items.append(.action(ContextMenuActionItem(
text: tableIsCompact ? environment.strings.RichText_Menu_Table_CompactOff : environment.strings.RichText_Menu_Table_CompactOn,
icon: { _ in
return nil
},
action: { [weak self] _, f in
f(.default)
self?.editor.toggleTableCompact()
})))
let tableIsBordered = tableState.isTableBordered
items.append(.action(ContextMenuActionItem(
text: tableIsBordered ? environment.strings.RichText_Menu_Table_BordersOff : environment.strings.RichText_Menu_Table_BordersOn,
icon: { _ in
// No borders/grid asset exists in Images.xcassets; the sibling
// Compact item is likewise icon-less. A made-up bundleImageName
// would silently render nothing (UIImage returns nil).
return nil
},
action: { [weak self] _, f in
f(.default)
self?.editor.toggleTableBordered()
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.RichText_Menu_Table_Delete, textColor: .destructive, icon: { _ in nil }, action: { [weak self] _, f in
f(.default); self?.editor.deleteTable()
})))
} else {
// No caret: drop the table at the document end (become FR + caret to end), not at
// the default offset-0 start. With a caret it inserts at the caret as before.
self.focusEditorAtDocumentEndIfNeeded(hasCursor: viewTreeContainsFirstResponder(view: self.editor))
self.editor.insertTable(rows: 2, cols: 2)
}
self.presentActionMenu(from: sourceView, items: items)
},
isSelected: false,
showsPremiumBadge: !component.context.isPremium
))
if component.presentAttachmentMenu != nil {
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("attach"), icon: "RichText/ToolAttach",
action: editorState.isInTable ? nil : { [weak self] _ in self?.presentImagePicker() },
isSelected: false
))
}
barActions.append(RichTextActionBarComponent.Action(
id: AnyHashable("emoji"), icon: "RichText/ToolEmoji",
action: { [weak self] _ in self?.emojiKeyboard?.toggle() },
isSelected: self.emojiKeyboard?.isEmojiMode ?? false
))
}
let tabBarBottomInset = max(environment.inputHeight, emojiPanelHeight, environment.additionalInsets.bottom)
var sideInset: CGFloat = 12.0
if tabBarBottomInset <= 28.0 {
sideInset = 20.0
}
let actionBarSpacing: CGFloat = 6.0
let aiButtonSize = self.aiButton.update(
transition: transition,
component: AnyComponent(GlassControlGroupComponent(
theme: environment.theme,
preferClearGlass: false,
background: .panel,
items: [
GlassControlGroupComponent.Item(id: 0, content: .icon("Chat/Input/Text/InputAIIcon"), action: { [weak self] in
Task { @MainActor in
guard let self, let component = self.component, let environment = self.environment else {
return
}
guard let controller = environment.controller() as? RichTextAttachmentScreen else {
return
}
// AI edit on the current selection: seed the edit screen with only the
// selected sub-document (partial table/image coverage expanded to the whole
// block, both directions) and replace that same range with the result. The
// gate is CONTENT-based (`ChatInputContent.isEmpty`), not text-based, so a
// selection covering only an image / empty-caption still enters here.
if let sel = self.editor.selectedGlobalRange() {
let doc = self.editor.document
let (lo, hi) = doc.expandingRangeOverNonTextBlocks(globalFrom: sel.from, globalTo: sel.to)
let subDoc = doc.extractFragment(globalFrom: lo, globalTo: hi, carryingNonTextBlocks: true)
let subContent = chatInputContent(fromDocument: subDoc, media: self.currentMedia, emojiFiles: self.currentEmojiFiles)
if !subContent.isEmpty {
let initialText = ComposedRichMessage.rich(instantPage: instantPage(from: subContent))
let textProcessingScreen = await component.context.sharedContext.makeTextProcessingScreen(
context: component.context,
theme: environment.theme,
mode: .edit(
saveRestoreStateId: nil,
completion: { [weak self] result in
guard let self else {
return
}
let content: ChatInputContent
switch result {
case let .rich(instantPage):
content = chatInputContent(fromInstantPage: instantPage)
case let .plain(text, entities):
content = chatInputContent(from: chatInputStateStringWithAppliedEntities(text, entities: entities))
case .empty:
// An empty result deletes the (expanded) selection.
self.editor.replaceRange(from: lo, to: hi, with: Document(blocks: []))
return
}
let (document, media, emojiFiles) = documentMediaAndEmoji(fromChatInputContent: content)
self.emojiKeyboard?.seedEmojiFiles(emojiFiles)
self.attachedMedia.merge(media) { _, new in new }
self.editor.replaceRange(from: lo, to: hi, with: document)
},
send: nil,
sendContextActions: nil
),
inputText: initialText,
copyResult: nil,
translateChat: nil
)
if let parentController = controller.parentController() {
parentController.push(textProcessingScreen)
} else {
controller.push(textProcessingScreen)
}
return
}
}
// No usable selection → generate content and insert it at the caret.
do {
let textProcessingScreen = await component.context.sharedContext.makeTextProcessingScreen(
context: component.context,
theme: environment.theme,
mode: .generate(
completion: { [weak self] result in
guard let self else {
return
}
let content: ChatInputContent
switch result {
case let .rich(instantPage):
content = chatInputContent(fromInstantPage: instantPage)
case let .plain(text, entities):
content = chatInputContent(from: chatInputStateStringWithAppliedEntities(text, entities: entities))
case .empty:
return
}
let (document, media, emojiFiles) = documentMediaAndEmoji(fromChatInputContent: content)
self.emojiKeyboard?.seedEmojiFiles(emojiFiles)
self.attachedMedia.merge(media) { _, new in new }
self.editor.insertDocument(document)
}
),
inputText: .plain(text: "", entities: []),
copyResult: nil,
translateChat: nil
)
if let parentController = controller.parentController() {
parentController.push(textProcessingScreen)
} else {
controller.push(textProcessingScreen)
}
}
}
})
], minWidth: 44.0)
),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
var isSendEnabled = true
let content = chatInputContent(fromDocument: self.currentDocument, media: self.currentMedia, emojiFiles: self.currentEmojiFiles)
if content.isEmptyWhitespaceTrimmed {
isSendEnabled = false
}
let longPressSendAvailable = component.sendContextActions != nil && isSendEnabled && !isSendRichFormattingLocked
let sendButtonSize = self.sendButton.update(
transition: transition,
component: AnyComponent(RichTextSendButtonComponent(
theme: environment.theme,
isEnabled: isSendEnabled,
isLocked: isSendRichFormattingLocked,
action: { [weak self] in
guard let self, let controller = self.environment?.controller() as? RichTextAttachmentScreen else {
return
}
controller.donePressed()
},
longPressAction: longPressSendAvailable ? { [weak self] in
guard let self, let controller = self.environment?.controller() as? RichTextAttachmentScreen else {
return
}
controller.displayLongPressSendMenu(sourceSendButton: self.sendButtonExtractedContainer)
} : nil
)),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
let actionBarSize = self.actionBar.update(
transition: transition,
component: AnyComponent(RichTextActionBarComponent(
theme: environment.theme,
actionsId: barActionsId,
actions: barActions
)),
environment: {},
containerSize: CGSize(width: availableSize.width - (sideInset + environment.safeInsets.left) * 2.0 - actionBarSpacing * 2.0 - aiButtonSize.width - sendButtonSize.width, height: 44.0)
)
var bottomInset = max(environment.inputHeight, emojiPanelHeight, environment.safeInsets.bottom)
let aiButtonFrame = CGRect(origin: CGPoint(x: sideInset + environment.safeInsets.left, y: availableSize.height - bottomInset - 6.0 - aiButtonSize.height), size: aiButtonSize)
if let aiButtonView = self.aiButton.view {
if aiButtonView.superview == nil {
self.addSubview(aiButtonView)
}
transition.setFrame(view: aiButtonView, frame: aiButtonFrame)
}
let sendButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - (sideInset + environment.safeInsets.left) - sendButtonSize.width, y: availableSize.height - bottomInset - 6.0 - sendButtonSize.height), size: sendButtonSize)
if self.sendButtonExtractedContainer.superview == nil {
self.addSubview(self.sendButtonExtractedContainer)
}
if let sendButtonView = self.sendButton.view {
if sendButtonView.superview == nil {
self.sendButtonExtractedContainer.contentView.addSubview(sendButtonView)
}
sendButtonView.frame = CGRect(origin: CGPoint(), size: sendButtonFrame.size)
}
transition.setPosition(view: self.sendButtonExtractedContainer, position: sendButtonFrame.center)
transition.setBounds(view: self.sendButtonExtractedContainer, bounds: CGRect(origin: CGPoint(), size: sendButtonFrame.size))
transition.setPosition(view: self.sendButtonExtractedContainer.contentView, position: CGPoint(x: sendButtonFrame.width * 0.5, y: sendButtonFrame.height * 0.5))
transition.setBounds(view: self.sendButtonExtractedContainer.contentView, bounds: CGRect(origin: CGPoint(), size: sendButtonFrame.size))
let actionBarFrame = CGRect(origin: CGPoint(x: sideInset + environment.safeInsets.left + aiButtonSize.width + actionBarSpacing, y: availableSize.height - bottomInset - 6.0 - actionBarSize.height), size: actionBarSize)
if let actionBarView = self.actionBar.view {
if actionBarView.superview == nil {
self.addSubview(actionBarView)
}
transition.setFrame(view: actionBarView, frame: actionBarFrame)
}
bottomInset += 6.0 + actionBarSize.height + 6.0
// The editor no longer tracks the keyboard; supply the bottom obstruction as a scroll inset.
// Only one of (system keyboard / emoji panel) is up at a time (the emoji panel uses an
// EmptyInputView, so inputHeight ≈ 0 while it shows); the home-indicator safe area is the floor.
_ = self.editor.update(size: editorFrame.size,
insets: UIEdgeInsets(top: editorTop, left: 0.0, bottom: bottomInset + 16.0, right: 0.0),
contentMargins: UIEdgeInsets(top: 12.0, left: 0.0, bottom: 12.0, right: 0.0))
// Top edge effect (mirrors ComposePollScreen): a blurred gradient at the screen top that content
// fades under. Content color = the screen background (themed list.plainBackgroundColor).
let edgeEffectHeight: CGFloat = 88.0
let topEdgeEffectFrame = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: edgeEffectHeight))
transition.setFrame(view: self.topEdgeEffectView, frame: topEdgeEffectFrame)
self.topEdgeEffectView.update(content: environment.theme.actionSheet.opaqueItemBackgroundColor, blur: true, alpha: 1.0, rect: topEdgeEffectFrame, edge: .top, edgeSize: topEdgeEffectFrame.height, transition: transition)
// While the emoji panel is up, hide the AttachmentController's bottom menu/tab bar so the
// container collapses that panel and re-lays out this screen at full height — otherwise the
// emoji panel renders behind the attachment menu (mirrors ComposePollScreen).
let isTabBarVisible = !(self.emojiKeyboard?.isEmojiMode ?? false)
if self.lastTabBarVisible != isTabBarVisible {
self.lastTabBarVisible = isTabBarVisible
if let controller = environment.controller() as? RichTextAttachmentScreen {
let tabBarTransition = transition.containedViewLayoutTransition
DispatchQueue.main.async { [weak controller] in
controller?.updateTabBarVisibility(isTabBarVisible, tabBarTransition)
}
}
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@available(iOS 13.0, *)
private final class RichTextActionContextReferenceSource: ContextReferenceContentSource {
private let sourceView: UIView
private let containerView: UIView
private let actionsPosition: ContextControllerReferenceViewInfo.ActionsPosition
init(sourceView: UIView, containerView: UIView, actionsPosition: ContextControllerReferenceViewInfo.ActionsPosition = .top) {
self.sourceView = sourceView
self.containerView = containerView
self.actionsPosition = actionsPosition
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView,
contentAreaInScreenSpace: self.containerView.convert(self.containerView.bounds, to: nil),
insets: UIEdgeInsets(top: -4.0, left: 0.0, bottom: -4.0, right: 0.0),
actionsPosition: self.actionsPosition)
}
}
/// The resource carrying a medium's main bytes — the one whose data is worth moving when a local
/// medium is promoted to its cloud twin.
private func preuploadPrimaryResource(_ media: Media) -> MediaResource? {
if let image = media as? TelegramMediaImage {
return largestImageRepresentation(image.representations)?.resource
}
if let file = media as? TelegramMediaFile {
return file.resource
}
return nil
}