From a0a2f9f6bf07b0e58f446ffa88da36160f95b62e Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 24 Mar 2026 16:47:36 +0800 Subject: [PATCH] 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: