Various improvements

This commit is contained in:
Isaac 2026-03-24 16:47:36 +08:00
parent 9c0e6f6151
commit a0a2f9f6bf
10 changed files with 348 additions and 145 deletions

View file

@ -319,6 +319,7 @@ public class CheckLayer: CALayer {
super.init() super.init()
self.isOpaque = false self.isOpaque = false
self.rasterizationScale = UIScreenScale
} }
public override init(layer: Any) { public override init(layer: Any) {

View file

@ -69,6 +69,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma
|> `catch` { error -> Signal<AddressNameAvailability, NoError> in |> `catch` { error -> Signal<AddressNameAvailability, NoError> in
if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" {
return .single(.purchaseAvailable) return .single(.purchaseAvailable)
} else if error.errorDescription == "USERNAME_OCCUPIED" {
return .single(.taken)
} else { } else {
return .single(.invalid) return .single(.invalid)
} }
@ -87,6 +89,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma
|> `catch` { error -> Signal<AddressNameAvailability, NoError> in |> `catch` { error -> Signal<AddressNameAvailability, NoError> in
if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" {
return .single(.purchaseAvailable) return .single(.purchaseAvailable)
} else if error.errorDescription == "USERNAME_OCCUPIED" {
return .single(.taken)
} else { } else {
return .single(.invalid) return .single(.invalid)
} }
@ -104,6 +108,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma
|> `catch` { error -> Signal<AddressNameAvailability, NoError> in |> `catch` { error -> Signal<AddressNameAvailability, NoError> in
if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" {
return .single(.purchaseAvailable) return .single(.purchaseAvailable)
} else if error.errorDescription == "USERNAME_OCCUPIED" {
return .single(.taken)
} else { } else {
return .single(.invalid) return .single(.invalid)
} }
@ -124,6 +130,8 @@ func _internal_addressNameAvailability(account: Account, domain: AddressNameDoma
|> `catch` { error -> Signal<AddressNameAvailability, NoError> in |> `catch` { error -> Signal<AddressNameAvailability, NoError> in
if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" { if error.errorDescription == "USERNAME_PURCHASE_AVAILABLE" {
return .single(.purchaseAvailable) return .single(.purchaseAvailable)
} else if error.errorDescription == "USERNAME_OCCUPIED" {
return .single(.taken)
} else { } else {
return .single(.invalid) return .single(.invalid)
} }

View file

@ -247,6 +247,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg
private let counterTextNode: ImmediateTextNode private let counterTextNode: ImmediateTextNode
private var aiButton: (button: HighlightTrackingButton, icon: UIImageView)? private var aiButton: (button: HighlightTrackingButton, icon: UIImageView)?
private var heightDependentAiButtonAlpha: CGFloat = 0.0
public let menuButton: HighlightTrackingButtonNode public let menuButton: HighlightTrackingButtonNode
private let menuButtonBackgroundView: GlassBackgroundView private let menuButtonBackgroundView: GlassBackgroundView
@ -3558,7 +3559,15 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg
transition.updateFrame(view: aiButton.button, frame: aiButtonFrame) transition.updateFrame(view: aiButton.button, frame: aiButtonFrame)
transition.updateFrame(view: aiButton.icon, frame: image.size.centered(in: 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.button.layer, alpha: aiButtonAlpha)
transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha) transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha)
} else if let aiButton = self.aiButton { } 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 transition.updateAlpha(layer: aiButton.icon.layer, alpha: 0.0, completion: { [weak aiButtonIconView] _ in
aiButtonIconView?.removeFromSuperview() aiButtonIconView?.removeFromSuperview()
}) })
self.heightDependentAiButtonAlpha = 0.0
} }
let containerFrame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: contentHeight + 64.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) self.updateTextHeight(animated: animated)
} }

View file

@ -26,6 +26,7 @@ final class CreateBotContentComponent: Component {
final class ExternalState { final class ExternalState {
var name: String = "" var name: String = ""
var username: String = "" var username: String = ""
var usernameIsChecked: Bool = false
init() { init() {
} }
@ -54,6 +55,13 @@ final class CreateBotContentComponent: Component {
static func ==(lhs: CreateBotContentComponent, rhs: CreateBotContentComponent) -> Bool { static func ==(lhs: CreateBotContentComponent, rhs: CreateBotContentComponent) -> Bool {
return true return true
} }
private enum UsernameCheckingStatus {
case checking
case valid
case invalid
case taken
}
final class View: UIView { final class View: UIView {
private var component: CreateBotContentComponent? private var component: CreateBotContentComponent?
@ -67,7 +75,19 @@ final class CreateBotContentComponent: Component {
private let usernameSection = ComponentView<Empty>() private let usernameSection = ComponentView<Empty>()
private let usernameInputState = ListMultilineTextFieldItemComponent.ExternalState() private let usernameInputState = ListMultilineTextFieldItemComponent.ExternalState()
private let usernameInputTag = ListMultilineTextFieldItemComponent.Tag()
private let nameInputState = ListMultilineTextFieldItemComponent.ExternalState() 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) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -77,6 +97,11 @@ final class CreateBotContentComponent: Component {
return return
} }
component.externalState.username = self.usernameInputState.text.string component.externalState.username = self.usernameInputState.text.string
self.inputUsernameUpdated()
if !self.isUpdating {
self.state?.updated(transition: .immediate)
}
} }
self.nameInputState.updated = { [weak self] in self.nameInputState.updated = { [weak self] in
guard let self, let component = self.component else { guard let self, let component = self.component else {
@ -89,6 +114,47 @@ final class CreateBotContentComponent: Component {
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
preconditionFailure() 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<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize { func update(component: CreateBotContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true self.isUpdating = true
@ -210,10 +276,24 @@ final class CreateBotContentComponent: Component {
autocapitalizationType: .words, autocapitalizationType: .words,
autocorrectionType: .no, autocorrectionType: .no,
characterLimit: 64, 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, emptyLineHandling: .notAllowed,
updated: { _ in }, updated: { _ in },
textUpdateTransition: .immediate textUpdateTransition: .immediate,
tag: self.nameInputTag
))) )))
] ]
)), )),
@ -231,14 +311,58 @@ final class CreateBotContentComponent: Component {
contentHeight += nameSectionSize.height + 22.0 contentHeight += nameSectionSize.height + 22.0
var initialUsername = "" var initialUsername = ""
var botSuffix = "bot"
if let value = component.initialUsername { 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)]) initialUsername = String(value[value.startIndex ..< value.index(value.endIndex, offsetBy: -3)])
} else { } else {
initialUsername = value 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( let usernameSectionSize = self.usernameSection.update(
transition: transition, transition: transition,
component: AnyComponent(ListSectionComponent( component: AnyComponent(ListSectionComponent(
@ -252,7 +376,10 @@ final class CreateBotContentComponent: Component {
)), )),
maximumNumberOfLines: 0 maximumNumberOfLines: 0
)), )),
footer: nil, footer: AnyComponent(MultilineTextComponent(
text: .plain(usernameFooterString),
maximumNumberOfLines: 0
)),
items: [ items: [
AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent(
externalState: usernameInputState, externalState: usernameInputState,
@ -268,11 +395,25 @@ final class CreateBotContentComponent: Component {
keyboardType: .asciiCapable, keyboardType: .asciiCapable,
characterLimit: 32, characterLimit: 32,
prefix: NSAttributedString(string: "@", font: Font.regular(17.0), textColor: environment.theme.list.itemPrimaryTextColor), 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), 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))), 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.usernameInputTag) as? ListMultilineTextFieldItemComponent.View else {
return
}
itemView.activateInput()
}
))),
insets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 0.0)
),
emptyLineHandling: .notAllowed, emptyLineHandling: .notAllowed,
updated: { _ in }, 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.name = self.nameInputState.text.string
component.externalState.username = self.usernameInputState.text.string component.externalState.username = self.usernameInputState.text.string
component.externalState.usernameIsChecked = self.usernameCheckingStatus?.status == .valid
return CGSize(width: availableSize.width, height: contentHeight) return CGSize(width: availableSize.width, height: contentHeight)
} }
@ -316,7 +458,7 @@ private final class CreateBotSheetComponent: Component {
let initialUsername: String? let initialUsername: String?
let initialTitle: String? let initialTitle: String?
let openAutomatically: Bool let openAutomatically: Bool
let completion: (EnginePeer.Id) -> Void let completion: (EnginePeer.Id?) -> Void
init( init(
context: AccountContext, context: AccountContext,
@ -324,7 +466,7 @@ private final class CreateBotSheetComponent: Component {
initialUsername: String?, initialUsername: String?,
initialTitle: String?, initialTitle: String?,
openAutomatically: Bool, openAutomatically: Bool,
completion: @escaping (EnginePeer.Id) -> Void completion: @escaping (EnginePeer.Id?) -> Void
) { ) {
self.context = context self.context = context
self.parentPeer = parentPeer self.parentPeer = parentPeer
@ -349,6 +491,7 @@ private final class CreateBotSheetComponent: Component {
private var isCreating: Bool = false private var isCreating: Bool = false
private var actionDisposable: Disposable? private var actionDisposable: Disposable?
private var isCompleted: Bool = false
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -362,28 +505,94 @@ private final class CreateBotSheetComponent: Component {
self.actionDisposable?.dispose() self.actionDisposable?.dispose()
} }
private func validatedParams() -> (name: String, username: String)? { func attemptNavigation(complete: @escaping () -> Void) -> Bool {
if self.contentExternalState.name.isEmpty { guard let component = self.component else {
return nil return true
} }
if self.contentExternalState.username.isEmpty { if self.isCreating {
return nil 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<String, UsernameValidationError> {
var isUsernameValid = true var isUsernameValid = true
var usernameCharacters = CharacterSet(charactersIn: "a".unicodeScalars.first! ... "z".unicodeScalars.first!) var usernameCharacters = CharacterSet(charactersIn: "a".unicodeScalars.first! ... "z".unicodeScalars.first!)
usernameCharacters.insert(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(charactersIn: "0".unicodeScalars.first! ... "9".unicodeScalars.first!)
usernameCharacters.insert("_") usernameCharacters.insert("_")
for c in self.contentExternalState.username.unicodeScalars { for c in inputUsername.unicodeScalars {
if !usernameCharacters.contains(c) { if !usernameCharacters.contains(c) {
isUsernameValid = false isUsernameValid = false
break break
} }
} }
if !isUsernameValid { 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 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() { private func performCreateBot() {
@ -413,6 +622,7 @@ private final class CreateBotSheetComponent: Component {
return return
} }
let context = component.context let context = component.context
self.isCompleted = true
self.animateOut.invoke(Action { [weak controller, weak navigationController] _ in self.animateOut.invoke(Action { [weak controller, weak navigationController] _ in
if let controller, let navigationController { if let controller, let navigationController {
controller.dismiss(completion: { [weak navigationController] in controller.dismiss(completion: { [weak navigationController] in
@ -466,8 +676,15 @@ private final class CreateBotSheetComponent: Component {
let theme = environmentValue.theme let theme = environmentValue.theme
let dismiss: (Bool) -> Void = { [weak self] animated in 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 { if animated {
self?.animateOut.invoke(Action { _ in self.animateOut.invoke(Action { _ in
if let controller = controller() { if let controller = controller() {
controller.dismiss(completion: nil) controller.dismiss(completion: nil)
} }
@ -544,7 +761,17 @@ private final class CreateBotSheetComponent: Component {
isCentered: environmentValue.metrics.widthClass == .regular, isCentered: environmentValue.metrics.widthClass == .regular,
screenSize: availableSize, screenSize: availableSize,
regularMetricsSize: nil, 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) dismiss(animated)
} }
) )
@ -581,7 +808,7 @@ public class CreateBotScreen: ViewControllerComponentContainer {
initialUsername: String?, initialUsername: String?,
initialTitle: String?, initialTitle: String?,
openAutomatically: Bool, openAutomatically: Bool,
completion: @escaping (EnginePeer.Id) -> Void completion: @escaping (EnginePeer.Id?) -> Void
) async { ) async {
self.context = context self.context = context
@ -609,6 +836,14 @@ public class CreateBotScreen: ViewControllerComponentContainer {
self.statusBar.statusBarStyle = .Ignore self.statusBar.statusBarStyle = .Ignore
self.navigationPresentation = .flatModal self.navigationPresentation = .flatModal
self.blocksBackgroundWhenInOverlay = true 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) { required public init(coder aDecoder: NSCoder) {
@ -777,13 +1012,16 @@ private final class ActionButtonsComponent: Component {
private final class EditLabelComponent: Component { private final class EditLabelComponent: Component {
let theme: PresentationTheme let theme: PresentationTheme
let strings: PresentationStrings let strings: PresentationStrings
let action: () -> Void
init( init(
theme: PresentationTheme, theme: PresentationTheme,
strings: PresentationStrings strings: PresentationStrings,
action: @escaping () -> Void
) { ) {
self.theme = theme self.theme = theme
self.strings = strings self.strings = strings
self.action = action
} }
static func ==(lhs: EditLabelComponent, rhs: EditLabelComponent) -> Bool { static func ==(lhs: EditLabelComponent, rhs: EditLabelComponent) -> Bool {
@ -805,12 +1043,23 @@ private final class EditLabelComponent: Component {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))))
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") 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<Empty>, transition: ComponentTransition) -> CGSize { func update(component: EditLabelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component self.component = component
self.state = state self.state = state
@ -846,6 +1095,7 @@ private final class EditLabelComponent: Component {
) )
if let backgroundView = self.background.view { if let backgroundView = self.background.view {
if backgroundView.superview == nil { if backgroundView.superview == nil {
backgroundView.isUserInteractionEnabled = false
self.addSubview(backgroundView) self.addSubview(backgroundView)
} }
transition.setFrame(view: backgroundView, frame: backgroundFrame) transition.setFrame(view: backgroundView, frame: backgroundFrame)
@ -853,6 +1103,7 @@ private final class EditLabelComponent: Component {
if let titleView = self.title.view { if let titleView = self.title.view {
if titleView.superview == nil { if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView) self.addSubview(titleView)
} }
transition.setPosition(view: titleView, position: titleFrame.center) transition.setPosition(view: titleView, position: titleFrame.center)

View file

@ -17,6 +17,11 @@ public final class ListMultilineTextFieldItemComponent: Component {
case legacy case legacy
} }
public final class Tag {
public init() {
}
}
public final class ExternalState { public final class ExternalState {
public fileprivate(set) var hasText: Bool = false public fileprivate(set) var hasText: Bool = false
public fileprivate(set) var text: NSAttributedString = NSAttributedString() public fileprivate(set) var text: NSAttributedString = NSAttributedString()

View file

@ -126,9 +126,11 @@ public extension PeerInfoScreenImpl {
commit() commit()
} }
controller.videoCompletion = { [weak parentController] image, url, values, markup, commit in controller.videoCompletion = { [weak parentController] image, url, values, markup, commit in
guard let parentController else {
return
}
resultImage = image resultImage = image
let _ = parentController updateProfileVideo(parentController: parentController, context: context, peer: peer, image: image, video: nil, values: nil, markup: markup, mode: mode, uploadStatus: uploadStatusPromise)
updateProfileVideo(context: context, image: image, video: nil, values: nil, markup: markup, mode: mode, uploadStatus: uploadStatusPromise)
commit() commit()
} }
parentController.push(controller) parentController.push(controller)
@ -193,7 +195,7 @@ public extension PeerInfoScreenImpl {
case let .video(video, coverImage, values, _, _): case let .video(video, coverImage, values, _, _):
if let coverImage { if let coverImage {
resultImage = 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({}) commit({})
default: default:
@ -283,52 +285,24 @@ public extension PeerInfoScreenImpl {
if case .complete = result { if case .complete = result {
dismissStatus?() 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?) { static func updateProfileVideo(parentController: ViewController, context: AccountContext, peer: EnginePeer, 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) 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<PeerInfoAvatarUploadStatus>?) { static func updateProfileVideo(parentController: ViewController, context: AccountContext, peer: EnginePeer, image: UIImage, video: MediaEditorScreenImpl.MediaResult.VideoResult?, values: MediaEditorValues?, markup: UploadPeerPhotoMarkup?, mode: PeerInfoAvatarEditingMode, uploadStatus: Promise<PeerInfoAvatarUploadStatus>?) {
/*var uploadVideo = true var uploadVideo = true
if let _ = markup { 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 uploadVideo = true
} else { } else {
uploadVideo = false 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)) uploadStatus?.set(.single(.done))
return return
} }
@ -338,8 +312,8 @@ public extension PeerInfoScreenImpl {
videoStartTimestamp = coverImageTimestamp - (values.videoTrimRange?.lowerBound ?? 0.0) videoStartTimestamp = coverImageTimestamp - (values.videoTrimRange?.lowerBound ?? 0.0)
} }
let account = self.context.account let account = context.account
let context = self.context let context = context
let videoResource: Signal<TelegramMediaResource?, UploadPeerPhotoError> let videoResource: Signal<TelegramMediaResource?, UploadPeerPhotoError>
if uploadVideo, let video, let values { if uploadVideo, let video, let values {
@ -388,10 +362,7 @@ public extension PeerInfoScreenImpl {
let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4") 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 = MediaEditorVideoExport(postbox: context.account.postbox, subject: exportSubject, configuration: configuration, outputPath: tempFile.path, textScale: 2.0)
let _ = (videoExport.status let _ = (videoExport.status
|> deliverOnMainQueue).startStandalone(next: { [weak self] status in |> deliverOnMainQueue).startStandalone(next: { status in
guard let self else {
return
}
switch status { switch status {
case .completed: case .completed:
if let data = try? Data(contentsOf: URL(fileURLWithPath: tempFile.path), options: .mappedIfSafe) { if let data = try? Data(contentsOf: URL(fileURLWithPath: tempFile.path), options: .mappedIfSafe) {
@ -401,11 +372,8 @@ public extension PeerInfoScreenImpl {
subscriber.putCompletion() subscriber.putCompletion()
} }
EngineTempBox.shared.dispose(tempFile) EngineTempBox.shared.dispose(tempFile)
case let .progress(progress): case .progress:
Queue.mainQueue().async { break
self.controllerNode.state = self.controllerNode.state.withAvatarUploadProgress(.value(CGFloat(progress * 0.45)))
self.requestLayout(transition: .immediate)
}
default: default:
break break
} }
@ -422,100 +390,41 @@ public extension PeerInfoScreenImpl {
} }
var dismissStatus: (() -> Void)? var dismissStatus: (() -> Void)?
if [.suggest, .fallback, .accept].contains(mode) { if "".isEmpty {
let statusController = OverlayStatusController(theme: self.presentationData.theme, type: .loading(cancelled: { [weak self] in let presentationData = context.sharedContext.currentPresentationData.with({ $0 })
self?.controllerNode.updateAvatarDisposable.set(nil) let statusController = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
dismissStatus?() dismissStatus?()
})) }))
dismissStatus = { [weak statusController] in dismissStatus = { [weak statusController] in
statusController?.dismiss() statusController?.dismiss()
} }
if let topController = self.navigationController?.topViewController as? ViewController { if let topController = parentController.navigationController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController)
} else if let topController = self.parentController?.topViewController as? ViewController {
topController.presentInGlobalOverlay(statusController) topController.presentInGlobalOverlay(statusController)
} else { } else {
self.presentInGlobalOverlay(statusController) parentController.presentInGlobalOverlay(statusController)
} }
} }
let peerId = self.peerId let peerId = peer.id
let isSettings = self.isSettings let updateAvatarDisposable = MetaDisposable()
let isMyProfile = self.isMyProfile updateAvatarDisposable.set((videoResource
self.controllerNode.updateAvatarDisposable.set((videoResource
|> mapToSignal { videoResource -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> in |> mapToSignal { videoResource -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> in
if isSettings || isMyProfile { 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
if case .fallback = mode { return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
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)
})
}
} }
|> deliverOnMainQueue).startStrict(next: { [weak self] result in |> deliverOnMainQueue).startStandalone(next: { result in
guard let strongSelf = self else {
return
}
switch result { switch result {
case .complete: case .complete:
uploadStatus?.set(.single(.done)) uploadStatus?.set(.single(.done))
strongSelf.controllerNode.state = strongSelf.controllerNode.state.withUpdatingAvatar(nil).withAvatarUploadProgress(nil)
case let .progress(value): case let .progress(value):
uploadStatus?.set(.single(.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 { if case .complete = result {
dismissStatus?() 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
}
}
})
} }
}))*/ }))
} }
} }

View file

@ -99,7 +99,7 @@ final class TextProcessingContentComponent: Component {
private var currentContent: (mode: Mode, view: ComponentView<Empty>)? private var currentContent: (mode: Mode, view: ComponentView<Empty>)?
private var currentMode: Mode = .stylize private var currentMode: Mode = .translate
override init(frame: CGRect) { override init(frame: CGRect) {
self.currentContentBackground = UIImageView() self.currentContentBackground = UIImageView()
@ -204,6 +204,12 @@ final class TextProcessingContentComponent: Component {
if self.component == nil { if self.component == nil {
self.stylizeState.displayStyleTooltip = component.shouldDisplayStyleNotice self.stylizeState.displayStyleTooltip = component.shouldDisplayStyleNotice
switch component.mode {
case .edit:
self.currentMode = .stylize
case .translate:
self.currentMode = .translate
}
} }
self.component = component self.component = component

View file

@ -4463,7 +4463,9 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
} }
let effectiveInputText: NSAttributedString let effectiveInputText: NSAttributedString
var isEdit = false
if effectivePresentationInterfaceState.interfaceState.editMessage != nil { if effectivePresentationInterfaceState.interfaceState.editMessage != nil {
isEdit = true
effectiveInputText = expandedInputStateAttributedString(effectivePresentationInterfaceState.interfaceState.effectiveInputState.inputText) effectiveInputText = expandedInputStateAttributedString(effectivePresentationInterfaceState.interfaceState.effectiveInputState.inputText)
} else { } else {
effectiveInputText = expandedInputStateAttributedString(effectivePresentationInterfaceState.interfaceState.composeInputState.inputText) effectiveInputText = expandedInputStateAttributedString(effectivePresentationInterfaceState.interfaceState.composeInputState.inputText)
@ -4516,7 +4518,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
}) })
self.sendCurrentMessage() 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, peerId: peerId,
send: { [weak self] text, mode, parameters in send: { [weak self] text, mode, parameters in
guard let self, let controller = self.controller else { guard let self, let controller = self.controller else {

View file

@ -506,6 +506,7 @@ final class ChatReportPeerTitlePanelNode: ChatTitleAccessoryPanelNode {
}) })
view.setImage(image?.withRenderingMode(.alwaysOriginal), for: []) view.setImage(image?.withRenderingMode(.alwaysOriginal), for: [])
view.setImage(generateTintedImage(image: image, color: interfaceState.theme.rootController.navigationBar.accentTextColor.withAlphaComponent(0.7))?.withRenderingMode(.alwaysOriginal), for: [.highlighted])
} }
} }
} }

View file

@ -2350,8 +2350,8 @@ public final class WebAppController: ViewController, AttachmentContainable {
} }
} }
) )
if let createBotScreen { if let createBotScreen, let navigationController = controller.getNavigationController() {
controller.push(createBotScreen) navigationController.pushViewController(createBotScreen)
} }
} }
default: default: