Merge branches 'master' and 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2026-03-24 17:21:52 +01:00
commit 583e794a53
30 changed files with 803 additions and 219 deletions

View file

@ -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,

View file

@ -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 {

View file

@ -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 {

View file

@ -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()

View file

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

View file

@ -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()

View file

@ -466,7 +466,7 @@ public final class ResizableSheetComponent<ChildEnvironmentType: Sendable & Equa
topOffsetFraction = 1.0
}
#if DEBUG
#if DEBUG && false
if "".isEmpty {
topOffsetFraction = 1.0
}

View file

@ -3,13 +3,23 @@ import UIKit
import AsyncDisplayKit
public final class ContextMenuControllerPresentationArguments {
public let sourceNodeAndRect: () -> (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 {

View file

@ -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

View file

@ -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
}

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,9 +3559,17 @@ 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
transition.updateAlpha(layer: aiButton.button.layer, alpha: aiButtonAlpha)
transition.updateAlpha(layer: aiButton.icon.layer, alpha: aiButtonAlpha)
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.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
inputHasText = true
}
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
@ -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 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)
ComponentTransition(transition).setAlpha(view: aiButton.icon, alpha: aiButtonAlpha)
}
self.updateTextHeight(animated: animated)
}

View file

@ -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

View file

@ -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

View file

@ -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<Empty>()
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<ViewControllerComponentContainer.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.usernameSection.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<String, UsernameValidationError> {
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<Empty>, 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)

View file

@ -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()

View file

@ -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<PeerInfoAvatarUploadStatus>?) {
/*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<PeerInfoAvatarUploadStatus>?) {
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<TelegramMediaResource?, UploadPeerPhotoError>
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<UpdatePeerPhotoStatus, UploadPeerPhotoError> 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
}
}
})
}
}))*/
}))
}
}

View file

@ -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

View file

@ -41,6 +41,9 @@ swift_library(
"//submodules/Markdown",
"//submodules/TelegramUIPreferences",
"//submodules/ChatSendMessageActionUI",
"//submodules/TextSelectionNode",
"//submodules/Pasteboard",
"//submodules/Speak",
],
visibility = [
"//visibility:public",

View file

@ -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
@ -99,7 +100,7 @@ final class TextProcessingContentComponent: Component {
private var currentContent: (mode: Mode, view: ComponentView<Empty>)?
private var currentMode: Mode = .stylize
private var currentMode: Mode = .translate
override init(frame: CGRect) {
self.currentContentBackground = UIImageView()
@ -204,9 +205,16 @@ 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
self.environment = environment
self.state = state
let sideInset: CGFloat = 16.0
@ -345,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(
@ -357,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(
@ -369,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
}
))
}
@ -1049,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

View file

@ -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<Int>]
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<Int>]
textCorrectionRanges: [Range<Int>],
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<Empty>()
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

View file

@ -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)

View file

@ -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 {

View file

@ -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])
}
}
}

View file

@ -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,

View file

@ -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))
}

View file

@ -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: