import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import LocalizedPeerData import TelegramStringFormatting import TextFormat import Markdown import AccountContext import MoreButtonNode import ContextUI import TranslateUI import TelegramUIPreferences import TelegramNotices import PremiumUI import ComponentFlow import ComponentDisplayAdapters import LocalMediaResources import AppBundle import TranslationLanguagesContextMenuContent final class ChatTranslationPanelNode: ASDisplayNode { private let context: AccountContext private let close: () -> Void private let toggle: () -> Void private let controller: () -> ViewController? private let changeLanguage: (String) -> Void private let addDoNotTranslateLanguage: (String) -> Void private let button: HighlightableButtonNode private let buttonIconNode: ASImageNode private let buttonTextNode: ImmediateTextNode private let moreButton: MoreButtonNode private let closeButton: HighlightableButtonNode private var theme: PresentationTheme? private var currentInfo: TranslateHeaderPanelComponent.Info? init(context: AccountContext, close: @escaping () -> Void, toggle: @escaping () -> Void, changeLanguage: @escaping (String) -> Void, addDoNotTranslateLanguage: @escaping (String) -> Void, controller: @escaping () -> ViewController?) { self.context = context self.close = close self.toggle = toggle self.changeLanguage = changeLanguage self.addDoNotTranslateLanguage = addDoNotTranslateLanguage self.controller = controller self.button = HighlightableButtonNode() self.buttonIconNode = ASImageNode() self.buttonIconNode.displaysAsynchronously = false self.buttonTextNode = ImmediateTextNode() self.buttonTextNode.displaysAsynchronously = false let theme: PresentationTheme = context.sharedContext.currentPresentationData.with { $0 }.theme self.moreButton = MoreButtonNode(theme: theme) self.moreButton.updateColor(theme.chat.inputPanel.panelControlColor, transition: .immediate) self.moreButton.iconNode.enqueueState(.more, animated: false) self.moreButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) self.closeButton = HighlightableButtonNode() self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) self.closeButton.displaysAsynchronously = false super.init() self.clipsToBounds = true self.addSubnode(self.button) self.addSubnode(self.moreButton) self.button.addSubnode(self.buttonIconNode) self.button.addSubnode(self.buttonTextNode) self.button.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: [.touchUpInside]) self.moreButton.action = { [weak self] _, gesture in if let strongSelf = self { strongSelf.morePressed(node: strongSelf.moreButton.contextSourceNode, gesture: gesture) } } self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside]) self.addSubnode(self.closeButton) } func animateOut() { self.layer.animateBounds(from: self.bounds, to: self.bounds.offsetBy(dx: 0.0, dy: self.bounds.size.height), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) } func updateLayout( width: CGFloat, info: TranslateHeaderPanelComponent.Info, theme: PresentationTheme, strings: PresentationStrings, transition: ContainedViewLayoutTransition ) -> CGFloat { let leftInset: CGFloat = 0.0 let rightInset: CGFloat = 0.0 let previousInfo = self.currentInfo self.currentInfo = info var themeUpdated = false if theme !== self.theme { themeUpdated = true self.theme = theme } if themeUpdated { self.buttonIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/Translate"), color: theme.chat.inputPanel.panelControlColor) self.moreButton.theme = theme self.moreButton.updateColor(theme.chat.inputPanel.panelControlColor, transition: .immediate) self.closeButton.setImage(generateImage(CGSize(width: 12.0, height: 12.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setStrokeColor(theme.chat.inputPanel.panelControlColor.cgColor) context.setLineWidth(1.33) context.setLineCap(.round) context.move(to: CGPoint(x: 1.0, y: 1.0)) context.addLine(to: CGPoint(x: size.width - 1.0, y: size.height - 1.0)) context.strokePath() context.move(to: CGPoint(x: size.width - 1.0, y: 1.0)) context.addLine(to: CGPoint(x: 1.0, y: size.height - 1.0)) context.strokePath() }), for: []) } var textUpdated = false if themeUpdated || previousInfo?.isActive != info.isActive { var languageCode = strings.baseLanguageCode let rawSuffix = "-raw" if languageCode.hasSuffix(rawSuffix) { languageCode = String(languageCode.dropLast(rawSuffix.count)) } let toLang = info.toLang ?? languageCode let key = "Translation.Language.\(toLang)" let translateTitle: String if let string = strings.primaryComponent.dict[key] { translateTitle = strings.Conversation_Translation_TranslateTo(string).string } else { let languageLocale = Locale(identifier: languageCode) let toLanguage = languageLocale.localizedString(forLanguageCode: toLang) ?? "" translateTitle = strings.Conversation_Translation_TranslateToOther(toLanguage).string } let buttonText = info.isActive ? strings.Conversation_Translation_ShowOriginal : translateTitle if self.buttonTextNode.attributedText?.string != buttonText { textUpdated = true } self.buttonTextNode.attributedText = NSAttributedString(string: buttonText, font: Font.regular(17.0), textColor: theme.chat.inputPanel.panelControlColor) } let panelHeight: CGFloat = 40.0 let contentRightInset: CGFloat = 11.0 + rightInset var copyTextView: UIView? if textUpdated, transition.isAnimated { if let copyView = self.buttonTextNode.layer.snapshotContentTreeAsView(unhide: false) { copyTextView = copyView self.buttonTextNode.view.superview?.insertSubview(copyView, belowSubview: self.buttonTextNode.view) transition.updateAlpha(layer: copyView.layer, alpha: 0.0, completion: { [weak copyView] _ in copyView?.removeFromSuperview() }) ComponentTransition(transition).setBlur(layer: copyView.layer, radius: 8.0) ComponentTransition(transition).animateBlur(layer: self.buttonTextNode.layer, fromRadius: 8.0, toRadius: 0.0) self.buttonTextNode.alpha = 0.0 transition.updateAlpha(layer: self.buttonTextNode.layer, alpha: 1.0) } } let moreButtonSize = self.moreButton.measure(CGSize(width: 100.0, height: panelHeight)) transition.updateFrame(node: self.moreButton, frame: CGRect(origin: CGPoint(x: width - contentRightInset - moreButtonSize.width, y: floorToScreenPixels((panelHeight - moreButtonSize.height) / 2.0) - 1.0), size: moreButtonSize)) let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0)) self.closeButton.frame = CGRect(origin: CGPoint(x: width - contentRightInset - closeButtonSize.width, y: floorToScreenPixels((panelHeight - closeButtonSize.height) / 2.0)), size: closeButtonSize) if info.isPremium { self.moreButton.isHidden = false self.closeButton.isHidden = true } else { self.moreButton.isHidden = true self.closeButton.isHidden = false } let buttonPadding: CGFloat = 10.0 let buttonSpacing: CGFloat = 10.0 let buttonTextSize = self.buttonTextNode.updateLayout(CGSize(width: width - contentRightInset - moreButtonSize.width, height: panelHeight)) if let icon = self.buttonIconNode.image { let buttonSize = CGSize(width: buttonTextSize.width + icon.size.width + buttonSpacing + buttonPadding * 2.0, height: panelHeight) transition.updateFrame(node: self.button, frame: CGRect(origin: CGPoint(x: leftInset + floorToScreenPixels((width - leftInset - rightInset - buttonSize.width) / 2.0), y: 0.0), size: buttonSize)) transition.updateFrame(node: self.buttonIconNode, frame: CGRect(origin: CGPoint(x: buttonPadding, y: floorToScreenPixels((buttonSize.height - icon.size.height) / 2.0)), size: icon.size)) let buttonTextFrame = CGRect(origin: CGPoint(x: buttonPadding + icon.size.width + buttonSpacing, y: floorToScreenPixels((buttonSize.height - buttonTextSize.height) / 2.0)), size: buttonTextSize) transition.updatePosition(node: self.buttonTextNode, position: buttonTextFrame.center) if let copyTextView { transition.updatePosition(layer: copyTextView.layer, position: buttonTextFrame.center) } self.buttonTextNode.bounds = CGRect(origin: CGPoint(), size: buttonTextFrame.size) } return panelHeight } @objc private func closePressed() { guard let info = self.currentInfo else { return } let isPremium = info.isPremium var translationAvailable = isPremium if case let .channel(channel) = info.peer, channel.flags.contains(.autoTranslateEnabled) { translationAvailable = true } if translationAvailable { self.close() } else if !isPremium { let _ = ApplicationSpecificNotice.incrementTranslationSuggestion(accountManager: self.context.sharedContext.accountManager, count: -100, timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 7).startStandalone() } } @objc private func buttonPressed() { guard let info = self.currentInfo else { return } let isPremium = info.isPremium var translationAvailable = isPremium if case let .channel(channel) = info.peer, channel.flags.contains(.autoTranslateEnabled) { translationAvailable = true } if translationAvailable { self.toggle() } else if !info.isActive { if !isPremium { let context = self.context var replaceImpl: ((ViewController) -> Void)? let controller = PremiumDemoScreen(context: context, subject: .translation, action: { let controller = PremiumIntroScreen(context: context, source: .translation) replaceImpl?(controller) }) replaceImpl = { [weak controller] c in controller?.replace(with: c) } self.controller()?.push(controller) } } } @objc private func morePressed(node: ContextReferenceContentNode, gesture: ContextGesture?) { guard let info = self.currentInfo else { return } let context = self.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } var languageCode = presentationData.strings.baseLanguageCode let rawSuffix = "-raw" if languageCode.hasSuffix(rawSuffix) { languageCode = String(languageCode.dropLast(rawSuffix.count)) } let doNotTranslateTitle: String let fromLang = info.fromLang let key = "Translation.Language.\(fromLang)" if let string = presentationData.strings.primaryComponent.dict[key] { doNotTranslateTitle = presentationData.strings.Conversation_Translation_DoNotTranslate(string).string } else { let languageLocale = Locale(identifier: languageCode) let fromLanguage = languageLocale.localizedString(forLanguageCode: fromLang) ?? "" doNotTranslateTitle = presentationData.strings.Conversation_Translation_DoNotTranslateOther(fromLanguage).string } let items: Signal = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings]) |> take(1) |> map { sharedData -> ContextController.Items in let settings: TranslationSettings if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.translationSettings]?.get(TranslationSettings.self) { settings = current } else { settings = TranslationSettings.defaultSettings } var items: [ContextMenuItem] = [] items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_Translation_ChooseLanguage, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Translate"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in guard let self else { return } var addedLanguages = Set() var topLanguages: [String] = [] let langCode = normalizeTranslationLanguage(languageCode) var selectedLanguages: Set if let ignoredLanguages = settings.ignoredLanguages { selectedLanguages = Set(ignoredLanguages) } else { selectedLanguages = Set([langCode]) for language in systemLanguageCodes() { selectedLanguages.insert(language) } } for code in supportedTranslationLanguages { if selectedLanguages.contains(code) { topLanguages.append(code) } } topLanguages.append("") var languages: [(String, String)] = [] let languageLocale = Locale(identifier: langCode) for code in topLanguages { if !addedLanguages.contains(code) { let displayTitle = languageLocale.localizedString(forLanguageCode: code) ?? "" let value = (code, displayTitle) if code == languageCode { languages.insert(value, at: 0) } else { languages.append(value) } addedLanguages.insert(code) } } for code in supportedTranslationLanguages { if !addedLanguages.contains(code) { let displayTitle = languageLocale.localizedString(forLanguageCode: code) ?? "" let value = (code, displayTitle) if code == languageCode { languages.insert(value, at: 0) } else { languages.append(value) } addedLanguages.insert(code) } } c?.pushItems(items: .single(ContextController.Items( content: .custom( TranslationLanguagesContextMenuContent( context: self.context, languages: languages, back: { [weak c] in c?.popItems() }, selectLanguage: { [weak self, weak c] language in c?.dismiss(completion: { guard let self else { return } self.changeLanguage(language) }) } ) ) ))) }))) items.append(.separator) items.append(.action(ContextMenuActionItem(text: doNotTranslateTitle, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) guard let self, let info = self.currentInfo else { return } self.addDoNotTranslateLanguage(info.fromLang) }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_Translation_Hide, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c?.dismiss(completion: nil) self?.close() }))) items.append(.separator) let cocoonPath = getAppBundle().url(forResource: "Cocoon", withExtension: "tgs")?.path ?? "" let cocoonFile = TelegramMediaFile( fileId: MediaId(namespace: Namespaces.Media.CloudFile, id: -123456789), partialReference: nil, resource: BundleResource(name: "Cocoon", path: cocoonPath), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/x-tgsticker", size: nil, attributes: [ .FileName(fileName: "sticker.tgs"), .CustomEmoji(isPremium: false, isSingleColor: true, alt: "", packReference: .animatedEmojiAnimations) ], alternativeRepresentations: [] ) let (cocoonText, entities) = parseCocoonMenuTextEntities(presentationData.strings.Conversation_Translation_CocoonInfo, emojiFileId: cocoonFile.fileId.id) items.append(.action(ContextMenuActionItem(text: cocoonText, entities: entities, entityFiles: [cocoonFile.fileId.id: cocoonFile], enableEntityAnimations: true, textLayout: .multiline, textFont: .small, icon: { _ in return nil }, action: { [weak self] c, _ in c?.dismiss(completion: nil) if let controller = self?.controller() { let infoController = context.sharedContext.makeCocoonInfoScreen(context: context) controller.push(infoController) } }))) return ContextController.Items(content: .list(items)) } if let controller = self.controller() { let contextController = makeContextController(context: context, presentationData: presentationData, source: .reference(TranslationContextReferenceContentSource(controller: controller, sourceNode: node)), items: items, gesture: gesture) controller.presentInGlobalOverlay(contextController) } } } private final class TranslationContextReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController private let sourceNode: ContextReferenceContentNode var keepInPlace: Bool { return true } init(controller: ViewController, sourceNode: ContextReferenceContentNode) { self.controller = controller self.sourceNode = sourceNode } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) } } private func parseCocoonMenuTextEntities(_ input: String, emojiFileId: Int64) -> (String, [MessageTextEntity]) { var output = "" var entities: [MessageTextEntity] = [] var i = input.startIndex var outputCount = 0 func utf16Len(_ s: String) -> Int { s.utf16.count } func peek(_ offset: Int) -> Character? { var idx = i for _ in 0.. start { entities.append(MessageTextEntity(range: start.. start { entities.append(MessageTextEntity(range: start..