import Foundation import UIKit import SwiftSignalKit import Display import TelegramPresentationData import ComponentFlow import ComponentDisplayAdapters import AccountContext import ViewControllerComponent import MultilineTextComponent import BalancedTextComponent import ButtonComponent import BundleIconComponent import TelegramCore import PresentationDataUtils import ResizableSheetComponent import GlassBarButtonComponent import ListSectionComponent import AvatarComponent import ListMultilineTextFieldItemComponent import Markdown final class CreateBotContentComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment final class ExternalState { var name: String = "" var username: String = "" var usernameIsChecked: Bool = false init() { } } let externalState: ExternalState let context: AccountContext let parentPeer: EnginePeer let initialUsername: String? let initialTitle: String? init( externalState: ExternalState, context: AccountContext, parentPeer: EnginePeer, initialUsername: String?, initialTitle: String? ) { self.externalState = externalState self.context = context self.parentPeer = parentPeer self.initialUsername = initialUsername self.initialTitle = initialTitle } static func ==(lhs: CreateBotContentComponent, rhs: CreateBotContentComponent) -> Bool { return true } private enum UsernameCheckingStatus { case checking case valid case invalid case taken } final class View: UIView { private var component: CreateBotContentComponent? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private let avatar = ComponentView() private let title = ComponentView() private let subtitle = ComponentView() private let nameSection = ComponentView() private let usernameSection = ComponentView() private let usernameInputState = ListMultilineTextFieldItemComponent.ExternalState() private let usernameInputTag = ListMultilineTextFieldItemComponent.Tag() private let nameInputState = ListMultilineTextFieldItemComponent.ExternalState() private let nameInputTag = ListMultilineTextFieldItemComponent.Tag() private var usernameCheckingStatus: (username: String, status: UsernameCheckingStatus)? { didSet { guard let component = self.component else { return } component.externalState.usernameIsChecked = self.usernameCheckingStatus?.status == .valid } } private var usernameCheckingDisposable: Disposable? override init(frame: CGRect) { super.init(frame: frame) self.usernameInputState.updated = { [weak self] in guard let self, let component = self.component else { return } component.externalState.username = self.usernameInputState.text.string self.inputUsernameUpdated() if !self.isUpdating { self.state?.updated(transition: .immediate) } } self.nameInputState.updated = { [weak self] in guard let self, let component = self.component else { return } component.externalState.name = self.nameInputState.text.string } } required init?(coder: NSCoder) { preconditionFailure() } deinit { self.usernameCheckingDisposable?.dispose() } private func inputUsernameUpdated() { guard let component = self.component else { return } let username = self.usernameInputState.text.string.lowercased() + "bot" if let usernameCheckingStatus = self.usernameCheckingStatus, usernameCheckingStatus.username == username { return } self.usernameCheckingDisposable?.dispose() self.usernameCheckingDisposable = nil guard case .success = CreateBotSheetComponent.View.validatedUsername(inputUsername: username) else { self.usernameCheckingStatus = (username, .invalid) return } self.usernameCheckingStatus = (username, .checking) self.usernameCheckingDisposable = (component.context.engine.peers.addressNameAvailability(domain: .bot(component.parentPeer.id), name: username) |> deliverOnMainQueue).startStrict(next: { [weak self] result in guard let self else { return } switch result { case .available: self.usernameCheckingStatus = (username, .valid) case .invalid: self.usernameCheckingStatus = (username, .invalid) case .purchaseAvailable: self.usernameCheckingStatus = (username, .invalid) case .taken: self.usernameCheckingStatus = (username, .taken) } if !self.isUpdating { self.state?.updated(transition: .immediate) } }) } func update(component: CreateBotContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) let _ = alphaTransition let environment = environment[ViewControllerComponentContainer.Environment.self].value let isFirstTime = self.component == nil self.component = component self.state = state let sideInset: CGFloat = 16.0 var contentHeight: CGFloat = 0.0 contentHeight += 32.0 let avatarSize = self.avatar.update( transition: transition, component: AnyComponent(AvatarComponent( context: component.context, theme: environment.theme, peer: component.parentPeer )), environment: {}, containerSize: CGSize(width: 92.0, height: 92.0) ) let avatarFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - avatarSize.width) * 0.5), y: contentHeight), size: avatarSize) if let avatarView = self.avatar.view { if avatarView.superview == nil { self.addSubview(avatarView) } transition.setPosition(view: avatarView, position: avatarFrame.center) avatarView.bounds = CGRect(origin: CGPoint(), size: avatarFrame.size) } contentHeight += avatarSize.height contentHeight += 16.0 let titleSize = self.title.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .plain(NSAttributedString(string: "Create Bot", font: Font.bold(24.0), textColor: environment.theme.list.itemPrimaryTextColor)), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.12 )), environment: {}, containerSize: CGSize(width: min(280.0, availableSize.width - sideInset * 2.0), height: 1000.0) ) let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } transition.setPosition(view: titleView, position: titleFrame.center) titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) } contentHeight += titleSize.height contentHeight += 10.0 let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) let textColor = environment.theme.actionSheet.primaryTextColor let linkColor = environment.theme.actionSheet.controlAccentColor let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { contents in return ("URL", contents) }) let subtitleSize = self.subtitle.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .markdown(text: "[\(component.parentPeer.debugDisplayTitle)]() wound like to create and manage a chatbot on your behalf.", attributes: markdownAttributes), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.12 )), environment: {}, containerSize: CGSize(width: min(280.0, availableSize.width - sideInset * 2.0), height: 1000.0) ) let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize) if let subtitleView = self.subtitle.view { if subtitleView.superview == nil { self.addSubview(subtitleView) } transition.setPosition(view: subtitleView, position: subtitleFrame.center) subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) } contentHeight += subtitleSize.height contentHeight += 24.0 let nameSectionSize = self.nameSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, style: .glass, header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "BOT NAME", font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), footer: nil, items: [ AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: self.nameInputState, style: .glass, context: component.context, theme: environment.theme, strings: environment.strings, initialText: "", resetText: isFirstTime ? ListMultilineTextFieldItemComponent.ResetText(value: component.initialTitle ?? "") : nil, placeholder: "Name", autocapitalizationType: .words, autocorrectionType: .no, characterLimit: 64, rightAccessory: ListMultilineTextFieldItemComponent.RightAccessory(component: AnyComponentWithIdentity( id: 0, component: AnyComponent(EditLabelComponent( theme: environment.theme, strings: environment.strings, action: { [weak self] in guard let self, let itemView = self.nameSection.findTaggedView(tag: self.nameInputTag) as? ListMultilineTextFieldItemComponent.View else { return } itemView.activateInput() } ))), insets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 0.0) ), emptyLineHandling: .notAllowed, updated: { _ in }, textUpdateTransition: .immediate, tag: self.nameInputTag ))) ] )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let nameSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: nameSectionSize) self.nameSection.parentState = state if let nameSectionView = self.nameSection.view { if nameSectionView.superview == nil { self.addSubview(nameSectionView) } transition.setFrame(view: nameSectionView, frame: nameSectionFrame) } contentHeight += nameSectionSize.height + 22.0 var initialUsername = "" var botSuffix = "bot" if let value = component.initialUsername { if value.lowercased().hasSuffix("bot") { botSuffix = String(value[value.index(value.endIndex, offsetBy: -3)...]) initialUsername = String(value[value.startIndex ..< value.index(value.endIndex, offsetBy: -3)]) } else { initialUsername = value } } let usernameFooterString: NSAttributedString switch CreateBotSheetComponent.View.validatedUsername(inputUsername: "\(self.usernameInputState.text.string)" + botSuffix) { case let .success(value): switch self.usernameCheckingStatus?.status ?? .valid { case .checking: usernameFooterString = NSAttributedString( string: "Checking...", font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor ) case .invalid: let errorText = "You can only use **a-z**, **0-9** and underscores." usernameFooterString = parseMarkdownIntoAttributedString(errorText, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemDestructiveColor), bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.itemDestructiveColor), link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemDestructiveColor), linkAttribute: { contents in return ("URL", contents) })) case .taken: let errorText = "This username is already taken." usernameFooterString = parseMarkdownIntoAttributedString(errorText, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemDestructiveColor), bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.itemDestructiveColor), link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemDestructiveColor), linkAttribute: { contents in return ("URL", contents) })) case .valid: usernameFooterString = NSAttributedString( string: "Link: t.me/\(value)", font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor ) } case let .failure(error): let errorText: String switch error { case .insufficientLength: errorText = "A username must have at least 5 characters." case .startsWithNumber: errorText = "A username can't start with a number" case .unsupportedCharacters: errorText = "You can only use **a-z**, **0-9** and underscores." } usernameFooterString = parseMarkdownIntoAttributedString(errorText, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemDestructiveColor), bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.itemDestructiveColor), link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemDestructiveColor), linkAttribute: { contents in return ("URL", contents) })) } let usernameSectionSize = self.usernameSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, style: .glass, header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "BOT USERNAME", font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), footer: AnyComponent(MultilineTextComponent( text: .plain(usernameFooterString), maximumNumberOfLines: 0 )), items: [ AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: usernameInputState, style: .glass, context: component.context, theme: environment.theme, strings: environment.strings, initialText: "", resetText: isFirstTime ? ListMultilineTextFieldItemComponent.ResetText(value: initialUsername) : nil, placeholder: "", autocapitalizationType: .none, autocorrectionType: .no, keyboardType: .asciiCapable, characterLimit: 32, prefix: NSAttributedString(string: "@", font: Font.regular(17.0), textColor: environment.theme.list.itemPrimaryTextColor), suffix: NSAttributedString(string: botSuffix, font: Font.regular(17.0), textColor: environment.theme.list.itemSecondaryTextColor), rightAccessory: ListMultilineTextFieldItemComponent.RightAccessory(component: AnyComponentWithIdentity( id: 0, component: AnyComponent(EditLabelComponent( theme: environment.theme, strings: environment.strings, action: { [weak self] in guard let self, let itemView = self.nameSection.findTaggedView(tag: self.usernameInputTag) as? ListMultilineTextFieldItemComponent.View else { return } itemView.activateInput() } ))), insets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 0.0) ), emptyLineHandling: .notAllowed, updated: { _ in }, textUpdateTransition: .immediate, tag: self.usernameInputTag, ))) ] )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let usernameSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: usernameSectionSize) self.usernameSection.parentState = state if let usernameSectionView = self.usernameSection.view { if usernameSectionView.superview == nil { self.addSubview(usernameSectionView) } transition.setFrame(view: usernameSectionView, frame: usernameSectionFrame) } contentHeight += usernameSectionSize.height + 18.0 contentHeight += 106.0 contentHeight += environment.inputHeight component.externalState.name = self.nameInputState.text.string component.externalState.username = self.usernameInputState.text.string component.externalState.usernameIsChecked = self.usernameCheckingStatus?.status == .valid return CGSize(width: availableSize.width, height: contentHeight) } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class CreateBotSheetComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let parentPeer: EnginePeer let initialUsername: String? let initialTitle: String? let openAutomatically: Bool let completion: (EnginePeer.Id?) -> Void init( context: AccountContext, parentPeer: EnginePeer, initialUsername: String?, initialTitle: String?, openAutomatically: Bool, completion: @escaping (EnginePeer.Id?) -> Void ) { self.context = context self.parentPeer = parentPeer self.initialUsername = initialUsername self.initialTitle = initialTitle self.openAutomatically = openAutomatically self.completion = completion } static func ==(lhs: CreateBotSheetComponent, rhs: CreateBotSheetComponent) -> Bool { return true } final class View: UIView { private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, ResizableSheetComponentEnvironment)>() private let animateOut = ActionSlot>() private let contentExternalState = CreateBotContentComponent.ExternalState() private var component: CreateBotSheetComponent? private var environment: ViewControllerComponentContainer.Environment? private weak var state: EmptyComponentState? private var isCreating: Bool = false private var actionDisposable: Disposable? private var isCompleted: Bool = false override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.actionDisposable?.dispose() } func attemptNavigation(complete: @escaping () -> Void) -> Bool { guard let component = self.component else { return true } if self.isCreating { return false } //TODO:localize let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let _ = presentationData let alertController = textAlertController( context: component.context, title: "Unsaved Changes", text: "You have not finished creating a bot.", actions: [ TextAlertAction(type: .genericAction, title: "Cancel", action: { }), TextAlertAction(type: .destructiveAction, title: "Discard", action: { [weak self] in guard let self, let component = self.component else { return } if !self.isCompleted { self.isCompleted = true component.completion(nil) } let controller = self.environment?.controller self.animateOut.invoke(Action { _ in if let controller = controller?() { controller.dismiss(completion: nil) } }) }) ] ) self.environment?.controller()?.present(alertController, in: .window(.root)) return false } enum UsernameValidationError: Error { case insufficientLength case unsupportedCharacters case startsWithNumber } static func validatedUsername(inputUsername: String) -> Result { var isUsernameValid = true var usernameCharacters = CharacterSet(charactersIn: "a".unicodeScalars.first! ... "z".unicodeScalars.first!) usernameCharacters.insert(charactersIn: "A".unicodeScalars.first! ... "Z".unicodeScalars.first!) usernameCharacters.insert(charactersIn: "0".unicodeScalars.first! ... "9".unicodeScalars.first!) usernameCharacters.insert("_") for c in inputUsername.unicodeScalars { if !usernameCharacters.contains(c) { isUsernameValid = false break } } if !isUsernameValid { return .failure(.unsupportedCharacters) } if let first = inputUsername.unicodeScalars.first { if CharacterSet.decimalDigits.contains(first) { return .failure(.startsWithNumber) } } if inputUsername.count < 5 { return .failure(.insufficientLength) } return .success(inputUsername) } static func validatedParams(inputName: String, inputUsername: String) -> (name: String, username: String)? { if inputName.isEmpty { return nil } guard case let .success(username) = validatedUsername(inputUsername: inputUsername) else { return nil } return (inputName, username) } private func validatedParams() -> (name: String, username: String)? { if !self.contentExternalState.usernameIsChecked { return nil } return CreateBotSheetComponent.View.validatedParams(inputName: contentExternalState.name, inputUsername: self.contentExternalState.username) } private func performCreateBot() { guard let component = self.component else { return } if self.isCreating { return } guard let params = self.validatedParams() else { return } self.isCreating = true self.state?.updated(transition: .immediate) self.actionDisposable?.dispose() self.actionDisposable = (component.context.engine.peers.createBot( name: params.name, username: params.username + "bot", managerPeerId: component.parentPeer.id, viaDeeplink: true ) |> deliverOnMainQueue).startStrict(next: { [weak self] botPeer in guard let self, let component = self.component, let controller = self.environment?.controller(), let navigationController = controller.navigationController as? NavigationController else { return } let context = component.context self.isCompleted = true self.animateOut.invoke(Action { [weak controller, weak navigationController] _ in if let controller, let navigationController { controller.dismiss(completion: { [weak navigationController] in if component.openAutomatically, let navigationController { component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, context: context, chatLocation: .peer(botPeer) )) } component.completion(botPeer.id) }) } }) }, error: { [weak self] error in guard let self, let environment = self.environment, let component = self.component else { return } self.isCreating = false self.state?.updated(transition: .immediate) let text: String switch error { case .generic: text = environment.strings.Login_UnknownError case .occupied: text = "This username already exists." } //TODO:localize let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) self.environment?.controller()?.push(textAlertController( context: component.context, title: nil, text: text, actions: [ .init(type: .defaultAction, title: presentationData.strings.Common_OK, action: {}) ] )) }) } func update(component: CreateBotSheetComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state let environmentValue = environment[ViewControllerComponentContainer.Environment.self].value self.environment = environmentValue let controller = environmentValue.controller let theme = environmentValue.theme let dismiss: (Bool) -> Void = { [weak self] animated in guard let self, let component = self.component else { return } if !self.isCompleted { self.isCompleted = true component.completion(nil) } if animated { self.animateOut.invoke(Action { _ in if let controller = controller() { controller.dismiss(completion: nil) } }) } else { if let controller = controller() { controller.dismiss(completion: nil) } } } let performMainAction: () -> Void = { [weak self] in guard let self else { return } self.performCreateBot() } let sheetSize = self.sheet.update( transition: transition, component: AnyComponent(ResizableSheetComponent( content: AnyComponent(CreateBotContentComponent( externalState: self.contentExternalState, context: component.context, parentPeer: component.parentPeer, initialUsername: component.initialUsername, initialTitle: component.initialTitle )), titleItem: nil, leftItem: AnyComponent( GlassBarButtonComponent( size: CGSize(width: 44.0, height: 44.0), backgroundColor: nil, isDark: theme.overallDarkAppearance, state: .glass, component: AnyComponentWithIdentity(id: "close", component: AnyComponent( BundleIconComponent( name: "Navigation/Close", tintColor: theme.chat.inputPanel.panelControlColor ) )), action: { _ in dismiss(true) } ) ), hasTopEdgeEffect: false, bottomItem: AnyComponent( ActionButtonsComponent( theme: environmentValue.theme, strings: environmentValue.strings, isActionEnabled: !self.isCreating && self.validatedParams() != nil, cancelAction: { dismiss(true) }, action: { performMainAction() } ) ), backgroundColor: .color(theme.list.blocksBackgroundColor), animateOut: self.animateOut )), environment: { environmentValue ResizableSheetComponentEnvironment( theme: theme, statusBarHeight: environmentValue.statusBarHeight, safeInsets: environmentValue.safeInsets, inputHeight: environmentValue.inputHeight, metrics: environmentValue.metrics, deviceMetrics: environmentValue.deviceMetrics, isDisplaying: environmentValue.isVisible, isCentered: environmentValue.metrics.widthClass == .regular, screenSize: availableSize, regularMetricsSize: nil, dismiss: { [weak self] animated in guard let self else { return } if animated { if !self.attemptNavigation(complete: { dismiss(animated) }) { return } } dismiss(animated) } ) }, containerSize: availableSize ) self.sheet.parentState = state if let sheetView = self.sheet.view { if sheetView.superview == nil { self.addSubview(sheetView) } transition.setFrame(view: sheetView, frame: CGRect(origin: .zero, size: sheetSize)) } return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public class CreateBotScreen: ViewControllerComponentContainer { private let context: AccountContext public init?( context: AccountContext, parentBot: EnginePeer.Id, initialUsername: String?, initialTitle: String?, openAutomatically: Bool, completion: @escaping (EnginePeer.Id?) -> Void ) async { self.context = context guard let parentPeer = await context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: parentBot) ).get() else { return nil } super.init( context: context, component: CreateBotSheetComponent( context: context, parentPeer: parentPeer, initialUsername: initialUsername, initialTitle: initialTitle, openAutomatically: openAutomatically, completion: completion ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default ) self.statusBar.statusBarStyle = .Ignore self.navigationPresentation = .flatModal self.blocksBackgroundWhenInOverlay = true self.attemptNavigation = { [weak self] complete in guard let self, let componentView = self.node.hostView.componentView as? CreateBotSheetComponent.View else { return true } return componentView.attemptNavigation(complete: complete) } } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } public func dismissAnimated() { if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent.View.Tag()) as? ResizableSheetComponent.View { view.dismissAnimated() } } } private final class ActionButtonsComponent: Component { let theme: PresentationTheme let strings: PresentationStrings let isActionEnabled: Bool let cancelAction: () -> Void let action: () -> Void init( theme: PresentationTheme, strings: PresentationStrings, isActionEnabled: Bool, cancelAction: @escaping () -> Void, action: @escaping () -> Void ) { self.theme = theme self.strings = strings self.isActionEnabled = isActionEnabled self.cancelAction = cancelAction self.action = action } static func ==(lhs: ActionButtonsComponent, rhs: ActionButtonsComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } if lhs.isActionEnabled != rhs.isActionEnabled { return false } return true } final class View: UIView { private let cancelButton = ComponentView() private let actionButton = ComponentView() private var component: ActionButtonsComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ActionButtonsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state let spacing: CGFloat = 10.0 let buttonWidth = floor((availableSize.width - spacing) * 0.5) let cancelButtonSize = self.cancelButton.update( transition: transition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( style: .glass, color: component.theme.actionSheet.primaryTextColor.withMultipliedAlpha(0.1).blitOver(component.theme.list.blocksBackgroundColor, alpha: 1.0), foreground: component.theme.actionSheet.primaryTextColor, pressedColor: component.theme.actionSheet.primaryTextColor.withMultipliedAlpha(0.1).withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(ButtonTextContentComponent( text: component.strings.Common_Cancel, badge: 0, textColor: component.theme.actionSheet.primaryTextColor, badgeBackground: component.theme.list.itemCheckColors.foregroundColor, badgeForeground: component.theme.list.itemCheckColors.fillColor )) ), isEnabled: true, displaysProgress: false, action: { [weak self] in guard let self, let component = self.component else { return } component.cancelAction() } )), environment: {}, containerSize: CGSize(width: buttonWidth, height: availableSize.height) ) let actionButtonSize = self.actionButton.update( transition: transition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( style: .glass, color: component.theme.list.itemCheckColors.fillColor, foreground: component.theme.list.itemCheckColors.foregroundColor, pressedColor: component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(ButtonTextContentComponent( text: "Create", //TODO:localize badge: 0, textColor: component.theme.list.itemCheckColors.foregroundColor, badgeBackground: component.theme.list.itemCheckColors.foregroundColor, badgeForeground: component.theme.list.itemCheckColors.fillColor )) ), isEnabled: component.isActionEnabled, displaysProgress: false, action: { [weak self] in guard let self, let component = self.component else { return } component.action() } )), environment: {}, containerSize: CGSize(width: availableSize.width - spacing - buttonWidth, height: availableSize.height) ) let cancelButtonFrame = CGRect(origin: CGPoint(), size: cancelButtonSize) let actionButtonFrame = CGRect(origin: CGPoint(x: cancelButtonFrame.maxX + spacing, y: cancelButtonFrame.minY), size: actionButtonSize) if let cancelButtonView = self.cancelButton.view { if cancelButtonView.superview == nil { self.addSubview(cancelButtonView) } transition.setFrame(view: cancelButtonView, frame: cancelButtonFrame) } if let actionButtonView = self.actionButton.view { if actionButtonView.superview == nil { self.addSubview(actionButtonView) } transition.setFrame(view: actionButtonView, frame: actionButtonFrame) } return CGSize(width: availableSize.width, height: max(cancelButtonSize.height, actionButtonSize.height)) } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class EditLabelComponent: Component { let theme: PresentationTheme let strings: PresentationStrings let action: () -> Void init( theme: PresentationTheme, strings: PresentationStrings, action: @escaping () -> Void ) { self.theme = theme self.strings = strings self.action = action } static func ==(lhs: EditLabelComponent, rhs: EditLabelComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } return true } final class View: UIView { private let title = ComponentView() private let background = ComponentView() private var component: EditLabelComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { super.init(frame: frame) self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) { guard let component = self.component else { return } if case .ended = recognizer.state { component.action() } } func update(component: EditLabelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state let sideInset: CGFloat = 7.0 let verticalInset: CGFloat = 4.0 let rightInset: CGFloat = 16.0 //TODO:localize let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: "edit", font: Font.regular(11.0), textColor: component.theme.list.itemAccentColor)) )), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) let backgroundSize = CGSize(width: titleSize.width + sideInset * 2.0, height: titleSize.height + verticalInset * 2.0) let size = CGSize(width: backgroundSize.width + rightInset, height: backgroundSize.height) let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((size.height - backgroundSize.height) * 0.5)), size: backgroundSize) let titleFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + floorToScreenPixels((backgroundSize.width - titleSize.width) * 0.5), y: backgroundFrame.minY + floorToScreenPixels((backgroundSize.height - titleSize.height) * 0.5) - UIScreenPixel), size: titleSize) let _ = self.background.update( transition: transition, component: AnyComponent(FilledRoundedRectangleComponent( color: component.theme.list.itemAccentColor.withMultipliedAlpha(0.1), cornerRadius: .minEdge, smoothCorners: false )), environment: {}, containerSize: backgroundFrame.size ) if let backgroundView = self.background.view { if backgroundView.superview == nil { backgroundView.isUserInteractionEnabled = false self.addSubview(backgroundView) } transition.setFrame(view: backgroundView, frame: backgroundFrame) } if let titleView = self.title.view { if titleView.superview == nil { titleView.isUserInteractionEnabled = false self.addSubview(titleView) } transition.setPosition(view: titleView, position: titleFrame.center) titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) } return size } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }