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 = "" 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 } 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 nameInputState = ListMultilineTextFieldItemComponent.ExternalState() 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.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() } 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))), insets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 0.0)), emptyLineHandling: .notAllowed, updated: { _ in }, textUpdateTransition: .immediate ))) ] )), 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 = "" if let value = component.initialUsername { if value.hasSuffix("bot") { initialUsername = String(value[value.startIndex ..< value.index(value.endIndex, offsetBy: -3)]) } else { initialUsername = value } } 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: nil, 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: "bot", 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))), insets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 0.0)), emptyLineHandling: .notAllowed, updated: { _ in }, textUpdateTransition: .immediate ))) ] )), 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 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? init( context: AccountContext, parentPeer: EnginePeer, initialUsername: String?, initialTitle: String? ) { self.context = context self.parentPeer = parentPeer self.initialUsername = initialUsername self.initialTitle = initialTitle } 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? override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.actionDisposable?.dispose() } private func validatedParams() -> (name: String, username: String)? { if self.contentExternalState.name.isEmpty { return nil } if self.contentExternalState.username.isEmpty { return nil } 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 self.contentExternalState.username.unicodeScalars { if !usernameCharacters.contains(c) { isUsernameValid = false break } } if !isUsernameValid { return nil } return (self.contentExternalState.name, 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.animateOut.invoke(Action { [weak controller, weak navigationController] _ in if let controller, let navigationController { controller.dismiss(completion: { [weak navigationController] in guard let navigationController else { return } component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, context: context, chatLocation: .peer(botPeer) )) }) } }) }, 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 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: { animated in 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? ) 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 ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default ) self.statusBar.statusBarStyle = .Ignore self.navigationPresentation = .flatModal self.blocksBackgroundWhenInOverlay = true } 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 init( theme: PresentationTheme, strings: PresentationStrings ) { self.theme = theme self.strings = strings } 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) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } 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 { self.addSubview(backgroundView) } transition.setFrame(view: backgroundView, frame: backgroundFrame) } 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) } 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) } }