From 84bc40e1f88651bb6c500f25cf4080913159e9ca Mon Sep 17 00:00:00 2001 From: Mikhail Filimonov Date: Tue, 24 Mar 2026 09:26:16 +0100 Subject: [PATCH 1/4] polls --- .../Sources/SyncCore/SyncCore_TelegramMediaPoll.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift index 2b80fd950d..99912bcd06 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift @@ -238,6 +238,11 @@ public final class TelegramMediaPoll: Media, Equatable { peerIds.append(contentsOf: voter.recentVoters) } } + for option in options { + if let addedBy = option.addedBy { + peerIds.append(addedBy) + } + } return peerIds } From a0a2f9f6bf07b0e58f446ffa88da36160f95b62e Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 24 Mar 2026 16:47:36 +0800 Subject: [PATCH 2/4] Various improvements --- submodules/CheckNode/Sources/CheckNode.swift | 1 + .../TelegramEngine/Peers/AddressNames.swift | 8 + .../Sources/ChatTextInputPanelNode.swift | 22 +- .../Sources/CreateBotScreen.swift | 291 ++++++++++++++++-- .../ListMultilineTextFieldItemComponent.swift | 5 + .../Sources/PeerInfoScreenAvatarSetup.swift | 149 ++------- .../Sources/TextProcessingScreen.swift | 8 +- .../Sources/ChatControllerNode.swift | 4 +- .../ChatReportPeerTitlePanelNode.swift | 1 + .../WebUI/Sources/WebAppController.swift | 4 +- 10 files changed, 348 insertions(+), 145 deletions(-) diff --git a/submodules/CheckNode/Sources/CheckNode.swift b/submodules/CheckNode/Sources/CheckNode.swift index beeefe26d2..dc296618e4 100644 --- a/submodules/CheckNode/Sources/CheckNode.swift +++ b/submodules/CheckNode/Sources/CheckNode.swift @@ -319,6 +319,7 @@ public class CheckLayer: CALayer { super.init() self.isOpaque = false + self.rasterizationScale = UIScreenScale } public override init(layer: Any) { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/AddressNames.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/AddressNames.swift index c506d58e4b..97fd807dd8 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/AddressNames.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/AddressNames.swift @@ -69,6 +69,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma |> `catch` { error -> Signal in if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { return .single(.purchaseAvailable) + } else if error.errorDescription == "USERNAME_OCCUPIED" { + return .single(.taken) } else { return .single(.invalid) } @@ -87,6 +89,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma |> `catch` { error -> Signal in if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { return .single(.purchaseAvailable) + } else if error.errorDescription == "USERNAME_OCCUPIED" { + return .single(.taken) } else { return .single(.invalid) } @@ -104,6 +108,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma |> `catch` { error -> Signal in if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { return .single(.purchaseAvailable) + } else if error.errorDescription == "USERNAME_OCCUPIED" { + return .single(.taken) } else { return .single(.invalid) } @@ -124,6 +130,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma |> `catch` { error -> Signal in if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { return .single(.purchaseAvailable) + } else if error.errorDescription == "USERNAME_OCCUPIED" { + return .single(.taken) } else { return .single(.invalid) } diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 5121a6e96a..ed68788e9b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -247,6 +247,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg private let counterTextNode: ImmediateTextNode private var aiButton: (button: HighlightTrackingButton, icon: UIImageView)? + private var heightDependentAiButtonAlpha: CGFloat = 0.0 public let menuButton: HighlightTrackingButtonNode private let menuButtonBackgroundView: GlassBackgroundView @@ -3558,7 +3559,15 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg transition.updateFrame(view: aiButton.button, frame: aiButtonFrame) transition.updateFrame(view: aiButton.icon, frame: image.size.centered(in: aiButtonFrame)) } - let aiButtonAlpha: CGFloat = actualTextFieldFrame.height >= 70.0 ? 1.0 : 0.0 + var aiButtonAlpha: CGFloat = actualTextFieldFrame.height >= 70.0 ? 1.0 : 0.0 + self.heightDependentAiButtonAlpha = aiButtonAlpha + var inputHasText = false + if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, attributedText.length != 0 { + inputHasText = true + } + if !inputHasText { + aiButtonAlpha = 0.0 + } transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha) transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha) } else if let aiButton = self.aiButton { @@ -3571,6 +3580,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg transition.updateAlpha(layer: aiButton.icon.layer, alpha: 0.0, completion: { [weak aiButtonIconView] _ in aiButtonIconView?.removeFromSuperview() }) + self.heightDependentAiButtonAlpha = 0.0 } let containerFrame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: contentHeight + 64.0)) @@ -4385,6 +4395,16 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } } + if let aiButton = self.aiButton { + let transition: ContainedViewLayoutTransition = .immediate + var aiButtonAlpha: CGFloat = self.heightDependentAiButtonAlpha + if !inputHasText { + aiButtonAlpha = 0.0 + } + transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha) + transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha) + } + self.updateTextHeight(animated: animated) } diff --git a/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift b/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift index b7b5416af8..b3f5be4b45 100644 --- a/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift +++ b/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift @@ -26,6 +26,7 @@ final class CreateBotContentComponent: Component { final class ExternalState { var name: String = "" var username: String = "" + var usernameIsChecked: Bool = false init() { } @@ -54,6 +55,13 @@ final class CreateBotContentComponent: Component { 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? @@ -67,7 +75,19 @@ final class CreateBotContentComponent: Component { 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) @@ -77,6 +97,11 @@ final class CreateBotContentComponent: Component { 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 { @@ -89,6 +114,47 @@ final class CreateBotContentComponent: Component { 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 @@ -210,10 +276,24 @@ final class CreateBotContentComponent: Component { 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)), + 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 + textUpdateTransition: .immediate, + tag: self.nameInputTag ))) ] )), @@ -231,14 +311,58 @@ final class CreateBotContentComponent: Component { contentHeight += nameSectionSize.height + 22.0 var initialUsername = "" + var botSuffix = "bot" if let value = component.initialUsername { - if value.hasSuffix("bot") { + 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( @@ -252,7 +376,10 @@ final class CreateBotContentComponent: Component { )), maximumNumberOfLines: 0 )), - footer: nil, + footer: AnyComponent(MultilineTextComponent( + text: .plain(usernameFooterString), + maximumNumberOfLines: 0 + )), items: [ AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: usernameInputState, @@ -268,11 +395,25 @@ final class CreateBotContentComponent: Component { 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)), + 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 + textUpdateTransition: .immediate, + tag: self.usernameInputTag, ))) ] )), @@ -294,6 +435,7 @@ final class CreateBotContentComponent: Component { 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) } @@ -316,7 +458,7 @@ private final class CreateBotSheetComponent: Component { let initialUsername: String? let initialTitle: String? let openAutomatically: Bool - let completion: (EnginePeer.Id) -> Void + let completion: (EnginePeer.Id?) -> Void init( context: AccountContext, @@ -324,7 +466,7 @@ private final class CreateBotSheetComponent: Component { initialUsername: String?, initialTitle: String?, openAutomatically: Bool, - completion: @escaping (EnginePeer.Id) -> Void + completion: @escaping (EnginePeer.Id?) -> Void ) { self.context = context self.parentPeer = parentPeer @@ -349,6 +491,7 @@ private final class CreateBotSheetComponent: Component { private var isCreating: Bool = false private var actionDisposable: Disposable? + private var isCompleted: Bool = false override init(frame: CGRect) { super.init(frame: frame) @@ -362,28 +505,94 @@ private final class CreateBotSheetComponent: Component { self.actionDisposable?.dispose() } - private func validatedParams() -> (name: String, username: String)? { - if self.contentExternalState.name.isEmpty { - return nil + func attemptNavigation(complete: @escaping () -> Void) -> Bool { + guard let component = self.component else { + return true } - if self.contentExternalState.username.isEmpty { - return nil + 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 self.contentExternalState.username.unicodeScalars { + 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 } - return (self.contentExternalState.name, self.contentExternalState.username) + 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() { @@ -413,6 +622,7 @@ private final class CreateBotSheetComponent: Component { 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 @@ -466,8 +676,15 @@ private final class CreateBotSheetComponent: Component { 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 + self.animateOut.invoke(Action { _ in if let controller = controller() { controller.dismiss(completion: nil) } @@ -544,7 +761,17 @@ private final class CreateBotSheetComponent: Component { isCentered: environmentValue.metrics.widthClass == .regular, screenSize: availableSize, regularMetricsSize: nil, - dismiss: { animated in + dismiss: { [weak self] animated in + guard let self else { + return + } + if animated { + if !self.attemptNavigation(complete: { + dismiss(animated) + }) { + return + } + } dismiss(animated) } ) @@ -581,7 +808,7 @@ public class CreateBotScreen: ViewControllerComponentContainer { initialUsername: String?, initialTitle: String?, openAutomatically: Bool, - completion: @escaping (EnginePeer.Id) -> Void + completion: @escaping (EnginePeer.Id?) -> Void ) async { self.context = context @@ -609,6 +836,14 @@ public class CreateBotScreen: ViewControllerComponentContainer { 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) { @@ -777,13 +1012,16 @@ private final class ActionButtonsComponent: Component { private final class EditLabelComponent: Component { let theme: PresentationTheme let strings: PresentationStrings + let action: () -> Void init( theme: PresentationTheme, - strings: PresentationStrings + strings: PresentationStrings, + action: @escaping () -> Void ) { self.theme = theme self.strings = strings + self.action = action } static func ==(lhs: EditLabelComponent, rhs: EditLabelComponent) -> Bool { @@ -805,12 +1043,23 @@ private final class EditLabelComponent: Component { 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 @@ -846,6 +1095,7 @@ private final class EditLabelComponent: Component { ) if let backgroundView = self.background.view { if backgroundView.superview == nil { + backgroundView.isUserInteractionEnabled = false self.addSubview(backgroundView) } transition.setFrame(view: backgroundView, frame: backgroundFrame) @@ -853,6 +1103,7 @@ private final class EditLabelComponent: Component { if let titleView = self.title.view { if titleView.superview == nil { + titleView.isUserInteractionEnabled = false self.addSubview(titleView) } transition.setPosition(view: titleView, position: titleFrame.center) diff --git a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift index 0db06f25dc..8be6a995f9 100644 --- a/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift +++ b/submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent/Sources/ListMultilineTextFieldItemComponent.swift @@ -17,6 +17,11 @@ public final class ListMultilineTextFieldItemComponent: Component { case legacy } + public final class Tag { + public init() { + } + } + public final class ExternalState { public fileprivate(set) var hasText: Bool = false public fileprivate(set) var text: NSAttributedString = NSAttributedString() diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift index cf9c45e67f..0c83494846 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift @@ -126,9 +126,11 @@ public extension PeerInfoScreenImpl { commit() } controller.videoCompletion = { [weak parentController] image, url, values, markup, commit in + guard let parentController else { + return + } resultImage = image - let _ = parentController - updateProfileVideo(context: context, image: image, video: nil, values: nil, markup: markup, mode: mode, uploadStatus: uploadStatusPromise) + updateProfileVideo(parentController: parentController, context: context, peer: peer, image: image, video: nil, values: nil, markup: markup, mode: mode, uploadStatus: uploadStatusPromise) commit() } parentController.push(controller) @@ -193,7 +195,7 @@ public extension PeerInfoScreenImpl { case let .video(video, coverImage, values, _, _): if let coverImage { resultImage = coverImage - updateProfileVideo(context: context, image: coverImage, video: video, values: values, markup: nil, mode: mode, uploadStatus: uploadStatusPromise) + updateProfileVideo(parentController: parentController, context: context, peer: peer, image: coverImage, video: video, values: values, markup: nil, mode: mode, uploadStatus: uploadStatusPromise) } commit({}) default: @@ -283,52 +285,24 @@ public extension PeerInfoScreenImpl { if case .complete = result { dismissStatus?() - - /*let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peer.id)) - |> deliverOnMainQueue).startStandalone(next: { [weak self] peer in - if let strongSelf = self, let peer { - switch mode { - case .fallback: - (strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: nil, text: strongSelf.presentationData.strings.Privacy_ProfilePhoto_PublicPhotoSuccess, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) - case .custom: - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, title: nil, text: strongSelf.presentationData.strings.UserInfo_SetCustomPhoto_SuccessPhotoText(peer.compactDisplayTitle).string, action: nil, duration: 5), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) - - let _ = (strongSelf.context.peerChannelMemberCategoriesContextsManager.profilePhotos(postbox: strongSelf.context.account.postbox, network: strongSelf.context.account.network, peerId: strongSelf.peerId, fetch: peerInfoProfilePhotos(context: strongSelf.context, peerId: strongSelf.peerId)) |> ignoreValues).startStandalone() - case .suggest: - if let navigationController = (strongSelf.navigationController as? NavigationController) { - strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .default, completion: { _ in - })) - } - case .accept: - (strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: strongSelf.presentationData.strings.Conversation_SuggestedPhotoSuccess, text: strongSelf.presentationData.strings.Conversation_SuggestedPhotoSuccessText, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] action in - if case .info = action { - self?.parentController?.openSettings(edit: false) - } - return false - }), in: .current) - default: - break - } - } - })*/ } })) } - static func updateProfileVideo(context: AccountContext, image: UIImage, video: Any?, values: Any?, markup: UploadPeerPhotoMarkup?) { - updateProfileVideo(context: context, image: image, video: video as? MediaEditorScreenImpl.MediaResult.VideoResult, values: values as? MediaEditorValues, markup: markup, mode: .generic, uploadStatus: nil) + static func updateProfileVideo(parentController: ViewController, context: AccountContext, peer: EnginePeer, image: UIImage, video: Any?, values: Any?, markup: UploadPeerPhotoMarkup?) { + updateProfileVideo(parentController: parentController, context: context, peer: peer, image: image, video: video as? MediaEditorScreenImpl.MediaResult.VideoResult, values: values as? MediaEditorValues, markup: markup, mode: .generic, uploadStatus: nil) } - static func updateProfileVideo(context: AccountContext, image: UIImage, video: MediaEditorScreenImpl.MediaResult.VideoResult?, values: MediaEditorValues?, markup: UploadPeerPhotoMarkup?, mode: PeerInfoAvatarEditingMode, uploadStatus: Promise?) { - /*var uploadVideo = true + static func updateProfileVideo(parentController: ViewController, context: AccountContext, peer: EnginePeer, image: UIImage, video: MediaEditorScreenImpl.MediaResult.VideoResult?, values: MediaEditorValues?, markup: UploadPeerPhotoMarkup?, mode: PeerInfoAvatarEditingMode, uploadStatus: Promise?) { + var uploadVideo = true if let _ = markup { - if let data = self.context.currentAppConfiguration.with({ $0 }).data, let uploadVideoValue = data["upload_markup_video"] as? Bool, uploadVideoValue { + if let data = context.currentAppConfiguration.with({ $0 }).data, let uploadVideoValue = data["upload_markup_video"] as? Bool, uploadVideoValue { uploadVideo = true } else { uploadVideo = false } } - guard let photoResource = self.setupProfilePhotoUpload(image: image, mode: mode, indefiniteProgress: !uploadVideo) else { + guard let photoResource = sharedSetupProfilePhotoUpload(context: context, image: image, mode: mode) else { uploadStatus?.set(.single(.done)) return } @@ -338,8 +312,8 @@ public extension PeerInfoScreenImpl { videoStartTimestamp = coverImageTimestamp - (values.videoTrimRange?.lowerBound ?? 0.0) } - let account = self.context.account - let context = self.context + let account = context.account + let context = context let videoResource: Signal if uploadVideo, let video, let values { @@ -388,10 +362,7 @@ public extension PeerInfoScreenImpl { let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4") let videoExport = MediaEditorVideoExport(postbox: context.account.postbox, subject: exportSubject, configuration: configuration, outputPath: tempFile.path, textScale: 2.0) let _ = (videoExport.status - |> deliverOnMainQueue).startStandalone(next: { [weak self] status in - guard let self else { - return - } + |> deliverOnMainQueue).startStandalone(next: { status in switch status { case .completed: if let data = try? Data(contentsOf: URL(fileURLWithPath: tempFile.path), options: .mappedIfSafe) { @@ -401,11 +372,8 @@ public extension PeerInfoScreenImpl { subscriber.putCompletion() } EngineTempBox.shared.dispose(tempFile) - case let .progress(progress): - Queue.mainQueue().async { - self.controllerNode.state = self.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(progress * 0.45))) - self.requestLayout(transition: .immediate) - } + case .progress: + break default: break } @@ -422,100 +390,41 @@ public extension PeerInfoScreenImpl { } var dismissStatus: (() -> Void)? - if [.suggest, .fallback, .accept].contains(mode) { - let statusController = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: { [weak self] in - self?.controllerNode.updateAvatarDisposable.set(nil) + if "".isEmpty { + let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { dismissStatus?() })) dismissStatus = { [weak statusController] in statusController?.dismiss() } - if let topController = self.navigationController?.topViewController as? ViewController { - topController.presentInGlobalOverlay(statusController) - } else if let topController = self.parentController?.topViewController as? ViewController { + if let topController = parentController.navigationController?.topViewController as? ViewController { topController.presentInGlobalOverlay(statusController) } else { - self.presentInGlobalOverlay(statusController) + parentController.presentInGlobalOverlay(statusController) } } - let peerId = self.peerId - let isSettings = self.isSettings - let isMyProfile = self.isMyProfile - self.controllerNode.updateAvatarDisposable.set((videoResource + let peerId = peer.id + let updateAvatarDisposable = MetaDisposable() + updateAvatarDisposable.set((videoResource |> mapToSignal { videoResource -> Signal in - if isSettings || isMyProfile { - if case .fallback = mode { - return context.engine.accountData.updateFallbackPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) - }) - } else { - return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) - }) - } - } else if case .custom = mode { - return context.engine.contacts.updateContactPhoto(peerId: peerId, resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mode: .custom, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) - }) - } else if case .suggest = mode { - return context.engine.contacts.updateContactPhoto(peerId: peerId, resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: markup, mode: .suggest, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) - }) - } else { - return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: videoResource.flatMap { context.engine.peers.uploadedPeerVideo(resource: $0) |> map(Optional.init) }, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) - }) - } + return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: videoResource.flatMap { context.engine.peers.uploadedPeerVideo(resource: $0) |> map(Optional.init) }, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) + }) } - |> deliverOnMainQueue).startStrict(next: { [weak self] result in - guard let strongSelf = self else { - return - } + |> deliverOnMainQueue).startStandalone(next: { result in switch result { case .complete: uploadStatus?.set(.single(.done)) - strongSelf.controllerNode.state = strongSelf.controllerNode.state.withUpdatingAvatar(nil).withAvatarUploadProgress(nil) case let .progress(value): uploadStatus?.set(.single(.progress(value))) - strongSelf.controllerNode.state = strongSelf.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(0.45 + value * 0.55))) - } - if let (layout, navigationHeight) = strongSelf.controllerNode.validLayout { - strongSelf.controllerNode.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate, additive: false) } if case .complete = result { dismissStatus?() - - let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.peerId)) - |> deliverOnMainQueue).startStandalone(next: { [weak self] peer in - if let strongSelf = self, let peer { - switch mode { - case .fallback: - (strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: nil, text: strongSelf.presentationData.strings.Privacy_ProfilePhoto_PublicVideoSuccess, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) - case .custom: - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, title: nil, text: strongSelf.presentationData.strings.UserInfo_SetCustomPhoto_SuccessVideoText(peer.compactDisplayTitle).string, action: nil, duration: 5), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) - - let _ = (strongSelf.context.peerChannelMemberCategoriesContextsManager.profilePhotos(postbox: strongSelf.context.account.postbox, network: strongSelf.context.account.network, peerId: strongSelf.peerId, fetch: peerInfoProfilePhotos(context: strongSelf.context, peerId: strongSelf.peerId)) |> ignoreValues).startStandalone() - case .suggest: - if let navigationController = (strongSelf.navigationController as? NavigationController) { - strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .default, completion: { _ in - })) - } - case .accept: - (strongSelf.parentController?.topViewController as? ViewController)?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .image(image: image, title: strongSelf.presentationData.strings.Conversation_SuggestedVideoSuccess, text: strongSelf.presentationData.strings.Conversation_SuggestedVideoSuccessText, round: true, undoText: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] action in - if case .info = action { - self?.parentController?.openSettings(edit: false) - } - return false - }), in: .current) - default: - break - } - } - }) } - }))*/ + })) } } diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift index 7014c3e746..67050a9997 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift @@ -99,7 +99,7 @@ final class TextProcessingContentComponent: Component { private var currentContent: (mode: Mode, view: ComponentView)? - private var currentMode: Mode = .stylize + private var currentMode: Mode = .translate override init(frame: CGRect) { self.currentContentBackground = UIImageView() @@ -204,6 +204,12 @@ final class TextProcessingContentComponent: Component { if self.component == nil { self.stylizeState.displayStyleTooltip = component.shouldDisplayStyleNotice + switch component.mode { + case .edit: + self.currentMode = .stylize + case .translate: + self.currentMode = .translate + } } self.component = component diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index baacfa7135..c470f280c7 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -4463,7 +4463,9 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } let effectiveInputText: NSAttributedString + var isEdit = false if effectivePresentationInterfaceState.interfaceState.editMessage != nil { + isEdit = true effectiveInputText = expandedInputStateAttributedString(effectivePresentationInterfaceState.interfaceState.effectiveInputState.inputText) } else { effectiveInputText = expandedInputStateAttributedString(effectivePresentationInterfaceState.interfaceState.composeInputState.inputText) @@ -4516,7 +4518,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { }) self.sendCurrentMessage() }, - sendContextActions: self.chatLocation.peerId.flatMap { peerId in return TextProcessingScreen.SendContextActions( + sendContextActions: isEdit ? nil : self.chatLocation.peerId.flatMap { peerId in return TextProcessingScreen.SendContextActions( peerId: peerId, send: { [weak self] text, mode, parameters in guard let self, let controller = self.controller else { diff --git a/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift b/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift index 526b3c37ab..0ac7851856 100644 --- a/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatReportPeerTitlePanelNode.swift @@ -506,6 +506,7 @@ final class ChatReportPeerTitlePanelNode: ChatTitleAccessoryPanelNode { }) view.setImage(image?.withRenderingMode(.alwaysOriginal), for: []) + view.setImage(generateTintedImage(image: image, color: interfaceState.theme.rootController.navigationBar.accentTextColor.withAlphaComponent(0.7))?.withRenderingMode(.alwaysOriginal), for: [.highlighted]) } } } diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index ececce6801..f31a46a97c 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -2350,8 +2350,8 @@ public final class WebAppController: ViewController, AttachmentContainable { } } ) - if let createBotScreen { - controller.push(createBotScreen) + if let createBotScreen, let navigationController = controller.getNavigationController() { + navigationController.pushViewController(createBotScreen) } } default: From 93bf4724724da3bff0f199fc0a4b70bb6f59ccbd Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 24 Mar 2026 22:51:44 +0800 Subject: [PATCH 3/4] Text selection --- .../MultilineTextWithEntitiesComponent.swift | 2 +- .../Sources/ResizableSheetComponent.swift | 2 +- .../Source/ContextMenuController.swift | 16 ++- .../ChatItemGalleryFooterContentNode.swift | 6 +- ...hatMessageFactCheckBubbleContentNode.swift | 6 +- .../ChatMessageInteractiveFileNode.swift | 6 +- .../ChatMessageTextBubbleContentNode.swift | 6 +- .../Sources/ChatTextInputPanelNode.swift | 12 +- .../Sources/ContextActionsContainerNode.swift | 6 +- .../Sources/ContextMenuController.swift | 6 +- .../Sources/CreateBotScreen.swift | 2 +- .../StoryContentCaptionComponent.swift | 6 +- .../Components/TextProcessingScreen/BUILD | 3 + .../Sources/TextProcessingScreen.swift | 26 +++- .../TextProcessingTextAreaComponent.swift | 122 +++++++++++++++++- ...tProcessingTranslateContentComponent.swift | 18 ++- .../Sources/TextSelectionNode.swift | 81 +++++++++--- 17 files changed, 266 insertions(+), 60 deletions(-) diff --git a/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift b/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift index 2519f2f6eb..55633a2b99 100644 --- a/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift +++ b/submodules/Components/MultilineTextWithEntitiesComponent/Sources/MultilineTextWithEntitiesComponent.swift @@ -190,7 +190,7 @@ public final class MultilineTextWithEntitiesComponent: Component { public final class View: UIView { var spoilerTextNode: ImmediateTextNodeWithEntities? - let textNode: ImmediateTextNodeWithEntities + public let textNode: ImmediateTextNodeWithEntities public override init(frame: CGRect) { self.textNode = ImmediateTextNodeWithEntities() diff --git a/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift b/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift index ff8fe0d47a..5b1e58f2dd 100644 --- a/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift +++ b/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift @@ -466,7 +466,7 @@ public final class ResizableSheetComponent (ASDisplayNode, CGRect, ASDisplayNode, CGRect)? + public let sourceViewAndRect: () -> (UIView, CGRect, UIView, CGRect)? public let bounce: Bool - public init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?, bounce: Bool = true) { - self.sourceNodeAndRect = sourceNodeAndRect + public init(sourceViewAndRect: @escaping () -> (UIView, CGRect, UIView, CGRect)?, bounce: Bool = true) { + self.sourceViewAndRect = sourceViewAndRect self.bounce = bounce } + + public convenience init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?, bounce: Bool = true) { + self.init(sourceViewAndRect: { + if let (view1, rect1, view2, rect2) = sourceNodeAndRect() { + return (view1.view, rect1, view2.view, rect2) + } else { + return nil + } + }, bounce: bounce) + } } public protocol ContextMenuController: ViewController, StandalonePresentableController { diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index a673be983b..a24b6f9b7d 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -459,18 +459,18 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll ) self.textNode.visibility = true - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: defaultDarkPresentationTheme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: defaultDarkPresentationTheme.list.itemAccentColor, isDark: true), strings: presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: defaultDarkPresentationTheme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: defaultDarkPresentationTheme.list.itemAccentColor, isDark: true), strings: presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in guard let self else { return } let _ = self }, present: { c, a in present(c, a) - }, rootNode: { [weak self] in + }, rootView: { [weak self] in guard let self else { return nil } - return self.controllerInteraction?.controller()?.displayNode + return self.controllerInteraction?.controller()?.displayNode.view }, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in guard let self else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift index 294d430e00..33a6223ac6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift @@ -156,12 +156,12 @@ public class ChatMessageFactCheckBubbleContentNode: ChatMessageBubbleContentNode let selectionColor: UIColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionColor let knobColor: UIColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionKnobColor - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in self?.updateIsTextSelectionActive?(value) }, present: { [weak self] c, a in self?.item?.controllerInteraction.presentGlobalOverlayController(c, a) - }, rootNode: { [weak rootNode] in - return rootNode + }, rootView: { [weak rootNode] in + return rootNode?.view }, performAction: { [weak self] text, action in guard let strongSelf = self, let item = strongSelf.item else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift index 63ec5d3875..cbff3c4238 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift @@ -2049,12 +2049,12 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor } - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode), updateIsActive: { [weak self] value in self?.updateIsTextSelectionActive?(value) }, present: { [weak self] c, a in self?.arguments?.controllerInteraction.presentGlobalOverlayController(c, a) - }, rootNode: { [weak rootNode] in - return rootNode + }, rootView: { [weak rootNode] in + return rootNode?.view }, performAction: { [weak self] text, action in guard let strongSelf = self, let item = strongSelf.arguments else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index a6b8ad6925..931ccd6ec2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -1622,7 +1622,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor } - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNodeOrView: .node(self.textNode.textNode), updateIsActive: { [weak self] value in self?.updateIsTextSelectionActive?(value) }, present: { [weak self] c, a in guard let self, let item = self.item else { @@ -1634,8 +1634,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } else { item.controllerInteraction.presentGlobalOverlayController(c, a) } - }, rootNode: { [weak rootNode] in - return rootNode + }, rootView: { [weak rootNode] in + return rootNode?.view }, performAction: { [weak self] text, action in guard let strongSelf = self, let item = strongSelf.item else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index ed68788e9b..d31dedf2e7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -3562,14 +3562,14 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg var aiButtonAlpha: CGFloat = actualTextFieldFrame.height >= 70.0 ? 1.0 : 0.0 self.heightDependentAiButtonAlpha = aiButtonAlpha var inputHasText = false - if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, attributedText.length != 0 { + if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { inputHasText = true } if !inputHasText { aiButtonAlpha = 0.0 } - transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha) - transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha) } else if let aiButton = self.aiButton { self.aiButton = nil let aiButtonView = aiButton.button @@ -4398,11 +4398,11 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg if let aiButton = self.aiButton { let transition: ContainedViewLayoutTransition = .immediate var aiButtonAlpha: CGFloat = self.heightDependentAiButtonAlpha - if !inputHasText { + if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { aiButtonAlpha = 0.0 } - transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha) - transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha) } self.updateTextHeight(animated: animated) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift index 077af06fa7..0ef201b886 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift @@ -438,10 +438,10 @@ final class InnerTextSelectionTipContainerNode: ASDisplayNode { super.init() - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { _ in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNodeOrView: .node(self.textNode.textNode), updateIsActive: { _ in }, present: { _, _ in - }, rootNode: { [weak self] in - return self + }, rootView: { [weak self] in + return self?.view }, performAction: { _, _ in }) self.textSelectionNode = textSelectionNode diff --git a/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift b/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift index 70ffcd512f..ee0f69bba6 100644 --- a/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift +++ b/submodules/TelegramUI/Components/ContextMenuScreen/Sources/ContextMenuController.swift @@ -85,13 +85,13 @@ public final class ContextMenuControllerImpl: ViewController, KeyShortcutRespond } else { self.layout = layout - if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceNode, sourceRect, containerNode, containerRect) = presentationArguments.sourceNodeAndRect() { + if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceView, sourceRect, containerView, containerRect) = presentationArguments.sourceViewAndRect() { if self.skipCoordnateConversion { self.contextMenuNode.sourceRect = sourceRect self.contextMenuNode.containerRect = containerRect } else { - self.contextMenuNode.sourceRect = sourceNode.view.convert(sourceRect, to: nil) - self.contextMenuNode.containerRect = containerNode.view.convert(containerRect, to: nil) + self.contextMenuNode.sourceRect = sourceView.convert(sourceRect, to: nil) + self.contextMenuNode.containerRect = containerView.convert(containerRect, to: nil) } } else { self.contextMenuNode.sourceRect = nil diff --git a/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift b/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift index b3f5be4b45..fda1953b09 100644 --- a/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift +++ b/submodules/TelegramUI/Components/CreateBotScreen/Sources/CreateBotScreen.swift @@ -402,7 +402,7 @@ final class CreateBotContentComponent: Component { 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 { + guard let self, let itemView = self.usernameSection.findTaggedView(tag: self.usernameInputTag) as? ListMultilineTextFieldItemComponent.View else { return } itemView.activateInput() diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift index 2707b92685..d16cafd51c 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentCaptionComponent.swift @@ -896,7 +896,7 @@ final class StoryContentCaptionComponent: Component { self.textSelectionKnobContainer.addSubview(textSelectionKnobSurface) } - let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: component.theme.list.itemAccentColor, isDark: true), strings: component.strings, textNode: textNode, updateIsActive: { [weak self] value in + let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: component.theme.list.itemAccentColor, isDark: true), strings: component.strings, textNodeOrView: .node(textNode), updateIsActive: { [weak self] value in guard let self else { return } @@ -912,8 +912,8 @@ final class StoryContentCaptionComponent: Component { return } component.controller()?.presentInGlobalOverlay(c, with: a) - }, rootNode: { [weak controller] in - return controller?.displayNode + }, rootView: { [weak controller] in + return controller?.displayNode.view }, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in guard let self, let component = self.component else { return diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/BUILD b/submodules/TelegramUI/Components/TextProcessingScreen/BUILD index ff2ce1a75b..f9f67f5d42 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/BUILD +++ b/submodules/TelegramUI/Components/TextProcessingScreen/BUILD @@ -41,6 +41,9 @@ swift_library( "//submodules/Markdown", "//submodules/TelegramUIPreferences", "//submodules/ChatSendMessageActionUI", + "//submodules/TextSelectionNode", + "//submodules/Pasteboard", + "//submodules/Speak", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift index 67050a9997..c501ec2c0c 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift @@ -84,6 +84,7 @@ final class TextProcessingContentComponent: Component { final class View: UIView { private var component: TextProcessingContentComponent? + private var environment: ViewControllerComponentContainer.Environment? private weak var state: EmptyComponentState? private var isUpdating: Bool = false @@ -213,6 +214,7 @@ final class TextProcessingContentComponent: Component { } self.component = component + self.environment = environment self.state = state let sideInset: CGFloat = 16.0 @@ -351,7 +353,13 @@ final class TextProcessingContentComponent: Component { inputText: component.inputText, mode: .translate, copyAction: component.copyCurrentResult, - displayLanguageSelectionMenu: component.displayLanguageSelectionMenu + displayLanguageSelectionMenu: component.displayLanguageSelectionMenu, + present: { [weak self] c, a in + self?.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + rootViewForTextSelection: { [weak self] in + return self?.environment?.controller()?.view + } )) case .stylize: contentComponent = AnyComponent(TextProcessingTranslateContentComponent( @@ -363,7 +371,13 @@ final class TextProcessingContentComponent: Component { inputText: component.inputText, mode: .stylize, copyAction: component.copyCurrentResult, - displayLanguageSelectionMenu: component.displayLanguageSelectionMenu + displayLanguageSelectionMenu: component.displayLanguageSelectionMenu, + present: { [weak self] c, a in + self?.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + rootViewForTextSelection: { [weak self] in + return self?.environment?.controller()?.view + } )) case .fix: contentComponent = AnyComponent(TextProcessingTranslateContentComponent( @@ -375,7 +389,13 @@ final class TextProcessingContentComponent: Component { inputText: component.inputText, mode: .fix, copyAction: component.copyCurrentResult, - displayLanguageSelectionMenu: component.displayLanguageSelectionMenu + displayLanguageSelectionMenu: component.displayLanguageSelectionMenu, + present: { [weak self] c, a in + self?.environment?.controller()?.present(c, in: .window(.root), with: a) + }, + rootViewForTextSelection: { [weak self] in + return self?.environment?.controller()?.view + } )) } diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift index ac3224fa07..2ca48b76a2 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTextAreaComponent.swift @@ -13,10 +13,14 @@ import TextFormat import PlainButtonComponent import CheckComponent import ShimmerEffect +import TextSelectionNode +import Pasteboard +import Speak final class TextProcessingTextAreaComponent: Component { let context: AccountContext let theme: PresentationTheme + let strings: PresentationStrings let titlePrefix: String let title: String let titleAction: ((UIView) -> Void)? @@ -26,10 +30,13 @@ final class TextProcessingTextAreaComponent: Component { let text: TextWithEntities? let loadingStateMeasuringText: String? let textCorrectionRanges: [Range] + let present: (ViewController, Any?) -> Void + let rootViewForTextSelection: () -> UIView? init( context: AccountContext, theme: PresentationTheme, + strings: PresentationStrings, titlePrefix: String, title: String, titleAction: ((UIView) -> Void)?, @@ -38,10 +45,13 @@ final class TextProcessingTextAreaComponent: Component { emojify: (value: Bool, toggle: () -> Void)?, text: TextWithEntities?, loadingStateMeasuringText: String?, - textCorrectionRanges: [Range] + textCorrectionRanges: [Range], + present: @escaping (ViewController, Any?) -> Void, + rootViewForTextSelection: @escaping () -> UIView? ) { self.context = context self.theme = theme + self.strings = strings self.titlePrefix = titlePrefix self.isExpanded = isExpanded self.copyAction = copyAction @@ -51,6 +61,8 @@ final class TextProcessingTextAreaComponent: Component { self.text = text self.loadingStateMeasuringText = loadingStateMeasuringText self.textCorrectionRanges = textCorrectionRanges + self.present = present + self.rootViewForTextSelection = rootViewForTextSelection } static func ==(lhs: TextProcessingTextAreaComponent, rhs: TextProcessingTextAreaComponent) -> Bool { @@ -60,6 +72,9 @@ final class TextProcessingTextAreaComponent: Component { if lhs.theme !== rhs.theme { return false } + if lhs.strings !== rhs.strings { + return false + } if lhs.titlePrefix != rhs.titlePrefix { return false } @@ -115,15 +130,29 @@ final class TextProcessingTextAreaComponent: Component { private let measureLoadingText = ComponentView() private var shimmerEffectNode: ShimmerEffectNode? + private var textSelectionNode: TextSelectionNode? + private let textSelectionContainer: UIView + private let textSelectionKnobContainer: UIView + private let textSelectionKnobSurface: UIView + + private var currentSpeechHolder: SpeechSynthesizerHolder? + override init(frame: CGRect) { self.textContainer = UIView() self.textContainer.clipsToBounds = true + self.textSelectionContainer = UIView() + self.textSelectionContainer.isUserInteractionEnabled = false + self.textSelectionKnobContainer = UIView() + self.textSelectionKnobSurface = UIView() + self.textSelectionKnobContainer.addSubview(self.textSelectionKnobSurface) + self.titleButton = HighlightTrackingButton() super.init(frame: frame) self.addSubview(self.textContainer) + self.addSubview(self.textSelectionContainer) self.addSubview(self.titleButton) self.titleButton.highligthedChanged = { [weak self] highighed in @@ -459,6 +488,96 @@ final class TextProcessingTextAreaComponent: Component { } } + if component.text != nil, component.isExpanded?.value ?? true, let textView = self.text.view as? MultilineTextWithEntitiesComponent.View { + let textSelectionNode: TextSelectionNode + if let current = self.textSelectionNode { + textSelectionNode = current + } else { + textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: component.theme.list.itemAccentColor.withMultipliedAlpha(0.5), knob: component.theme.list.itemAccentColor, isDark: component.theme.overallDarkAppearance), strings: component.strings, textNodeOrView: .node(textView.textNode), updateIsActive: { [weak self] value in + guard let self else { + return + } + let _ = self + }, present: { [weak self] c, a in + guard let self, let component = self.component else { + return + } + component.present(c, a) + }, rootView: { [weak self] in + guard let self, let component = self.component else { + return nil + } + return component.rootViewForTextSelection() + }, externalKnobSurface: self.textSelectionKnobSurface, performAction: { [weak self] text, action in + guard let self, let component = self.component else { + return + } + + switch action { + case .copy: + storeAttributedTextInPasteboard(text) + case .share: + let shareController = component.context.sharedContext.makeShareController(context: component.context, subject: .text(text.string), forceExternal: true, shareStory: nil, enqueued: nil, actionCompleted: nil) + component.present(shareController, nil) + case .lookup: + let controller = UIReferenceLibraryViewController(term: text.string) + if let window = self.window { + controller.popoverPresentationController?.sourceView = window + controller.popoverPresentationController?.sourceRect = CGRect(origin: CGPoint(x: window.bounds.width / 2.0, y: window.bounds.size.height - 1.0), size: CGSize(width: 1.0, height: 1.0)) + window.rootViewController?.present(controller, animated: true) + } + case .speak: + if let speechHolder = speakText(context: component.context, text: text.string) { + speechHolder.completion = { [weak self, weak speechHolder] in + guard let self else { + return + } + if self.currentSpeechHolder === speechHolder { + self.currentSpeechHolder = nil + } + } + self.currentSpeechHolder = speechHolder + } + case .translate: + break + case .quote: + break + } + }) + + textSelectionNode.enableLookup = true + textSelectionNode.enableTranslate = false + textSelectionNode.menuSkipCoordnateConversion = false + textSelectionNode.canBeginSelection = { _ in + return true + } + self.textSelectionNode = textSelectionNode + + self.textSelectionContainer.insertSubview(self.textSelectionKnobContainer, at: 0) + self.textContainer.insertSubview(textSelectionNode.highlightAreaNode.view, belowSubview: textView) + self.textContainer.insertSubview(textSelectionNode.view, aboveSubview: textView) + } + + textSelectionNode.frame = textView.frame + textSelectionNode.highlightAreaNode.frame = textView.frame + self.textSelectionKnobSurface.frame = textView.frame + } else { + for subview in Array(self.textSelectionContainer.subviews) { + subview.removeFromSuperview() + } + if self.textSelectionKnobContainer.superview != nil { + self.textSelectionKnobContainer.removeFromSuperview() + } + if let textSelectionNode = self.textSelectionNode { + self.textSelectionNode = nil + textSelectionNode.view.removeFromSuperview() + + if textSelectionNode.highlightAreaNode.view.superview != nil { + textSelectionNode.highlightAreaNode.view.removeFromSuperview() + } + } + } + if component.text == nil { let shimmerEffectNode: ShimmerEffectNode if let current = self.shimmerEffectNode { @@ -568,6 +687,7 @@ final class TextProcessingTextAreaComponent: Component { } transition.setFrame(view: self.textContainer, frame: textContainerFrame) + transition.setFrame(view: self.textSelectionContainer, frame: textContainerFrame) contentHeight += textContainerFrame.height contentHeight += bottomInset diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift index df326e7ade..6ec7fcfe68 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift @@ -83,6 +83,8 @@ final class TextProcessingTranslateContentComponent: Component { let mode: Mode let copyAction: (() -> Void)? let displayLanguageSelectionMenu: (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void + let present: (ViewController, Any?) -> Void + let rootViewForTextSelection: () -> UIView? init( context: AccountContext, @@ -93,7 +95,9 @@ final class TextProcessingTranslateContentComponent: Component { inputText: TextWithEntities, mode: Mode, copyAction: (() -> Void)?, - displayLanguageSelectionMenu: @escaping (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void + displayLanguageSelectionMenu: @escaping (UIView, String, TelegramComposeAIMessageMode.StyleId, Bool, @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void) -> Void, + present: @escaping (ViewController, Any?) -> Void, + rootViewForTextSelection: @escaping () -> UIView? ) { self.context = context self.theme = theme @@ -104,6 +108,8 @@ final class TextProcessingTranslateContentComponent: Component { self.mode = mode self.copyAction = copyAction self.displayLanguageSelectionMenu = displayLanguageSelectionMenu + self.present = present + self.rootViewForTextSelection = rootViewForTextSelection } static func ==(lhs: TextProcessingTranslateContentComponent, rhs: TextProcessingTranslateContentComponent) -> Bool { @@ -354,6 +360,7 @@ final class TextProcessingTranslateContentComponent: Component { component: AnyComponent(TextProcessingTextAreaComponent( context: component.context, theme: component.theme, + strings: component.strings, titlePrefix: fromPrefix, title: localizedLanguageName(strings: component.strings, language: component.externalState.sourceLanguage ?? ""), titleAction: nil, @@ -373,7 +380,9 @@ final class TextProcessingTranslateContentComponent: Component { emojify: nil, text: component.inputText, loadingStateMeasuringText: nil, - textCorrectionRanges: [] + textCorrectionRanges: [], + present: component.present, + rootViewForTextSelection: component.rootViewForTextSelection )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) @@ -396,6 +405,7 @@ final class TextProcessingTranslateContentComponent: Component { component: AnyComponent(TextProcessingTextAreaComponent( context: component.context, theme: component.theme, + strings: component.strings, titlePrefix: toPrefix, title: toTitle, titleAction: component.mode == .translate ? { [weak self] sourceView in @@ -441,7 +451,9 @@ final class TextProcessingTranslateContentComponent: Component { ) : nil, text: component.externalState.result?.text, loadingStateMeasuringText: component.inputText.text, - textCorrectionRanges: component.mode == .fix ? (component.externalState.result?.textCorrectionRanges ?? []) : [] + textCorrectionRanges: component.mode == .fix ? (component.externalState.result?.textCorrectionRanges ?? []) : [], + present: component.present, + rootViewForTextSelection: component.rootViewForTextSelection )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) diff --git a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift index f29ff1ef8a..a779d44732 100644 --- a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift +++ b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift @@ -216,15 +216,56 @@ public enum TextSelectionAction: Equatable { } public final class TextSelectionNode: ASDisplayNode { + public enum TextNodeOrView { + case node(TextNodeProtocol) + case view(TextView) + + var view: UIView { + switch self { + case let .node(node): + return node.view + case let .view(view): + return view + } + } + + var currentText: NSAttributedString? { + switch self { + case let .node(node): + return node.currentText + case let .view(view): + return view.cachedLayout?.attributedString + } + } + + func attributesAtPoint(_ point: CGPoint, orNearest: Bool = false) -> (Int, [NSAttributedString.Key: Any])? { + switch self { + case let .node(node): + return node.attributesAtPoint(point, orNearest: orNearest) + case let .view(view): + return view.attributesAtPoint(point, orNearest: orNearest) + } + } + + func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + switch self { + case let .node(node): + return node.textRangeRects(in: range) + case let .view(view): + return view.cachedLayout?.rangeRects(in: range) + } + } + } + private let theme: TextSelectionTheme private let strings: PresentationStrings - private let textNode: TextNodeProtocol + private let textNodeOrView: TextNodeOrView private let updateIsActive: (Bool) -> Void public var canBeginSelection: (CGPoint) -> Bool = { _ in true } public var updateRange: ((NSRange?) -> Void)? public var presentMenu: ((UIView, CGPoint, [ContextMenuAction]) -> Void)? private let present: (ViewController, Any?) -> Void - private let rootNode: () -> ASDisplayNode? + private let rootView: () -> UIView? private let performAction: (NSAttributedString, TextSelectionAction) -> Void private var highlightOverlay: LinkHighlightingNode? private let leftKnob: ASImageNode @@ -252,13 +293,13 @@ public final class TextSelectionNode: ASDisplayNode { private weak var contextMenu: ContextMenuController? - public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNodeProtocol, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: @escaping () -> ASDisplayNode?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) { + public init(theme: TextSelectionTheme, strings: PresentationStrings, textNodeOrView: TextNodeOrView, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootView: @escaping () -> UIView?, externalKnobSurface: UIView? = nil, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) { self.theme = theme self.strings = strings - self.textNode = textNode + self.textNodeOrView = textNodeOrView self.updateIsActive = updateIsActive self.present = present - self.rootNode = rootNode + self.rootView = rootView self.performAction = performAction self.leftKnob = ASImageNode() self.leftKnob.isUserInteractionEnabled = false @@ -306,8 +347,8 @@ public final class TextSelectionNode: ASDisplayNode { return } - let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNode.view) - if let stringIndex = strongSelf.textNode.attributesAtPoint(mappedPoint, orNearest: true)?.0 { + let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNodeOrView.view) + if let stringIndex = strongSelf.textNodeOrView.attributesAtPoint(mappedPoint, orNearest: true)?.0 { var updatedLeft = currentRange.0 var updatedRight = currentRange.1 switch knob { @@ -335,15 +376,15 @@ public final class TextSelectionNode: ASDisplayNode { strongSelf.displayMenu() } recognizer.beginSelection = { [weak self] point in - guard let strongSelf = self, let attributedString = strongSelf.textNode.currentText else { + guard let strongSelf = self, let attributedString = strongSelf.textNodeOrView.currentText else { return } strongSelf.dismissSelection() - let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNode.view) + let mappedPoint = strongSelf.view.convert(point, to: strongSelf.textNodeOrView.view) var resultRange: NSRange? - if let stringIndex = strongSelf.textNode.attributesAtPoint(mappedPoint, orNearest: false)?.0 { + if let stringIndex = strongSelf.textNodeOrView.attributesAtPoint(mappedPoint, orNearest: false)?.0 { let string = attributedString.string as NSString let inputRange = CFRangeMake(0, string.length) @@ -398,7 +439,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func pretendInitiateSelection() { - guard let attributedString = self.textNode.currentText else { + guard let attributedString = self.textNodeOrView.currentText else { return } @@ -432,7 +473,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func pretendExtendSelection(to index: Int) { - guard let endRangeRect = self.textNode.textRangeRects(in: NSRange(location: index, length: 1))?.rects.first else { + guard let endRangeRect = self.textNodeOrView.textRangeRects(in: NSRange(location: index, length: 1))?.rects.first else { return } let startPoint = self.rightKnob.frame.center @@ -449,7 +490,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func setSelection(range: NSRange, displayMenu: Bool) { - guard let attributedString = self.textNode.currentText else { + guard let attributedString = self.textNodeOrView.currentText else { return } let range = self.convertSelectionFromOriginalText(attributedString: attributedString, range: range) @@ -561,7 +602,7 @@ public final class TextSelectionNode: ASDisplayNode { } public func getSelection() -> NSRange? { - guard let currentRange = self.currentRange, let attributedString = self.textNode.currentText else { + guard let currentRange = self.currentRange, let attributedString = self.textNodeOrView.currentText else { return nil } let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1)) @@ -574,7 +615,7 @@ public final class TextSelectionNode: ASDisplayNode { var rects: (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? if let range { - if var rectsValue = self.textNode.textRangeRects(in: range) { + if var rectsValue = self.textNodeOrView.textRangeRects(in: range) { var rectList = rectsValue.rects if rectList.count > 1 { for i in 0 ..< rectList.count - 1 { @@ -683,7 +724,7 @@ public final class TextSelectionNode: ASDisplayNode { } private func displayMenu() { - guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let attributedString = self.textNode.currentText else { + guard let currentRects = self.currentRects, !currentRects.isEmpty, let currentRange = self.currentRange, let attributedString = self.textNodeOrView.currentText else { return } let range = NSRange(location: min(currentRange.0, currentRange.1), length: max(currentRange.0, currentRange.1) - min(currentRange.0, currentRange.1)) @@ -778,15 +819,15 @@ public final class TextSelectionNode: ASDisplayNode { return true } self.contextMenu = contextMenu - self.present(contextMenu, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak self] in - guard let strongSelf = self, let rootNode = strongSelf.rootNode() else { + self.present(contextMenu, ContextMenuControllerPresentationArguments(sourceViewAndRect: { [weak self] in + guard let strongSelf = self, let rootView = strongSelf.rootView() else { return nil } if strongSelf.menuSkipCoordnateConversion { - return (strongSelf, strongSelf.view.convert(completeRect, to: rootNode.view), rootNode, rootNode.bounds) + return (strongSelf.view, strongSelf.view.convert(completeRect, to: rootView), rootView, rootView.bounds) } else { - return (strongSelf, completeRect, rootNode, rootNode.bounds) + return (strongSelf.view, completeRect, rootView, rootView.bounds) } }, bounce: false)) } From f48617fbec6e86e867da228b6f968eecd5b67562 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Wed, 25 Mar 2026 00:18:11 +0800 Subject: [PATCH 4/4] Update --- .../Sources/AccountContext.swift | 25 ++++++ .../AttachmentTextInputPanelNode.swift | 89 ++++++++++++++++++- .../Sources/AttachmentController.swift | 51 +++++++++++ .../Sources/AttachmentPanel.swift | 7 ++ .../Sources/ChatTextInputPanelNode.swift | 2 +- .../Sources/TextProcessingScreen.swift | 17 +--- .../Sources/SharedAccountContext.swift | 19 ++++ 7 files changed, 190 insertions(+), 20 deletions(-) diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index fd4dc7cde8..fbe392b279 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1304,6 +1304,23 @@ public protocol ChannelMembersSearchController: ViewController { var copyInviteLink: (() -> Void)? { get set } } +public final class TextProcessingScreenSendContextActions { + public let peerId: EnginePeer.Id + public let send: (TextWithEntities, ChatSendMessageActionSheetController.SendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void + public let schedule: (TextWithEntities, ChatSendMessageActionSheetController.SendParameters?) -> Void + + public init(peerId: EnginePeer.Id, send: @escaping (TextWithEntities, ChatSendMessageActionSheetController.SendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void, schedule: @escaping (TextWithEntities, ChatSendMessageActionSheetController.SendParameters?) -> Void) { + self.peerId = peerId + self.send = send + self.schedule = schedule + } +} + +public enum TextProcessingScreenMode { + case edit(saveRestoreStateId: EnginePeer.Id?, completion: (TextWithEntities) -> Void, send: ((TextWithEntities) -> Void)?, sendContextActions: TextProcessingScreenSendContextActions?) + case translate(fromLanguage: String?) +} + public protocol SharedAccountContext: AnyObject { var sharedContainerPath: String { get } var basePath: String { get } @@ -1541,6 +1558,14 @@ public protocol SharedAccountContext: AnyObject { func makePeerCopyProtectionInfoScreen(context: AccountContext, completion: @escaping () -> Void) -> ViewController func makeChatRankInfoScreen(context: AccountContext, chatPeer: EnginePeer, userPeer: EnginePeer, role: ChatRankInfoScreenRole, rank: String, canChange: Bool, completion: @escaping () -> Void) -> ViewController func makeChatRankPreviewItem(context: AccountContext, peer: EnginePeer, rank: String, rankRole: ChatRankInfoScreenRole, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, fontSize: PresentationFontSize, chatBubbleCorners: PresentationChatBubbleCorners, dateTimeFormat: PresentationDateTimeFormat, nameOrder: PresentationPersonNameOrder, sectionId: Int32) -> ListViewItem + func makeTextProcessingScreen( + context: AccountContext, + mode: TextProcessingScreenMode, + ignoredTranslationLanguages: [String], + inputText: TextWithEntities, + copyResult: ((TextWithEntities) -> Void)?, + translateChat: ((String) -> Void)? + ) async -> ViewController func makeCreateBotScreen( context: AccountContext, parentBot: EnginePeer.Id, diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index 361ba3e26c..fbc3cb7cce 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -274,6 +274,10 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private var transparentTextInputBackgroundImage: UIImage? private let actionButtons: AttachmentTextInputActionButtonsNode private let counterTextNode: ImmediateTextNode + + private var aiButton: (button: HighlightTrackingButton, icon: UIImageView)? + private var heightDependentAiButtonAlpha: CGFloat = 0.0 + public var isAIEnabled: Bool = false public var opaqueActionButtons: ASDisplayNode { return self.actionButtons @@ -284,6 +288,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private var validLayout: (CGFloat, CGFloat, CGFloat, UIEdgeInsets, CGFloat, LayoutMetrics, Bool)? public var sendMessage: (AttachmentTextInputPanelSendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void = { _, _ in } + public var invokeAICompose: (() -> Void)? public var updateHeight: (Bool) -> Void = { _ in } private var updatingInputState = false @@ -722,10 +727,16 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS guard self.isUserInteractionEnabled else { return nil } + if let aiButton = self.aiButton, aiButton.button.alpha > 0.0 { + let aiButtonPoint = self.view.convert(point, to: aiButton.button) + if aiButton.button.bounds.contains(aiButtonPoint) { + return aiButton.button + } + } if !self.inputModeView.isHidden, let result = self.inputModeView.hitTest(self.view.convert(point, to: self.inputModeView), with: event) { return result } - + return super.hitTest(point, with: event) } @@ -1039,7 +1050,62 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS transition.updateFrame(layer: self.textInputBackgroundNode.layer, frame: CGRect(x: leftInset + textFieldInsets.left, y: textFieldInsets.top, width: baseWidth - textFieldInsets.left - textFieldInsets.right + composeButtonsOffset - textBackgroundInset, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom)) transition.updateFrame(layer: self.textInputBackgroundImageNode.layer, frame: CGRect(x: 0.0, y: 0.0, width: baseWidth - textFieldInsets.left - textFieldInsets.right + composeButtonsOffset - textBackgroundInset, height: panelHeight - textFieldInsets.top - textFieldInsets.bottom)) - + + if self.isAIEnabled { + let aiButton: (button: HighlightTrackingButton, icon: UIImageView) + if let current = self.aiButton { + aiButton = current + } else { + aiButton = (HighlightTrackingButton(), UIImageView()) + self.aiButton = aiButton + aiButton.button.highligthedChanged = { [weak self] highlighted in + guard let self, let aiButton = self.aiButton else { + return + } + if highlighted { + aiButton.icon.alpha = 0.6 + } else { + let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) + transition.updateAlpha(layer: aiButton.icon.layer, alpha: 1.0) + } + } + aiButton.button.addTarget(self, action: #selector(self.aiButtonPressed), for: .touchUpInside) + aiButton.button.addSubview(aiButton.icon) + aiButton.icon.image = UIImage(bundleImageName: "Chat/Input/Text/InputAIIcon")?.withRenderingMode(.alwaysTemplate) + self.textInputBackgroundNode.view.addSubview(aiButton.icon) + self.textInputBackgroundNode.view.addSubview(aiButton.button) + } + + if let presentationInterfaceState = self.presentationInterfaceState { + aiButton.icon.tintColor = presentationInterfaceState.theme.chat.inputPanel.inputControlColor + } + if let image = aiButton.icon.image { + let aiButtonSize = CGSize(width: 40.0, height: 40.0) + let aiButtonFrame = CGRect(origin: CGPoint(x: baseWidth - rightInset - aiButtonSize.width - 12.0, y: -1.0), size: aiButtonSize) + transition.updateFrame(view: aiButton.button, frame: aiButtonFrame) + transition.updateFrame(view: aiButton.icon, frame: image.size.centered(in: aiButtonFrame)) + } + + var aiButtonAlpha: CGFloat = textInputHeight >= 70.0 ? 1.0 : 0.0 + self.heightDependentAiButtonAlpha = aiButtonAlpha + if !inputHasText { + aiButtonAlpha = 0.0 + } + ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha) + ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha) + } else if let aiButton = self.aiButton { + self.aiButton = nil + let aiButtonView = aiButton.button + let aiButtonIconView = aiButton.icon + transition.updateAlpha(layer: aiButton.button.layer, alpha: 0.0, completion: { [weak aiButtonView] _ in + aiButtonView?.removeFromSuperview() + }) + transition.updateAlpha(layer: aiButton.icon.layer, alpha: 0.0, completion: { [weak aiButtonIconView] _ in + aiButtonIconView?.removeFromSuperview() + }) + self.heightDependentAiButtonAlpha = 0.0 + } + var textInputViewRealInsets = UIEdgeInsets() if let presentationInterfaceState = self.presentationInterfaceState { textInputViewRealInsets = calculateTextFieldRealInsets(presentationInterfaceState, glass: self.glass) @@ -1114,11 +1180,22 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.updateTextNodeText(animated: true) self.updateCounterTextNode(transition: .immediate) - + + if let aiButton = self.aiButton { + var aiButtonAlpha: CGFloat = self.heightDependentAiButtonAlpha + if let attributedText = textInputNode.attributedText, attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + aiButtonAlpha = 0.0 + } else if textInputNode.attributedText == nil { + aiButtonAlpha = 0.0 + } + ComponentTransition(.immediate).setAlpha(view: aiButton.button, alpha: aiButtonAlpha) + ComponentTransition(.immediate).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha) + } + self.skipUpdate = false } } - + @objc public func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { self.chatInputTextNodeDidUpdateText() } @@ -1908,6 +1985,10 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS public func chatInputTextNodeBackspaceWhileEmpty() { } + @objc private func aiButtonPressed() { + self.invokeAICompose?() + } + @objc func sendButtonPressed() { let inputTextMaxLength: Int32? if let maxCaptionLength = self.maxCaptionLength { diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index 58771820ca..3916affc8a 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -748,6 +748,57 @@ public class AttachmentController: ViewController, MinimizableController { } } } + self.panel.invokeAICompose = { [weak self] in + Task { @MainActor in + guard let self, let controller = self.controller, let mediaPickerContext = self.mediaPickerContext else { + return + } + + guard let caption = await mediaPickerContext.caption.get() else { + return + } + if caption.length == 0 { + return + } + + let sharedDataEntries = await controller.context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings]).get() + let translationSettings: TranslationSettings + if let value = sharedDataEntries.entries[ApplicationSpecificSharedDataKeys.translationSettings], let parsedValue = value.get(TranslationSettings.self) { + translationSettings = parsedValue + } else { + translationSettings = .defaultSettings + } + + let textProcessingScreen = await controller.context.sharedContext.makeTextProcessingScreen( + context: controller.context, + mode: .edit( + saveRestoreStateId: nil, + completion: { [weak self] text in + //TODO:localize + guard let self, let mediaPickerContext = self.mediaPickerContext else { + return + } + self.panel.updateCaption(NSAttributedString(string: text.text)) + mediaPickerContext.setCaption(NSAttributedString(string: text.text)) + }, + send: { [weak self] text in + //TODO:localize + guard let self, let mediaPickerContext = self.mediaPickerContext else { + return + } + mediaPickerContext.setCaption(NSAttributedString(string: text.text)) + mediaPickerContext.send(mode: .generic, attachmentMode: .media, parameters: nil) + }, + sendContextActions: nil + ), + ignoredTranslationLanguages: translationSettings.ignoredLanguages ?? [], + inputText: TextWithEntities(text: caption.string, entities: []), + copyResult: nil, + translateChat: nil + ) + self.controller?.push(textProcessingScreen) + } + } self.panel.onMainButtonPressed = { [weak self] in if let strongSelf = self { diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index 903e3b5fd5..dd7793fc13 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -1065,6 +1065,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate, ASGestureRecog var beganTextEditing: () -> Void = {} var textUpdated: (NSAttributedString) -> Void = { _ in } var sendMessagePressed: (AttachmentTextInputPanelSendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void = { _, _ in } + var invokeAICompose: () -> Void = {} var requestLayout: () -> Void = {} var present: (ViewController) -> Void = { _ in } var presentInGlobalOverlay: (ViewController) -> Void = { _ in } @@ -2227,12 +2228,18 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate, ASGestureRecog strongSelf.present(c) } }, makeEntityInputView: self.makeEntityInputView) + textInputPanelNode.isAIEnabled = true textInputPanelNode.interfaceInteraction = self.interfaceInteraction textInputPanelNode.sendMessage = { [weak self] mode, messageEffect in if let strongSelf = self { strongSelf.sendMessagePressed(mode, messageEffect) } } + textInputPanelNode.invokeAICompose = { [weak self] in + if let strongSelf = self { + strongSelf.invokeAICompose() + } + } textInputPanelNode.focusUpdated = { [weak self] focus in if let strongSelf = self, focus { strongSelf.beganTextEditing() diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index d31dedf2e7..68a8987f95 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -4398,7 +4398,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg if let aiButton = self.aiButton { let transition: ContainedViewLayoutTransition = .immediate var aiButtonAlpha: CGFloat = self.heightDependentAiButtonAlpha - if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, !attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { aiButtonAlpha = 0.0 } ComponentTransition(transition).setAlpha(view: aiButton.button, alpha: aiButtonAlpha) diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift index c501ec2c0c..f2e3a5857d 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift @@ -1075,22 +1075,9 @@ private final class TextProcessingSheetComponent: Component { } public class TextProcessingScreen: ViewControllerComponentContainer { - public final class SendContextActions { - public let peerId: EnginePeer.Id - public let send: (TextWithEntities, ChatSendMessageActionSheetController.SendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void - public let schedule: (TextWithEntities, ChatSendMessageActionSheetController.SendParameters?) -> Void - - public init(peerId: EnginePeer.Id, send: @escaping (TextWithEntities, ChatSendMessageActionSheetController.SendMode, ChatSendMessageActionSheetController.SendParameters?) -> Void, schedule: @escaping (TextWithEntities, ChatSendMessageActionSheetController.SendParameters?) -> Void) { - self.peerId = peerId - self.send = send - self.schedule = schedule - } - } + public typealias SendContextActions = TextProcessingScreenSendContextActions - public enum Mode { - case edit(saveRestoreStateId: EnginePeer.Id?, completion: (TextWithEntities) -> Void, send: ((TextWithEntities) -> Void)?, sendContextActions: SendContextActions?) - case translate(fromLanguage: String?) - } + public typealias Mode = TextProcessingScreenMode struct EditState: Codable { var selectedMode: Int32 diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 4878a1fca7..f3acbcf411 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -100,6 +100,7 @@ import ChatParticipantRightsScreen import PeerCopyProtectionInfoScreen import ChatRankInfoScreen import RankChatPreviewItem +import TextProcessingScreen import CreateBotScreen private final class AccountUserInterfaceInUseContext { @@ -4365,6 +4366,24 @@ public final class SharedAccountContextImpl: SharedAccountContext { return RankChatPreviewItem(context: context, systemStyle: .glass, theme: theme, componentTheme: theme, strings: strings, sectionId: sectionId, fontSize: fontSize, chatBubbleCorners: chatBubbleCorners, wallpaper: wallpaper, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameOrder, messageItems: [messageItem]) } + public func makeTextProcessingScreen( + context: AccountContext, + mode: TextProcessingScreenMode, + ignoredTranslationLanguages: [String], + inputText: TextWithEntities, + copyResult: ((TextWithEntities) -> Void)?, + translateChat: ((String) -> Void)? + ) async -> ViewController { + return await TextProcessingScreen( + context: context, + mode: mode, + ignoredTranslationLanguages: ignoredTranslationLanguages, + inputText: inputText, + copyResult: copyResult, + translateChat: translateChat + ) + } + public func makeCreateBotScreen( context: AccountContext, parentBot: EnginePeer.Id,