Various improvements

This commit is contained in:
Ilya Laktyushin
2022-12-17 15:35:00 +04:00
parent aeafae62df
commit 77df1cf45a
182 changed files with 6119 additions and 5449 deletions

View File

@@ -0,0 +1,45 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ChatEntityKeyboardInputNode",
module_name = "ChatEntityKeyboardInputNode",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/AccountContext:AccountContext",
"//submodules/ChatPresentationInterfaceState:ChatPresentationInterfaceState",
"//submodules/ComponentFlow:ComponentFlow",
"//submodules/Components/ComponentDisplayAdapters:ComponentDisplayAdapters",
"//submodules/TelegramUI/Components/EntityKeyboard:EntityKeyboard",
"//submodules/TelegramUI/Components/AnimationCache:AnimationCache",
"//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer",
"//submodules/TextFormat:TextFormat",
"//submodules/AppBundle:AppBundle",
"//submodules/TelegramUI/Components/ChatInputNode:ChatInputNode",
"//submodules/Components/PagerComponent:PagerComponent",
"//submodules/PremiumUI:PremiumUI",
"//submodules/UndoUI:UndoUI",
"//submodules/ContextUI:ContextUI",
"//submodules/GalleryUI:GalleryUI",
"//submodules/AttachmentTextInputPanelNode:AttachmentTextInputPanelNode",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramNotices:TelegramNotices",
"//submodules/StickerPeekUI:StickerPeekUI",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/TelegramUI/Components/MultiplexedVideoNode:MultiplexedVideoNode",
"//submodules/TelegramUI/Components/ChatControllerInteraction:ChatControllerInteraction",
"//submodules/FeaturedStickersScreen:FeaturedStickersScreen",
],
visibility = [
"//visibility:public",
],
)

View File

@@ -0,0 +1,279 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import AppBundle
import ChatControllerInteraction
import MultiplexedVideoNode
import ChatPresentationInterfaceState
final class GifPaneSearchContentNode: ASDisplayNode & PaneSearchContentNode {
private let context: AccountContext
private let controllerInteraction: ChatControllerInteraction
private let inputNodeInteraction: ChatMediaInputNodeInteraction
private var theme: PresentationTheme
private var strings: PresentationStrings
private var multiplexedNode: MultiplexedVideoNode?
private let notFoundNode: ASImageNode
private let notFoundLabel: ImmediateTextNode
private var nextOffset: (String, String)?
private var isLoadingNextResults: Bool = false
private var validLayout: CGSize?
private let trendingPromise: Promise<ChatMediaInputGifPaneTrendingState?>
private let searchDisposable = MetaDisposable()
private let _ready = Promise<Void>()
var ready: Signal<Void, NoError> {
return self._ready.get()
}
var deactivateSearchBar: (() -> Void)?
var updateActivity: ((Bool) -> Void)?
var requestUpdateQuery: ((String) -> Void)?
var openGifContextMenu: ((MultiplexedVideoNodeFile, ASDisplayNode, CGRect, ContextGesture, Bool) -> Void)?
private var hasInitialText = false
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: ChatControllerInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction, trendingPromise: Promise<ChatMediaInputGifPaneTrendingState?>) {
self.context = context
self.controllerInteraction = controllerInteraction
self.inputNodeInteraction = inputNodeInteraction
self.trendingPromise = trendingPromise
self.theme = theme
self.strings = strings
self.notFoundNode = ASImageNode()
self.notFoundNode.displayWithoutProcessing = true
self.notFoundNode.displaysAsynchronously = false
self.notFoundNode.clipsToBounds = false
self.notFoundLabel = ImmediateTextNode()
self.notFoundLabel.displaysAsynchronously = false
self.notFoundLabel.isUserInteractionEnabled = false
self.notFoundNode.addSubnode(self.notFoundLabel)
super.init()
self.notFoundNode.isHidden = true
self._ready.set(.single(Void()))
self.addSubnode(self.notFoundNode)
self.updateThemeAndStrings(theme: theme, strings: strings)
}
deinit {
self.searchDisposable.dispose()
}
func updateText(_ text: String, languageCode: String?) {
self.hasInitialText = true
self.isLoadingNextResults = true
let signal: Signal<([MultiplexedVideoNodeFile], String?)?, NoError>
if !text.isEmpty {
signal = paneGifSearchForQuery(context: self.context, query: text, offset: "", updateActivity: self.updateActivity)
|> map { result -> ([MultiplexedVideoNodeFile], String?)? in
if let result = result {
return (result.files, result.nextOffset)
} else {
return nil
}
}
self.updateActivity?(true)
} else {
signal = self.trendingPromise.get()
|> map { items -> ([MultiplexedVideoNodeFile], String?)? in
if let items = items {
return (items.files, nil)
} else {
return nil
}
}
self.updateActivity?(false)
}
self.searchDisposable.set((signal
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let strongSelf = self, let (result, nextOffset) = result else {
return
}
strongSelf.isLoadingNextResults = false
if let nextOffset = nextOffset {
strongSelf.nextOffset = (text, nextOffset)
} else {
strongSelf.nextOffset = nil
}
strongSelf.multiplexedNode?.setFiles(files: MultiplexedVideoNodeFiles(saved: [], trending: result, isSearch: true, canLoadMore: false, isStale: false), synchronous: true, resetScrollingToOffset: nil)
strongSelf.updateActivity?(false)
strongSelf.notFoundNode.isHidden = text.isEmpty || !result.isEmpty
}))
}
private func loadMore() {
if self.isLoadingNextResults {
return
}
guard let (text, nextOffsetValue) = self.nextOffset else {
return
}
self.isLoadingNextResults = true
let signal: Signal<([MultiplexedVideoNodeFile], String?)?, NoError>
signal = paneGifSearchForQuery(context: self.context, query: text, offset: nextOffsetValue, updateActivity: self.updateActivity)
|> map { result -> ([MultiplexedVideoNodeFile], String?)? in
if let result = result {
return (result.files, result.nextOffset)
} else {
return nil
}
}
self.searchDisposable.set((signal
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let strongSelf = self, let (result, nextOffset) = result else {
return
}
var files = strongSelf.multiplexedNode?.files.trending ?? []
var currentIds = Set(files.map { $0.file.media.fileId })
for item in result {
if currentIds.contains(item.file.media.fileId) {
continue
}
currentIds.insert(item.file.media.fileId)
files.append(item)
}
strongSelf.isLoadingNextResults = false
if let nextOffset = nextOffset {
strongSelf.nextOffset = (text, nextOffset)
} else {
strongSelf.nextOffset = nil
}
strongSelf.multiplexedNode?.setFiles(files: MultiplexedVideoNodeFiles(saved: [], trending: files, isSearch: true, canLoadMore: false, isStale: false), synchronous: true, resetScrollingToOffset: nil)
strongSelf.notFoundNode.isHidden = text.isEmpty || !files.isEmpty
}))
}
func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
self.notFoundNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/GifsNotFoundIcon"), color: theme.list.freeMonoIconColor)
self.notFoundLabel.attributedText = NSAttributedString(string: strings.Gif_NoGifsFound, font: Font.medium(14.0), textColor: theme.list.freeTextColor)
}
func updatePreviewing(animated: Bool) {
}
func itemAt(point: CGPoint) -> (ASDisplayNode, Any)? {
if let multiplexedNode = self.multiplexedNode, let file = multiplexedNode.fileAt(point: point.offsetBy(dx: -multiplexedNode.frame.minX, dy: -multiplexedNode.frame.minY)) {
return (self, file)
} else {
return nil
}
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) {
let firstLayout = self.validLayout == nil
self.validLayout = size
if let image = self.notFoundNode.image {
let areaHeight = size.height - inputHeight
let labelSize = self.notFoundLabel.updateLayout(CGSize(width: size.width, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.notFoundNode, frame: CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((areaHeight - image.size.height - labelSize.height) / 2.0)), size: image.size))
transition.updateFrame(node: self.notFoundLabel, frame: CGRect(origin: CGPoint(x: floor((image.size.width - labelSize.width) / 2.0), y: image.size.height + 8.0), size: labelSize))
}
if let multiplexedNode = self.multiplexedNode {
multiplexedNode.topInset = 0.0
multiplexedNode.bottomInset = 0.0
let nodeFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))
transition.updateFrame(layer: multiplexedNode.layer, frame: nodeFrame)
multiplexedNode.updateLayout(theme: self.theme, strings: self.strings, size: nodeFrame.size, transition: transition)
}
if firstLayout && !self.hasInitialText {
self.updateText("", languageCode: nil)
}
}
override func willEnterHierarchy() {
super.willEnterHierarchy()
if self.multiplexedNode == nil {
let multiplexedNode = MultiplexedVideoNode(account: self.context.account, theme: self.theme, strings: self.strings)
self.multiplexedNode = multiplexedNode
if let layout = self.validLayout {
multiplexedNode.frame = CGRect(origin: CGPoint(), size: layout)
}
self.addSubnode(multiplexedNode)
multiplexedNode.fileSelected = { [weak self] file, sourceNode, sourceRect in
if let (collection, result) = file.contextResult {
let _ = self?.controllerInteraction.sendBotContextResultAsGif(collection, result, sourceNode.view, sourceRect, false)
} else {
let _ = self?.controllerInteraction.sendGif(file.file, sourceNode.view, sourceRect, false, false)
}
}
multiplexedNode.fileContextMenu = { [weak self] fileReference, sourceNode, sourceRect, gesture, isSaved in
self?.openGifContextMenu?(fileReference, sourceNode, sourceRect, gesture, isSaved)
}
multiplexedNode.didScroll = { [weak self] offset, height in
guard let strongSelf = self, let multiplexedNode = strongSelf.multiplexedNode else {
return
}
strongSelf.deactivateSearchBar?()
if offset >= height - multiplexedNode.bounds.height - 200.0 {
strongSelf.loadMore()
}
}
multiplexedNode.reactionSelected = { [weak self] reaction in
self?.requestUpdateQuery?(reaction)
}
}
}
func animateIn(additivePosition: CGFloat, transition: ContainedViewLayoutTransition) {
guard let multiplexedNode = self.multiplexedNode else {
return
}
multiplexedNode.alpha = 0.0
transition.updateAlpha(layer: multiplexedNode.layer, alpha: 1.0, completion: { _ in
})
if case let .animated(duration, curve) = transition {
multiplexedNode.layer.animatePosition(from: CGPoint(x: 0.0, y: additivePosition), to: CGPoint(), duration: duration, timingFunction: curve.timingFunction, additive: true)
}
}
func animateOut(transition: ContainedViewLayoutTransition) {
guard let multiplexedNode = self.multiplexedNode else {
return
}
transition.updateAlpha(layer: multiplexedNode.layer, alpha: 0.0, completion: { _ in
})
}
}

View File

@@ -0,0 +1,500 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ActivityIndicator
import AppBundle
import FeaturedStickersScreen
private func generateLoupeIcon(color: UIColor) -> UIImage? {
return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Loupe"), color: color)
}
private func generateClearIcon(color: UIColor) -> UIImage? {
return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: color)
}
private func generateBackground(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? {
let diameter: CGFloat = 10.0
return generateImage(CGSize(width: diameter, height: diameter), contextGenerator: { size, context in
context.setFillColor(backgroundColor.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
context.setFillColor(foregroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
}, opaque: true)?.stretchableImage(withLeftCapWidth: Int(diameter / 2.0), topCapHeight: Int(diameter / 2.0))
}
private class PaneSearchBarTextField: UITextField {
public var didDeleteBackwardWhileEmpty: (() -> Void)?
let placeholderLabel: ImmediateTextNode
var placeholderString: NSAttributedString? {
didSet {
self.placeholderLabel.attributedText = self.placeholderString
}
}
let prefixLabel: ASTextNode
var prefixString: NSAttributedString? {
didSet {
self.prefixLabel.attributedText = self.prefixString
}
}
override init(frame: CGRect) {
self.placeholderLabel = ImmediateTextNode()
self.placeholderLabel.isUserInteractionEnabled = false
self.placeholderLabel.displaysAsynchronously = false
self.placeholderLabel.maximumNumberOfLines = 1
self.prefixLabel = ASTextNode()
self.prefixLabel.isUserInteractionEnabled = false
self.prefixLabel.displaysAsynchronously = false
super.init(frame: frame)
self.addSubnode(self.placeholderLabel)
self.addSubnode(self.prefixLabel)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var keyboardAppearance: UIKeyboardAppearance {
get {
return super.keyboardAppearance
}
set {
let resigning = self.isFirstResponder
if resigning {
self.resignFirstResponder()
}
super.keyboardAppearance = newValue
if resigning {
self.becomeFirstResponder()
}
}
}
override func textRect(forBounds bounds: CGRect) -> CGRect {
if bounds.size.width.isZero {
return CGRect(origin: CGPoint(), size: CGSize())
}
var rect = bounds.insetBy(dx: 4.0, dy: 4.0)
let prefixSize = self.prefixLabel.measure(bounds.size)
if !prefixSize.width.isZero {
let prefixOffset = prefixSize.width
rect.origin.x += prefixOffset
rect.size.width -= prefixOffset
}
return rect
}
override func editingRect(forBounds bounds: CGRect) -> CGRect {
return self.textRect(forBounds: bounds)
}
override func layoutSubviews() {
super.layoutSubviews()
let bounds = self.bounds
if bounds.size.width.isZero {
return
}
let constrainedSize = self.textRect(forBounds: self.bounds).size
let labelSize = self.placeholderLabel.updateLayout(constrainedSize)
self.placeholderLabel.frame = CGRect(origin: CGPoint(x: self.textRect(forBounds: bounds).minX, y: self.textRect(forBounds: bounds).minY + 4.0), size: labelSize)
let prefixSize = self.prefixLabel.measure(constrainedSize)
let prefixBounds = bounds.insetBy(dx: 4.0, dy: 4.0)
self.prefixLabel.frame = CGRect(origin: CGPoint(x: prefixBounds.minX, y: prefixBounds.minY + 1.0), size: prefixSize)
}
override func deleteBackward() {
if self.text == nil || self.text!.isEmpty {
self.didDeleteBackwardWhileEmpty?()
}
super.deleteBackward()
}
}
class PaneSearchBarNode: ASDisplayNode, UITextFieldDelegate {
var cancel: (() -> Void)?
var textUpdated: ((String, String) -> Void)?
var clearPrefix: (() -> Void)?
private let backgroundNode: ASDisplayNode
private let separatorNode: ASDisplayNode
private let textBackgroundNode: ASImageNode
private var activityIndicator: ActivityIndicator?
private let iconNode: ASImageNode
private let textField: PaneSearchBarTextField
private let clearButton: HighlightableButtonNode
private let cancelButton: ASButtonNode
var placeholderString: NSAttributedString? {
get {
return self.textField.placeholderString
} set(value) {
self.textField.placeholderString = value
}
}
var prefixString: NSAttributedString? {
get {
return self.textField.prefixString
} set(value) {
let previous = self.prefixString
let updated: Bool
if let previous = previous, let value = value {
updated = !previous.isEqual(to: value)
} else {
updated = (previous != nil) != (value != nil)
}
if updated {
self.textField.prefixString = value
self.textField.setNeedsLayout()
self.updateIsEmpty()
}
}
}
var text: String {
get {
return self.textField.text ?? ""
} set(value) {
if self.textField.text ?? "" != value {
self.textField.text = value
self.textFieldDidChange(self.textField)
}
}
}
var activity: Bool = false {
didSet {
if self.activity != oldValue {
if self.activity {
if self.activityIndicator == nil, let theme = self.theme {
let activityIndicator = ActivityIndicator(type: .custom(theme.chat.inputMediaPanel.stickersSearchControlColor, 13.0, 1.0, false))
self.activityIndicator = activityIndicator
self.addSubnode(activityIndicator)
if let (boundingSize, leftInset, rightInset) = self.validLayout {
self.updateLayout(boundingSize: boundingSize, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
}
}
} else if let activityIndicator = self.activityIndicator {
self.activityIndicator = nil
activityIndicator.removeFromSupernode()
}
self.iconNode.isHidden = self.activity
}
}
}
private var validLayout: (CGSize, CGFloat, CGFloat)?
private var theme: PresentationTheme?
override init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.textBackgroundNode = ASImageNode()
self.textBackgroundNode.isLayerBacked = false
self.textBackgroundNode.displaysAsynchronously = false
self.textBackgroundNode.displayWithoutProcessing = true
self.iconNode = ASImageNode()
self.iconNode.isUserInteractionEnabled = false
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.textField = PaneSearchBarTextField()
self.textField.accessibilityTraits = .searchField
self.textField.autocorrectionType = .no
self.textField.returnKeyType = .search
self.textField.font = Font.regular(17.0)
self.clearButton = HighlightableButtonNode()
self.clearButton.imageNode.displaysAsynchronously = false
self.clearButton.imageNode.displayWithoutProcessing = true
self.clearButton.displaysAsynchronously = false
self.clearButton.isHidden = true
self.cancelButton = ASButtonNode()
self.cancelButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.cancelButton.displaysAsynchronously = false
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.textBackgroundNode)
self.view.addSubview(self.textField)
self.addSubnode(self.iconNode)
self.addSubnode(self.clearButton)
self.addSubnode(self.cancelButton)
self.textField.delegate = self
self.textField.addTarget(self, action: #selector(self.textFieldDidChange(_:)), for: .editingChanged)
self.textField.didDeleteBackwardWhileEmpty = { [weak self] in
self?.clearPressed()
}
self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside)
self.clearButton.addTarget(self, action: #selector(self.clearPressed), forControlEvents: .touchUpInside)
}
func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
self.theme = theme
if let activityIndicator = self.activityIndicator {
activityIndicator.type = .custom(theme.chat.inputMediaPanel.stickersSearchControlColor, 13.0, 1.0, false)
}
self.separatorNode.backgroundColor = theme.chat.inputMediaPanel.panelSeparatorColor
self.textBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 36.0, color: theme.chat.inputMediaPanel.stickersSearchBackgroundColor)
self.textField.textColor = theme.chat.inputMediaPanel.stickersSearchPrimaryColor
self.iconNode.image = generateLoupeIcon(color: theme.chat.inputMediaPanel.stickersSearchControlColor)
self.clearButton.setImage(generateClearIcon(color: theme.chat.inputMediaPanel.stickersSearchControlColor), for: [])
self.cancelButton.setAttributedTitle(NSAttributedString(string: strings.Common_Cancel, font: Font.regular(17.0), textColor: theme.chat.inputPanel.panelControlAccentColor), for: [])
self.textField.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance
self.textField.tintColor = theme.list.itemAccentColor
if let (boundingSize, leftInset, rightInset) = self.validLayout {
self.updateLayout(boundingSize: boundingSize, leftInset: leftInset, rightInset: rightInset, transition: .immediate)
}
}
func updateLayout(boundingSize: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = (boundingSize, leftInset, rightInset)
self.backgroundNode.frame = self.bounds
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: self.bounds.size.height), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel)))
let verticalOffset: CGFloat = -20.0
let contentFrame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: boundingSize.width - leftInset - rightInset, height: boundingSize.height))
let cancelButtonSize = self.cancelButton.measure(CGSize(width: 100.0, height: CGFloat.infinity))
transition.updateFrame(node: self.cancelButton, frame: CGRect(origin: CGPoint(x: contentFrame.maxX - 8.0 - cancelButtonSize.width, y: verticalOffset + 34.0), size: cancelButtonSize))
let textBackgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX + 8.0, y: verticalOffset + 28.0), size: CGSize(width: contentFrame.width - 16.0 - cancelButtonSize.width - 11.0, height: 36.0))
transition.updateFrame(node: self.textBackgroundNode, frame: textBackgroundFrame)
let textFrame = CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 27.0, y: textBackgroundFrame.minY), size: CGSize(width: max(1.0, textBackgroundFrame.size.width - 27.0 - 20.0), height: textBackgroundFrame.size.height))
if let iconImage = self.iconNode.image {
let iconSize = iconImage.size
transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 5.0, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - iconSize.height) / 2.0)), size: iconSize))
}
if let activityIndicator = self.activityIndicator {
let indicatorSize = activityIndicator.measure(CGSize(width: 32.0, height: 32.0))
transition.updateFrame(node: activityIndicator, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 11.0, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - indicatorSize.height) / 2.0)), size: indicatorSize))
}
let clearSize = self.clearButton.measure(CGSize(width: 100.0, height: 100.0))
transition.updateFrame(node: self.clearButton, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.maxX - 8.0 - clearSize.width, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - clearSize.height) / 2.0)), size: clearSize))
self.textField.frame = textFrame
self.textField.layoutSubviews()
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
if let cancel = self.cancel {
cancel()
}
}
}
func activate() {
self.textField.becomeFirstResponder()
}
func animateIn(from node: PaneSearchBarPlaceholderNode, duration: Double, timingFunction: String, completion: @escaping () -> Void) {
let initialTextBackgroundFrame = node.view.convert(node.backgroundNode.frame, to: self.view)
var backgroundCompleted = false
var separatorCompleted = false
var textBackgroundCompleted = false
let intermediateCompletion: () -> Void = {
if backgroundCompleted && separatorCompleted && textBackgroundCompleted {
completion()
}
}
let initialBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.size.width, height: max(0.0, initialTextBackgroundFrame.maxY + 8.0)))
if let fromBackgroundColor = node.backgroundColor, let toBackgroundColor = self.backgroundNode.backgroundColor {
self.backgroundNode.layer.animate(from: fromBackgroundColor.cgColor, to: toBackgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration * 0.7)
} else {
self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration)
}
self.backgroundNode.layer.animateFrame(from: initialBackgroundFrame, to: self.backgroundNode.frame, duration: duration, timingFunction: timingFunction, completion: { _ in
backgroundCompleted = true
intermediateCompletion()
})
let initialSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, initialTextBackgroundFrame.maxY + 8.0)), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel))
self.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration)
self.separatorNode.layer.animateFrame(from: initialSeparatorFrame, to: self.separatorNode.frame, duration: duration, timingFunction: timingFunction, completion: { _ in
separatorCompleted = true
intermediateCompletion()
})
self.textBackgroundNode.layer.animateFrame(from: initialTextBackgroundFrame, to: self.textBackgroundNode.frame, duration: duration, timingFunction: timingFunction, completion: { _ in
textBackgroundCompleted = true
intermediateCompletion()
})
let labelFrame = self.textField.placeholderLabel.frame
let initialLabelNodeFrame = CGRect(origin: node.labelNode.view.convert(node.labelNode.bounds, to: self.textField.superview).origin, size: labelFrame.size)
self.textField.layer.animateFrame(from: CGRect(origin: initialLabelNodeFrame.origin.offsetBy(dx: -labelFrame.minX, dy: -labelFrame.minY), size: self.textField.frame.size), to: self.textField.frame, duration: duration, timingFunction: timingFunction)
let iconFrame = self.iconNode.frame
let initialIconFrame = CGRect(origin: node.iconNode.view.convert(node.iconNode.bounds, to: self.iconNode.view.superview).origin, size: iconFrame.size)
self.iconNode.layer.animateFrame(from: initialIconFrame, to: self.iconNode.frame, duration: duration, timingFunction: timingFunction)
let cancelButtonFrame = self.cancelButton.frame
self.cancelButton.layer.animatePosition(from: CGPoint(x: self.bounds.size.width + cancelButtonFrame.size.width / 2.0, y: initialTextBackgroundFrame.minY + 2.0 + cancelButtonFrame.size.height / 2.0), to: self.cancelButton.layer.position, duration: duration, timingFunction: timingFunction)
node.isHidden = true
}
func deactivate(clear: Bool = true) {
self.textField.resignFirstResponder()
if clear {
self.textField.text = nil
self.textField.placeholderLabel.isHidden = false
}
}
func transitionOut(to node: PaneSearchBarPlaceholderNode, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
let targetTextBackgroundFrame = node.view.convert(node.backgroundNode.view.frame, to: self.view)
let duration: Double = 0.5
let timingFunction = kCAMediaTimingFunctionSpring
node.isHidden = true
self.clearButton.isHidden = true
self.textField.text = ""
var backgroundCompleted = false
var separatorCompleted = false
var textBackgroundCompleted = false
let intermediateCompletion: () -> Void = { [weak node] in
if backgroundCompleted && separatorCompleted && textBackgroundCompleted {
completion()
node?.isHidden = false
}
}
let targetBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.size.width, height: max(0.0, targetTextBackgroundFrame.maxY + 8.0)))
if let toBackgroundColor = node.backgroundColor, let fromBackgroundColor = self.backgroundNode.backgroundColor {
self.backgroundNode.layer.animate(from: fromBackgroundColor.cgColor, to: toBackgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration * 0.5, removeOnCompletion: false)
} else {
self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration / 2.0, removeOnCompletion: false)
}
self.backgroundNode.layer.animateFrame(from: self.backgroundNode.frame, to: targetBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in
backgroundCompleted = true
intermediateCompletion()
})
let targetSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, targetTextBackgroundFrame.maxY + 8.0)), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel))
self.separatorNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration / 2.0, removeOnCompletion: false)
self.separatorNode.layer.animateFrame(from: self.separatorNode.frame, to: targetSeparatorFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in
separatorCompleted = true
intermediateCompletion()
})
self.textBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in
textBackgroundCompleted = true
intermediateCompletion()
})
let transitionBackgroundNode = ASImageNode()
transitionBackgroundNode.isLayerBacked = true
transitionBackgroundNode.displaysAsynchronously = false
transitionBackgroundNode.displayWithoutProcessing = true
transitionBackgroundNode.image = node.backgroundNode.image
self.insertSubnode(transitionBackgroundNode, aboveSubnode: self.textBackgroundNode)
transitionBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration / 2.0, removeOnCompletion: false)
transitionBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
let textFieldFrame = self.textField.frame
let targetLabelNodeFrame = CGRect(origin: node.labelNode.view.convert(node.labelNode.bounds, to: self.textField.superview).origin, size: textFieldFrame.size)
self.textField.layer.animateFrame(from: self.textField.frame, to: CGRect(origin: targetLabelNodeFrame.origin.offsetBy(dx: -self.textField.placeholderLabel.frame.minX, dy: -self.textField.placeholderLabel.frame.minY), size: self.textField.frame.size), duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
if let snapshot = node.labelNode.layer.snapshotContentTree() {
snapshot.frame = CGRect(origin: self.textField.placeholderLabel.frame.origin, size: node.labelNode.frame.size)
self.textField.layer.addSublayer(snapshot)
snapshot.animateAlpha(from: 0.0, to: 1.0, duration: duration * 2.0 / 3.0, timingFunction: CAMediaTimingFunctionName.linear.rawValue)
//self.textField.placeholderLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 3.0 / 2.0, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false)
}
let iconFrame = self.iconNode.frame
let targetIconFrame = CGRect(origin: node.iconNode.view.convert(node.iconNode.bounds, to: self.iconNode.view.superview).origin, size: iconFrame.size)
self.iconNode.image = node.iconNode.image
self.iconNode.layer.animateFrame(from: self.iconNode.frame, to: targetIconFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
let cancelButtonFrame = self.cancelButton.frame
self.cancelButton.layer.animatePosition(from: self.cancelButton.layer.position, to: CGPoint(x: self.bounds.size.width + cancelButtonFrame.size.width / 2.0, y: targetTextBackgroundFrame.minY + 2.0 + cancelButtonFrame.size.height / 2.0), duration: duration, timingFunction: timingFunction, removeOnCompletion: false)
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if string.range(of: "\n") != nil {
return false
}
return true
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
self.textField.resignFirstResponder()
return false
}
@objc func textFieldDidChange(_ textField: UITextField) {
self.updateIsEmpty()
if let textUpdated = self.textUpdated {
textUpdated(textField.text ?? "", self.textField.textInputMode?.primaryLanguage ?? "")
}
}
private func updateIsEmpty() {
let isEmpty = !(textField.text?.isEmpty ?? true)
if isEmpty != self.textField.placeholderLabel.isHidden {
self.textField.placeholderLabel.isHidden = isEmpty
}
self.clearButton.isHidden = !isEmpty && self.prefixString == nil
}
@objc func cancelPressed() {
if let cancel = self.cancel {
cancel()
}
}
@objc func clearPressed() {
if (self.textField.text?.isEmpty ?? true) {
if self.prefixString != nil {
self.clearPrefix?()
}
} else {
self.textField.text = ""
self.textFieldDidChange(self.textField)
}
}
func updateQuery(_ query: String) {
self.textField.text = query
self.textFieldDidChange(self.textField)
}
}

