import Foundation import UIKit import SwiftSignalKit import Display import TelegramPresentationData import ComponentFlow import ComponentDisplayAdapters import AccountContext import ViewControllerComponent import MultilineTextComponent import ButtonComponent import BundleIconComponent import TelegramCore import PresentationDataUtils import ResizableSheetComponent import GlassBarButtonComponent import LottieComponent import ListSectionComponent import Markdown import TelegramUIPreferences import ListMultilineTextFieldItemComponent import CheckComponent import PlainButtonComponent import EntityKeyboard import EmojiStatusComponent final class TextStyleEditContentComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment final class ExternalState { let titleInputState = ListMultilineTextFieldItemComponent.ExternalState() let textInputState = ListMultilineTextFieldItemComponent.ExternalState() var isLinkToProfileEnabled: Bool = false var emojiFile: TelegramMediaFile? } let externalState: ExternalState let context: AccountContext let mode: TextStyleEditScreen.Mode init( externalState: ExternalState, context: AccountContext, mode: TextStyleEditScreen.Mode ) { self.externalState = externalState self.context = context self.mode = mode } static func ==(lhs: TextStyleEditContentComponent, rhs: TextStyleEditContentComponent) -> Bool { return true } private enum Mode { case translate case stylize case fix } final class View: UIView { private var component: TextStyleEditContentComponent? private var environment: ViewControllerComponentContainer.Environment? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private let iconBackground = ComponentView() private let emptyIcon = ComponentView() private var emojiIcon: ComponentView? private let titleSection = ComponentView() private let textSection = ComponentView() private let linkOption = ComponentView() override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { preconditionFailure() } func update(component: TextStyleEditContentComponent, 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 environment = environment[ViewControllerComponentContainer.Environment.self].value var resetTitle: String? var resetText: String? if self.component == nil { resetTitle = "" if case let .edit(style) = component.mode, case let .custom(style) = style.content { resetTitle = style.title resetText = style.prompt ?? "" } } self.component = component self.environment = environment self.state = state let sideInset: CGFloat = 16.0 let sectionSpacing: CGFloat = 24.0 let iconSpacing: CGFloat = 24.0 var contentHeight: CGFloat = 0.0 contentHeight += 70.0 let iconBackgroundSize = CGSize(width: 100.0, height: 100.0) let _ = self.iconBackground.update( transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(FilledRoundedRectangleComponent( color: environment.theme.list.itemBlocksBackgroundColor, cornerRadius: .minEdge, smoothCorners: false )), action: { [weak self] in guard let self, let component = self.component else { return } guard let iconBackgroundView = self.iconBackground.view else { return } self.environment?.controller()?.present(component.context.sharedContext.makeEmojiStatusSelectionController( context: component.context, mode: .backgroundSelection(completion: { [weak self] file in guard let self, let component = self.component else { return } component.externalState.emojiFile = file self.state?.updated(transition: .immediate) DispatchQueue.main.async { [weak self] in guard let self else { return } self.state?.updated(transition: .immediate) } }), sourceView: iconBackgroundView, emojiContent: EmojiPagerContentComponent.emojiInputData( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, isStandalone: false, subject: .emoji, hasTrending: false, topReactionItems: [], areUnicodeEmojiEnabled: false, areCustomEmojiEnabled: true, chatPeerId: component.context.account.peerId, selectedItems: Set() ) |> map { $0 }, currentSelection: nil, color: nil, destinationItemView: { [weak self] in guard let self else { return nil } return self.emojiIcon?.view } ), in: .window(.root)) }, animateAlpha: false, animateScale: false, )), environment: {}, containerSize: iconBackgroundSize ) let iconBackgroundFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconBackgroundSize.width) * 0.5), y: contentHeight), size: iconBackgroundSize) if let iconBackgroundView = self.iconBackground.view { if iconBackgroundView.superview == nil { self.addSubview(iconBackgroundView) } transition.setFrame(view: iconBackgroundView, frame: iconBackgroundFrame) } let emptyIconSize = self.emptyIcon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( name: "TextProcessing/EditEmojiPlaceholder", tintColor: environment.theme.list.controlSecondaryColor )), environment: {}, containerSize: iconBackgroundFrame.size ) let emptyIconFrame = emptyIconSize.centered(in: iconBackgroundFrame) if let emptyIconView = self.emptyIcon.view { if emptyIconView.superview == nil { self.addSubview(emptyIconView) emptyIconView.isUserInteractionEnabled = false } transition.setFrame(view: emptyIconView, frame: emptyIconFrame) transition.setAlpha(view: emptyIconView, alpha: component.externalState.emojiFile == nil ? 1.0 : 0.0) } if let emojiFile = component.externalState.emojiFile { var emojiIconTransition = transition let emojiIcon: ComponentView if let current = self.emojiIcon { emojiIcon = current } else { emojiIconTransition = emojiIconTransition.withAnimation(.none) emojiIcon = ComponentView() self.emojiIcon = emojiIcon } let emojiSize = CGSize(width: 70.0, height: 70.0) let emojiIconSize = emojiIcon.update( transition: emojiIconTransition, component: AnyComponent(EmojiStatusComponent( context: component.context, animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, content: .animation( content: .file(file: emojiFile), size: emojiSize, placeholderColor: environment.theme.list.mediaPlaceholderColor, themeColor: environment.theme.list.itemPrimaryTextColor, loopMode: .count(1) ), isVisibleForAnimations: true, action: nil )), environment: {}, containerSize: emojiSize ) let emojiIconFrame = emojiIconSize.centered(in: iconBackgroundFrame) if let emojiIconView = emojiIcon.view { if emojiIconView.superview == nil { self.addSubview(emojiIconView) emojiIconView.isUserInteractionEnabled = false } emojiIconTransition.setFrame(view: emojiIconView, frame: emojiIconFrame) } } else { if let emojiIcon = self.emojiIcon { self.emojiIcon = nil if let emojiIconView = emojiIcon.view { transition.setAlpha(view: emojiIconView, alpha: 0.0, completion: { [weak emojiIconView] _ in emojiIconView?.removeFromSuperview() }) transition.setScale(view: emojiIconView, scale: 0.001) } } } contentHeight += iconBackgroundSize.height + iconSpacing //TODO:localize var titleSectionItems: [AnyComponentWithIdentity] = [] titleSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: component.externalState.titleInputState, style: .glass, context: component.context, theme: environment.theme, strings: environment.strings, initialText: "", resetText: resetTitle.flatMap { resetTitle in return ListMultilineTextFieldItemComponent.ResetText(value: resetTitle) }, placeholder: "Style Name (for example, \"Pirate\")", autocapitalizationType: .none, autocorrectionType: .no, characterLimit: 256, emptyLineHandling: .notAllowed, updated: nil, textUpdateTransition: .spring(duration: 0.4), tag: nil )))) let titleSectionSize = self.titleSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, style: .glass, header: nil, footer: nil, items: titleSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let titleSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: titleSectionSize) if let titleSectionView = self.titleSection.view { if titleSectionView.superview == nil { self.addSubview(titleSectionView) self.titleSection.parentState = state } transition.setFrame(view: titleSectionView, frame: titleSectionFrame) } contentHeight += titleSectionSize.height contentHeight += sectionSpacing //TODO:localize var textSectionItems: [AnyComponentWithIdentity] = [] textSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: component.externalState.textInputState, style: .glass, context: component.context, theme: environment.theme, strings: environment.strings, initialText: "", resetText: resetText.flatMap { resetText in return ListMultilineTextFieldItemComponent.ResetText(value: resetText) }, placeholder: "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure, the sea, and rum\")", placeholderDefinesMinHeight: true, autocapitalizationType: .none, autocorrectionType: .no, characterLimit: 4096, emptyLineHandling: .allowed, updated: nil, textUpdateTransition: .spring(duration: 0.4), tag: nil )))) let textSectionSize = self.textSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, style: .glass, header: nil, footer: nil, items: textSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let textSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: textSectionSize) if let textSectionView = self.textSection.view { if textSectionView.superview == nil { self.addSubview(textSectionView) self.textSection.parentState = state } transition.setFrame(view: textSectionView, frame: textSectionFrame) } contentHeight += textSectionSize.height contentHeight += 23.0 let checkTheme = CheckComponent.Theme( backgroundColor: environment.theme.list.itemCheckColors.fillColor, strokeColor: environment.theme.list.itemCheckColors.foregroundColor, borderColor: environment.theme.list.itemCheckColors.strokeColor, overlayBorder: false, hasInset: false, hasShadow: false ) let linkOptionSize = self.linkOption.update( transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(HStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(CheckComponent( theme: checkTheme, size: CGSize(width: 18.0, height: 18.0), selected: component.externalState.isLinkToProfileEnabled ))), AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: "Add a link to my account", font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor)) ))) ], spacing: 10.0)), effectAlignment: .center, action: { [weak self] in guard let self else { return } component.externalState.isLinkToProfileEnabled = !component.externalState.isLinkToProfileEnabled if !self.isUpdating { self.state?.updated(transition: .spring(duration: 0.4)) } }, animateAlpha: false, animateScale: false )), environment: { }, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) ) let linkOptionFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - linkOptionSize.width) * 0.5), y: contentHeight), size: linkOptionSize) if let linkOptionView = self.linkOption.view { if linkOptionView.superview == nil { self.addSubview(linkOptionView) } transition.setFrame(view: linkOptionView, frame: linkOptionFrame) } contentHeight += linkOptionSize.height contentHeight += 104.0 let _ = alphaTransition 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 TextStyleEditSheetComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let mode: TextStyleEditScreen.Mode let initialEmojiFile: TelegramMediaFile? let completion: (TelegramComposeAIMessageMode.CloudStyle) -> Void init( context: AccountContext, mode: TextStyleEditScreen.Mode, initialEmojiFile: TelegramMediaFile?, completion: @escaping (TelegramComposeAIMessageMode.CloudStyle) -> Void ) { self.context = context self.mode = mode self.initialEmojiFile = initialEmojiFile self.completion = completion } static func ==(lhs: TextStyleEditSheetComponent, rhs: TextStyleEditSheetComponent) -> Bool { return true } final class View: UIView { private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, ResizableSheetComponentEnvironment)>() private let animateOut = ActionSlot>() private var component: TextStyleEditSheetComponent? private var environment: ViewControllerComponentContainer.Environment? private weak var state: EmptyComponentState? private let contentState = TextStyleEditContentComponent.ExternalState() private var createDisposable: Disposable? override init(frame: CGRect) { super.init(frame: frame) self.contentState.titleInputState.updated = { [weak self] in DispatchQueue.main.async { guard let self else { return } self.state?.updated(transition: .spring(duration: 0.4)) } } self.contentState.textInputState.updated = self.contentState.titleInputState.updated } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.createDisposable?.dispose() } private func performCreateStyle() { guard let component = self.component else { return } if self.contentState.titleInputState.text.string.isEmpty || self.contentState.textInputState.text.string.isEmpty { return } guard let emojiFile = self.contentState.emojiFile else { return } self.createDisposable?.dispose() switch component.mode { case .create: self.createDisposable = (component.context.engine.messages.createAITextStyle( displayAuthor: self.contentState.isLinkToProfileEnabled, emojiFileId: emojiFile.fileId.id, title: self.contentState.titleInputState.text.string, prompt: self.contentState.textInputState.text.string ) |> deliverOnMainQueue).startStrict(next: { [weak self] result in guard let self, let component = self.component, let environment = self.environment else { return } let controller = environment.controller self.animateOut.invoke(Action { _ in if let controller = controller() { controller.dismiss(completion: nil) } }) component.completion(result) }, error: { [weak self] error in guard let self else { return } let _ = self }) case let .edit(style): guard case let .custom(style) = style.content else { return } self.createDisposable = (component.context.engine.messages.editAITextStyle( id: style.id, accessHash: style.accessHash, displayAuthor: self.contentState.isLinkToProfileEnabled, emojiFileId: emojiFile.fileId.id, title: self.contentState.titleInputState.text.string, prompt: self.contentState.textInputState.text.string ) |> deliverOnMainQueue).startStrict(next: { [weak self] result in guard let self, let component = self.component, let environment = self.environment else { return } let controller = environment.controller self.animateOut.invoke(Action { _ in if let controller = controller() { controller.dismiss(completion: nil) } }) component.completion(result) }, error: { [weak self] error in guard let self else { return } let _ = self }) } } func update(component: TextStyleEditSheetComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { if self.component == nil { self.contentState.emojiFile = component.initialEmojiFile } 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.performCreateStyle() } let isMainActionEnabled = self.contentState.titleInputState.hasText && self.contentState.textInputState.hasText && self.contentState.emojiFile != nil let actionButtonTitle: String let titleString: String switch component.mode { case .create: titleString = "New Style" actionButtonTitle = "Create" case .edit: titleString = "Edit Style" actionButtonTitle = "Save" } let sheetSize = self.sheet.update( transition: transition, component: AnyComponent(ResizableSheetComponent( content: AnyComponent(TextStyleEditContentComponent( externalState: self.contentState, context: component.context, mode: component.mode )), titleItem: AnyComponent(TitleComponent( theme: theme, title: titleString )), 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) } ) ), rightItem: nil, bottomItem: AnyComponent( ActionButtonsComponent( theme: theme, strings: environmentValue.strings, actionTitle: actionButtonTitle, action: isMainActionEnabled ? performMainAction : nil ) ), backgroundColor: .color(theme.list.blocksBackgroundColor), animateOut: self.animateOut )), environment: { environmentValue ResizableSheetComponentEnvironment( theme: theme, statusBarHeight: environmentValue.statusBarHeight, safeInsets: environmentValue.safeInsets, inputHeight: 0.0, 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 TextStyleEditScreen: ViewControllerComponentContainer { public enum Mode { case create case edit(TelegramComposeAIMessageMode.CloudStyle) } private let context: AccountContext public init( context: AccountContext, theme: PresentationTheme? = nil, mode: Mode, completion: @escaping (TelegramComposeAIMessageMode.CloudStyle) -> Void ) async { self.context = context var initialEmojiFile: TelegramMediaFile? if case let .edit(style) = mode, case let .custom(style) = style.content, let emojiFileId = style.emojiFileId { initialEmojiFile = await context.engine.stickers.resolveInlineStickers(fileIds: [emojiFileId]).get()[emojiFileId] } super.init( context: context, component: TextStyleEditSheetComponent( context: context, mode: mode, initialEmojiFile: initialEmojiFile, completion: completion ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: theme.flatMap({ .custom($0) }) ?? .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 TitleComponent: Component { let theme: PresentationTheme let title: String init( theme: PresentationTheme, title: String ) { self.theme = theme self.title = title } static func ==(lhs: TitleComponent, rhs: TitleComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.title != rhs.title { return false } return true } final class View: UIView { private let title = ComponentView() private var component: TitleComponent? 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: TitleComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component self.state = state let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 100.0) ) let titleFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { titleView.isUserInteractionEnabled = false self.addSubview(titleView) } titleView.frame = titleFrame } return titleSize } } 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 ActionButtonsComponent: Component { let theme: PresentationTheme let strings: PresentationStrings let actionTitle: String let action: (() -> Void)? init( theme: PresentationTheme, strings: PresentationStrings, actionTitle: String, action: (() -> Void)? ) { self.theme = theme self.strings = strings self.actionTitle = actionTitle 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.actionTitle != rhs.actionTitle { return false } if (lhs.action == nil) != (rhs.action == nil) { return false } return true } final class View: UIView { 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 actionButtonWidth: CGFloat = availableSize.width var actionButtonContents: [AnyComponentWithIdentity] = [] actionButtonContents.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.actionTitle, font: Font.semibold(17.0), textColor: component.theme.list.itemCheckColors.foregroundColor)) )))) 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(HStack( actionButtonContents, spacing: 6.0 )) ), isEnabled: component.action != nil, displaysProgress: false, action: { [weak self] in guard let self, let component = self.component else { return } component.action?() } )), environment: {}, containerSize: CGSize(width: actionButtonWidth, height: availableSize.height) ) let actionButtonFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: actionButtonSize) 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: 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) } }