View File

@@ -0,0 +1,192 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import AccountContext
import ChatPresentationInterfaceState
import EntityKeyboard
import ChatControllerInteraction
import MultiplexedVideoNode
import FeaturedStickersScreen
private let searchBarHeight: CGFloat = 52.0
public protocol PaneSearchContentNode {
var ready: Signal<Void, NoError> { get }
var deactivateSearchBar: (() -> Void)? { get set }
var updateActivity: ((Bool) -> Void)? { get set }
func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings)
func updateText(_ text: String, languageCode: String?)
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition)
func animateIn(additivePosition: CGFloat, transition: ContainedViewLayoutTransition)
func animateOut(transition: ContainedViewLayoutTransition)
func updatePreviewing(animated: Bool)
func itemAt(point: CGPoint) -> (ASDisplayNode, Any)?
}
public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainerNode {
private let context: AccountContext
private let mode: ChatMediaInputSearchMode
public private(set) var contentNode: PaneSearchContentNode & ASDisplayNode
private let controllerInteraction: ChatControllerInteraction
private let inputNodeInteraction: ChatMediaInputNodeInteraction
private let backgroundNode: ASDisplayNode
private let searchBar: PaneSearchBarNode
private var validLayout: CGSize?
public var onCancel: (() -> Void)?
public var openGifContextMenu: ((MultiplexedVideoNodeFile, ASDisplayNode, CGRect, ContextGesture, Bool) -> Void)?
public var ready: Signal<Void, NoError> {
return self.contentNode.ready
}
public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: ChatControllerInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction, mode: ChatMediaInputSearchMode, trendingGifsPromise: Promise<ChatMediaInputGifPaneTrendingState?>, cancel: @escaping () -> Void) {
self.context = context
self.mode = mode
self.controllerInteraction = controllerInteraction
self.inputNodeInteraction = inputNodeInteraction
switch mode {
case .gif:
self.contentNode = GifPaneSearchContentNode(context: context, theme: theme, strings: strings, controllerInteraction: controllerInteraction, inputNodeInteraction: inputNodeInteraction, trendingPromise: trendingGifsPromise)
case .sticker, .trending:
self.contentNode = StickerPaneSearchContentNode(context: context, theme: theme, strings: strings, controllerInteraction: controllerInteraction, inputNodeInteraction: inputNodeInteraction)
}
self.backgroundNode = ASDisplayNode()
self.searchBar = PaneSearchBarNode()
super.init()
self.clipsToBounds = true
self.addSubnode(self.backgroundNode)
self.addSubnode(self.contentNode)
self.addSubnode(self.searchBar)
self.contentNode.deactivateSearchBar = { [weak self] in
self?.searchBar.deactivate(clear: false)
}
self.contentNode.updateActivity = { [weak self] active in
self?.searchBar.activity = active
}
self.searchBar.cancel = { [weak self] in
cancel()
self?.searchBar.view.endEditing(true)
self?.onCancel?()
}
self.searchBar.activate()
self.searchBar.textUpdated = { [weak self] text, languageCode in
self?.contentNode.updateText(text, languageCode: languageCode)
}
self.updateThemeAndStrings(theme: theme, strings: strings)
if let contentNode = self.contentNode as? GifPaneSearchContentNode {
contentNode.requestUpdateQuery = { [weak self] query in
self?.updateQuery(query)
}
contentNode.openGifContextMenu = { [weak self] file, node, rect, gesture, isSaved in
self?.openGifContextMenu?(file, node, rect, gesture, isSaved)
}
}
}
public func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
self.backgroundNode.backgroundColor = theme.chat.inputMediaPanel.stickersBackgroundColor.withAlphaComponent(1.0)
self.contentNode.updateThemeAndStrings(theme: theme, strings: strings)
self.searchBar.updateThemeAndStrings(theme: theme, strings: strings)
let placeholder: String
switch mode {
case .gif:
placeholder = strings.Gif_Search
case .sticker, .trending:
placeholder = strings.Stickers_Search
}
self.searchBar.placeholderString = NSAttributedString(string: placeholder, font: Font.regular(17.0), textColor: theme.chat.inputMediaPanel.stickersSearchPlaceholderColor)
}
public func updateQuery(_ query: String) {
self.searchBar.updateQuery(query)
}
public func itemAt(point: CGPoint) -> (ASDisplayNode, Any)? {
return self.contentNode.itemAt(point: CGPoint(x: point.x, y: point.y - searchBarHeight))
}
public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) {
self.validLayout = size
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size))
transition.updateFrame(node: self.searchBar, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: searchBarHeight)))
self.searchBar.updateLayout(boundingSize: CGSize(width: size.width, height: searchBarHeight), leftInset: leftInset, rightInset: rightInset, transition: transition)
let contentFrame = CGRect(origin: CGPoint(x: leftInset, y: searchBarHeight), size: CGSize(width: size.width - leftInset - rightInset, height: size.height - searchBarHeight))
transition.updateFrame(node: self.contentNode, frame: contentFrame)
self.contentNode.updateLayout(size: contentFrame.size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, inputHeight: inputHeight, deviceMetrics: deviceMetrics, transition: transition)
}
public func deactivate() {
self.searchBar.deactivate(clear: true)
}
public func animateIn(from placeholder: PaneSearchBarPlaceholderNode?, anchorTop: CGPoint, anhorTopView: UIView, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
var verticalOrigin: CGFloat = anhorTopView.convert(anchorTop, to: self.view).y
if let placeholder = placeholder {
let placeholderFrame = placeholder.view.convert(placeholder.bounds, to: self.view)
verticalOrigin = placeholderFrame.minY - 4.0
self.contentNode.animateIn(additivePosition: verticalOrigin, transition: transition)
} else {
self.contentNode.animateIn(additivePosition: 0.0, transition: transition)
}
switch transition {
case let .animated(duration, curve):
self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration / 2.0)
if let placeholder = placeholder {
self.searchBar.animateIn(from: placeholder, duration: duration, timingFunction: curve.timingFunction, completion: completion)
} else {
self.searchBar.alpha = 0.0
transition.updateAlpha(node: self.searchBar, alpha: 1.0)
}
if let size = self.validLayout {
let initialBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: size.width, height: max(0.0, size.height - verticalOrigin)))
self.backgroundNode.layer.animateFrame(from: initialBackgroundFrame, to: self.backgroundNode.frame, duration: duration, timingFunction: curve.timingFunction)
}
case .immediate:
break
}
}
public func animateOut(to placeholder: PaneSearchBarPlaceholderNode, animateOutSearchBar: Bool, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
if case let .animated(duration, curve) = transition {
if let size = self.validLayout {
let placeholderFrame = placeholder.view.convert(placeholder.bounds, to: self.view)
let verticalOrigin = placeholderFrame.minY - 4.0
self.backgroundNode.layer.animateFrame(from: self.backgroundNode.frame, to: CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: size.width, height: max(0.0, size.height - verticalOrigin))), duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false)
}
}
self.searchBar.transitionOut(to: placeholder, transition: transition, completion: completion)
transition.updateAlpha(node: self.backgroundNode, alpha: 0.0)
if animateOutSearchBar {
transition.updateAlpha(node: self.searchBar, alpha: 0.0)
}
self.contentNode.animateOut(transition: transition)
self.deactivate()
}
}

View File

@@ -0,0 +1,620 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import PresentationDataUtils
import LegacyComponents
import MergeLists
import AccountContext
import StickerPackPreviewUI
import StickerPeekUI
import Emoji
import AppBundle
import OverlayStatusController
import UndoUI
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatPresentationInterfaceState
import FeaturedStickersScreen
private enum StickerSearchEntryId: Equatable, Hashable {
case sticker(String?, Int64)
case global(ItemCollectionId)
}
private enum StickerSearchEntry: Identifiable, Comparable {
case sticker(index: Int, code: String?, stickerItem: FoundStickerItem, theme: PresentationTheme)
case global(index: Int, info: StickerPackCollectionInfo, topItems: [StickerPackItem], installed: Bool, topSeparator: Bool)
var stableId: StickerSearchEntryId {
switch self {
case let .sticker(_, code, stickerItem, _):
return .sticker(code, stickerItem.file.fileId.id)
case let .global(_, info, _, _, _):
return .global(info.id)
}
}
static func ==(lhs: StickerSearchEntry, rhs: StickerSearchEntry) -> Bool {
switch lhs {
case let .sticker(lhsIndex, lhsCode, lhsStickerItem, lhsTheme):
if case let .sticker(rhsIndex, rhsCode, rhsStickerItem, rhsTheme) = rhs {
if lhsIndex != rhsIndex {
return false
}
if lhsCode != rhsCode {
return false
}
if lhsStickerItem != rhsStickerItem {
return false
}
if lhsTheme !== rhsTheme {
return false
}
return true
} else {
return false
}
case let .global(index, info, topItems, installed, topSeparator):
if case .global(index, info, topItems, installed, topSeparator) = rhs {
return true
} else {
return false
}
}
}
static func <(lhs: StickerSearchEntry, rhs: StickerSearchEntry) -> Bool {
switch lhs {
case let .sticker(lhsIndex, _, _, _):
switch rhs {
case let .sticker(rhsIndex, _, _, _):
return lhsIndex < rhsIndex
default:
return true
}
case let .global(lhsIndex, _, _, _, _):
switch rhs {
case .sticker:
return false
case let .global(rhsIndex, _, _, _, _):
return lhsIndex < rhsIndex
}
}
}
func item(account: Account, theme: PresentationTheme, strings: PresentationStrings, interaction: StickerPaneSearchInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction) -> GridItem {
switch self {
case let .sticker(_, code, stickerItem, theme):
return StickerPaneSearchStickerItem(account: account, code: code, stickerItem: stickerItem, inputNodeInteraction: inputNodeInteraction, theme: theme, selected: { node, rect in
interaction.sendSticker(.standalone(media: stickerItem.file), node.view, rect)
})
case let .global(_, info, topItems, installed, topSeparator):
let itemContext = StickerPaneSearchGlobalItemContext()
itemContext.canPlayMedia = true
return StickerPaneSearchGlobalItem(account: account, theme: theme, strings: strings, listAppearance: false, info: info, topItems: topItems, topSeparator: topSeparator, regularInsets: false, installed: installed, unread: false, open: {
interaction.open(info)
}, install: {
interaction.install(info, topItems, !installed)
}, getItemIsPreviewed: { item in
return interaction.getItemIsPreviewed(item)
}, itemContext: itemContext)
}
}
}
private struct StickerPaneSearchGridTransition {
let deletions: [Int]
let insertions: [GridNodeInsertItem]
let updates: [GridNodeUpdateItem]
let updateFirstIndexInSectionOffset: Int?
let stationaryItems: GridNodeStationaryItems
let scrollToItem: GridNodeScrollToItem?
let animated: Bool
}
private func preparedChatMediaInputGridEntryTransition(account: Account, theme: PresentationTheme, strings: PresentationStrings, from fromEntries: [StickerSearchEntry], to toEntries: [StickerSearchEntry], interaction: StickerPaneSearchInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction) -> StickerPaneSearchGridTransition {
let stationaryItems: GridNodeStationaryItems = .none
let scrollToItem: GridNodeScrollToItem? = nil
var animated = false
animated = true
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices
let insertions = indicesAndItems.map { GridNodeInsertItem(index: $0.0, item: $0.1.item(account: account, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction), previousIndex: $0.2) }
let updates = updateIndices.map { GridNodeUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction)) }
let firstIndexInSectionOffset = 0
return StickerPaneSearchGridTransition(deletions: deletions, insertions: insertions, updates: updates, updateFirstIndexInSectionOffset: firstIndexInSectionOffset, stationaryItems: stationaryItems, scrollToItem: scrollToItem, animated: animated)
}
final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode {
private let context: AccountContext
private let controllerInteraction: ChatControllerInteraction
private let inputNodeInteraction: ChatMediaInputNodeInteraction
private var interaction: StickerPaneSearchInteraction?
private var theme: PresentationTheme
private var strings: PresentationStrings
private let trendingPane: ChatMediaInputTrendingPane
private let gridNode: GridNode
private let notFoundNode: ASImageNode
private let notFoundLabel: ImmediateTextNode
private var validLayout: CGSize?
private var enqueuedTransitions: [StickerPaneSearchGridTransition] = []
private let searchDisposable = MetaDisposable()
private let queue = Queue()
private let currentEntries = Atomic<[StickerSearchEntry]?>(value: nil)
private let currentRemotePacks = Atomic<FoundStickerSets?>(value: nil)
private let _ready = Promise<Void>()
var ready: Signal<Void, NoError> {
return self._ready.get()
}
var deactivateSearchBar: (() -> Void)?
var updateActivity: ((Bool) -> Void)?
private let installDisposable = MetaDisposable()
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: ChatControllerInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction) {
self.context = context
self.controllerInteraction = controllerInteraction
self.inputNodeInteraction = inputNodeInteraction
self.theme = theme
self.strings = strings
self.trendingPane = ChatMediaInputTrendingPane(context: context, controllerInteraction: controllerInteraction, getItemIsPreviewed: { [weak inputNodeInteraction] item in
return inputNodeInteraction?.previewedStickerPackItem == .pack(item.file)
}, isPane: false)
self.gridNode = GridNode()
self.notFoundNode = ASImageNode()
self.notFoundNode.displayWithoutProcessing = true
self.notFoundNode.displaysAsynchronously = false
self.notFoundNode.clipsToBounds = false
self.notFoundLabel = ImmediateTextNode()
self.notFoundLabel.displaysAsynchronously = false
self.notFoundLabel.isUserInteractionEnabled = false
self.notFoundNode.addSubnode(self.notFoundLabel)
self.gridNode.isHidden = true
self.trendingPane.isHidden = false
self.notFoundNode.isHidden = true
super.init()
self.addSubnode(self.trendingPane)
self.addSubnode(self.gridNode)
self.addSubnode(self.notFoundNode)
self.gridNode.scrollView.alwaysBounceVertical = true
self.gridNode.scrollingInitiated = { [weak self] in
self?.deactivateSearchBar?()
}
self.trendingPane.scrollingInitiated = { [weak self] in
self?.deactivateSearchBar?()
}
self.interaction = StickerPaneSearchInteraction(open: { [weak self] info in
if let strongSelf = self {
strongSelf.view.window?.endEditing(true)
let packReference: StickerPackReference = .id(id: info.id.id, accessHash: info.accessHash)
let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: strongSelf.controllerInteraction.navigationController(), sendSticker: { [weak self] fileReference, sourceNode, sourceRect in
if let strongSelf = self {
return strongSelf.controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, [])
} else {
return false
}
})
strongSelf.controllerInteraction.presentController(controller, nil)
}
}, install: { [weak self] info, items, install in
guard let strongSelf = self else {
return
}
let context = strongSelf.context
if install {
var installSignal = strongSelf.context.engine.stickers.loadedStickerPack(reference: .id(id: info.id.id, accessHash: info.accessHash), forceActualized: false)
|> mapToSignal { result -> Signal<(StickerPackCollectionInfo, [StickerPackItem]), NoError> in
switch result {
case let .result(info, items, installed):
if installed {
return .complete()
} else {
return preloadedStickerPackThumbnail(account: context.account, info: info, items: items)
|> filter { $0 }
|> ignoreValues
|> then(
context.engine.stickers.addStickerPackInteractively(info: info, items: items)
|> ignoreValues
)
|> mapToSignal { _ -> Signal<(StickerPackCollectionInfo, [StickerPackItem]), NoError> in
}
|> then(.single((info, items)))
}
case .fetching:
break
case .none:
break
}
return .complete()
}
|> deliverOnMainQueue
let context = strongSelf.context
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { subscriber in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
self?.controllerInteraction.presentController(controller, nil)
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.12, queue: Queue.mainQueue())
let progressDisposable = progressSignal.start()
installSignal = installSignal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
cancelImpl = {
self?.installDisposable.set(nil)
}
strongSelf.installDisposable.set(installSignal.start(next: { info, items in
guard let strongSelf = self else {
return
}
var animateInAsReplacement = false
if let navigationController = strongSelf.controllerInteraction.navigationController() {
for controller in navigationController.overlayControllers {
if let controller = controller as? UndoOverlayController {
controller.dismissWithCommitActionAndReplacementAnimation()
animateInAsReplacement = true
}
}
}
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
strongSelf.controllerInteraction.navigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in
return true
}))
}))
} else {
let _ = (context.engine.stickers.removeStickerPackInteractively(id: info.id, option: .delete)
|> deliverOnMainQueue).start(next: { _ in
})
}
}, sendSticker: { [weak self] file, sourceView, sourceRect in
if let strongSelf = self {
let _ = strongSelf.controllerInteraction.sendSticker(file, false, false, nil, false, sourceView, sourceRect, nil, [])
}
}, getItemIsPreviewed: { item in
return inputNodeInteraction.previewedStickerPackItem == .pack(item.file)
})
self._ready.set(self.trendingPane.ready)
self.trendingPane.activate()
self.updateThemeAndStrings(theme: theme, strings: strings)
}
deinit {
self.searchDisposable.dispose()
self.installDisposable.dispose()
}
func updateText(_ text: String, languageCode: String?) {
let signal: Signal<([(String?, FoundStickerItem)], FoundStickerSets, Bool, FoundStickerSets?)?, NoError>
if !text.isEmpty {
let context = self.context
let stickers: Signal<[(String?, FoundStickerItem)], NoError> = Signal { subscriber in
var signals: Signal<[Signal<(String?, [FoundStickerItem]), NoError>], NoError> = .single([])
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
if query.isSingleEmoji {
signals = .single([context.engine.stickers.searchStickers(query: text.basicEmoji.0)
|> map { (nil, $0) }])
} else if query.count > 1, let languageCode = languageCode, !languageCode.isEmpty && languageCode != "emoji" {
var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query.lowercased(), completeMatch: query.count < 3)
if !languageCode.lowercased().hasPrefix("en") {
signal = signal
|> mapToSignal { keywords in
return .single(keywords)
|> then(
context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query.lowercased(), completeMatch: query.count < 3)
|> map { englishKeywords in
return keywords + englishKeywords
}
)
}
}
signals = signal
|> map { keywords -> [Signal<(String?, [FoundStickerItem]), NoError>] in
var signals: [Signal<(String?, [FoundStickerItem]), NoError>] = []
let emoticons = keywords.flatMap { $0.emoticons }
for emoji in emoticons {
signals.append(context.engine.stickers.searchStickers(query: emoji.basicEmoji.0)
// |> take(1)
|> map { (emoji, $0) })
}
return signals
}
}
return (signals
|> mapToSignal { signals in
return combineLatest(signals)
}).start(next: { results in
var result: [(String?, FoundStickerItem)] = []
for (emoji, stickers) in results {
for sticker in stickers {
result.append((emoji, sticker))
}
}
subscriber.putNext(result)
}, completed: {
// subscriber.putCompletion()
})
}
let local = context.engine.stickers.searchStickerSets(query: text)
let remote = context.engine.stickers.searchStickerSetsRemotely(query: text)
|> delay(0.2, queue: Queue.mainQueue())
let rawPacks = local
|> mapToSignal { result -> Signal<(FoundStickerSets, Bool, FoundStickerSets?), NoError> in
var localResult = result
if let currentRemote = self.currentRemotePacks.with ({ $0 }) {
localResult = localResult.merge(with: currentRemote)
}
return .single((localResult, false, nil))
|> then(
remote
|> map { remote -> (FoundStickerSets, Bool, FoundStickerSets?) in
return (result.merge(with: remote), true, remote)
}
)
}
let installedPackIds = context.account.postbox.combinedView(keys: [.itemCollectionInfos(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])])
|> map { view -> Set<ItemCollectionId> in
var installedPacks = Set<ItemCollectionId>()
if let stickerPacksView = view.views[.itemCollectionInfos(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])] as? ItemCollectionInfosView {
if let packsEntries = stickerPacksView.entriesByNamespace[Namespaces.ItemCollection.CloudStickerPacks] {
for entry in packsEntries {
installedPacks.insert(entry.id)
}
}
}
return installedPacks
}
|> distinctUntilChanged
let packs = combineLatest(rawPacks, installedPackIds)
|> map { packs, installedPackIds -> (FoundStickerSets, Bool, FoundStickerSets?) in
var (localPacks, completed, remotePacks) = packs
for i in 0 ..< localPacks.infos.count {
let installed = installedPackIds.contains(localPacks.infos[i].0)
if installed != localPacks.infos[i].3 {
localPacks.infos[i].3 = installed
}
}
if remotePacks != nil {
for i in 0 ..< remotePacks!.infos.count {
let installed = installedPackIds.contains(remotePacks!.infos[i].0)
if installed != remotePacks!.infos[i].3 {
remotePacks!.infos[i].3 = installed
}
}
}
return (localPacks, completed, remotePacks)
}
signal = combineLatest(stickers, packs)
|> map { stickers, packs -> ([(String?, FoundStickerItem)], FoundStickerSets, Bool, FoundStickerSets?)? in
return (stickers, packs.0, packs.1, packs.2)
}
self.updateActivity?(true)
} else {
signal = .single(nil)
self.updateActivity?(false)
}
self.searchDisposable.set((signal
|> deliverOn(self.queue)).start(next: { [weak self] result in
Queue.mainQueue().async {
guard let strongSelf = self, let interaction = strongSelf.interaction else {
return
}
var entries: [StickerSearchEntry] = []
if let (stickers, packs, final, remote) = result {
if let remote = remote {
let _ = strongSelf.currentRemotePacks.swap(remote)
}
strongSelf.gridNode.isHidden = false
strongSelf.trendingPane.isHidden = true
if final {
strongSelf.updateActivity?(false)
}
var index = 0
var existingStickerIds = Set<MediaId>()
var previousCode: String?
for (code, sticker) in stickers {
if let id = sticker.file.id, !existingStickerIds.contains(id) {
entries.append(.sticker(index: index, code: code != previousCode ? code : nil, stickerItem: sticker, theme: strongSelf.theme))
index += 1
previousCode = code
existingStickerIds.insert(id)
}
}
var isFirstGlobal = true
for (collectionId, info, _, installed) in packs.infos {
if let info = info as? StickerPackCollectionInfo {
var topItems: [StickerPackItem] = []
for e in packs.entries {
if let item = e.item as? StickerPackItem {
if e.index.collectionId == collectionId {
topItems.append(item)
}
}
}
entries.append(.global(index: index, info: info, topItems: topItems, installed: installed, topSeparator: !isFirstGlobal))
isFirstGlobal = false
index += 1
}
}
if final || !entries.isEmpty {
strongSelf.notFoundNode.isHidden = !entries.isEmpty
}
} else {
let _ = strongSelf.currentRemotePacks.swap(nil)
strongSelf.updateActivity?(false)
strongSelf.gridNode.isHidden = true
strongSelf.notFoundNode.isHidden = true
strongSelf.trendingPane.isHidden = false
}
let previousEntries = strongSelf.currentEntries.swap(entries)
let transition = preparedChatMediaInputGridEntryTransition(account: strongSelf.context.account, theme: strongSelf.theme, strings: strongSelf.strings, from: previousEntries ?? [], to: entries, interaction: interaction, inputNodeInteraction: strongSelf.inputNodeInteraction)
strongSelf.enqueueTransition(transition)
}
}))
}
func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
self.notFoundNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/StickersNotFoundIcon"), color: theme.list.freeMonoIconColor)
self.notFoundLabel.attributedText = NSAttributedString(string: strings.Stickers_NoStickersFound, font: Font.medium(14.0), textColor: theme.list.freeTextColor)
}
private func enqueueTransition(_ transition: StickerPaneSearchGridTransition) {
self.enqueuedTransitions.append(transition)
if self.validLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let transition = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
let itemTransition: ContainedViewLayoutTransition = .immediate
self.gridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: transition.scrollToItem, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: transition.updateFirstIndexInSectionOffset), completion: { _ in })
}
}
func updatePreviewing(animated: Bool) {
self.gridNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? StickerPaneSearchStickerItemNode {
itemNode.updatePreviewing(animated: animated)
} else if let itemNode = itemNode as? StickerPaneSearchGlobalItemNode {
itemNode.updatePreviewing(animated: animated)
}
}
self.trendingPane.updatePreviewing(animated: animated)
}
func itemAt(point: CGPoint) -> (ASDisplayNode, Any)? {
if !self.trendingPane.isHidden {
if let (itemNode, item) = self.trendingPane.itemAt(point: self.view.convert(point, to: self.trendingPane.view)) {
return (itemNode, StickerPreviewPeekItem.pack(item.file))
}
} else {
if let itemNode = self.gridNode.itemNodeAtPoint(self.view.convert(point, to: self.gridNode.view)) {
if let itemNode = itemNode as? StickerPaneSearchStickerItemNode, let stickerItem = itemNode.stickerItem {
return (itemNode, StickerPreviewPeekItem.found(stickerItem))
} else if let itemNode = itemNode as? StickerPaneSearchGlobalItemNode {
if let (node, item) = itemNode.itemAt(point: self.view.convert(point, to: itemNode.view)) {
return (node, StickerPreviewPeekItem.pack(item.file))
}
}
}
}
return nil
}
func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) {
let firstLayout = self.validLayout == nil
self.validLayout = size
if let image = self.notFoundNode.image {
let areaHeight = size.height - inputHeight
let labelSize = self.notFoundLabel.updateLayout(CGSize(width: size.width, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.notFoundNode, frame: CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((areaHeight - image.size.height - labelSize.height) / 2.0)), size: image.size))
transition.updateFrame(node: self.notFoundLabel, frame: CGRect(origin: CGPoint(x: floor((image.size.width - labelSize.width) / 2.0), y: image.size.height + 8.0), size: labelSize))
}
let contentFrame = CGRect(origin: CGPoint(), size: size)
self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: contentFrame.size, insets: UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0 + bottomInset, right: 0.0), preloadSize: 300.0, type: .fixed(itemSize: CGSize(width: 75.0, height: 75.0), fillWidth: nil, lineSpacing: 0.0, itemSpacing: nil)), transition: transition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in })
transition.updateFrame(node: self.trendingPane, frame: contentFrame)
self.trendingPane.updateLayout(size: contentFrame.size, topInset: 0.0, bottomInset: bottomInset, isExpanded: false, isVisible: true, deviceMetrics: deviceMetrics, transition: transition)
transition.updateFrame(node: self.gridNode, frame: contentFrame)
if firstLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
func animateIn(additivePosition: CGFloat, transition: ContainedViewLayoutTransition) {
self.gridNode.alpha = 0.0
transition.updateAlpha(node: self.gridNode, alpha: 1.0, completion: { _ in
})
self.trendingPane.alpha = 0.0
transition.updateAlpha(node: self.trendingPane, alpha: 1.0, completion: { _ in
})
if case let .animated(duration, curve) = transition {
self.trendingPane.layer.animatePosition(from: CGPoint(x: 0.0, y: additivePosition), to: CGPoint(), duration: duration, timingFunction: curve.timingFunction, additive: true)
}
}
func animateOut(transition: ContainedViewLayoutTransition) {
transition.updateAlpha(node: self.gridNode, alpha: 0.0, completion: { _ in
})
transition.updateAlpha(node: self.trendingPane, alpha: 0.0, completion: { _ in
})
transition.updateAlpha(node: self.notFoundNode, alpha: 0.0, completion: { _ in
})
}
}