Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,513 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import ActivityIndicator
import StickerResources
import AnimatedStickerNode
import TelegramAnimatedStickerNode
import ShimmerEffect
import AppBundle
enum GroupStickerPackCurrentItemContent: Equatable {
case notFound(isEmoji: Bool)
case searching
case found(packInfo: StickerPackCollectionInfo, topItem: StickerPackItem?, subtitle: String)
}
final class GroupStickerPackCurrentItem: ListViewItem, ItemListItem {
let theme: PresentationTheme
let strings: PresentationStrings
let systemStyle: ItemListSystemStyle
let account: Account
let content: GroupStickerPackCurrentItemContent
let sectionId: ItemListSectionId
let action: (() -> Void)?
let remove: (() -> Void)?
init(theme: PresentationTheme, strings: PresentationStrings, systemStyle: ItemListSystemStyle = .legacy, account: Account, content: GroupStickerPackCurrentItemContent, sectionId: ItemListSectionId, action: (() -> Void)?, remove: (() -> Void)?) {
self.theme = theme
self.strings = strings
self.systemStyle = systemStyle
self.account = account
self.content = content
self.sectionId = sectionId
self.action = action
self.remove = remove
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = GroupStickerPackCurrentItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply(false) })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? GroupStickerPackCurrentItemNode {
let makeLayout = nodeValue.asyncLayout()
var animated = true
if case .None = animation {
animated = false
}
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply(animated)
})
}
}
}
}
}
var selectable: Bool = true
func selected(listView: ListView){
listView.clearHighlightAnimated(true)
self.action?()
}
}
private let titleFont = Font.bold(15.0)
private let statusFont = Font.regular(14.0)
class GroupStickerPackCurrentItemNode: ItemListRevealOptionsItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
fileprivate let imageNode: TransformImageNode
private var animationNode: AnimatedStickerNode?
private var placeholderNode: StickerShimmerEffectNode?
private let notFoundNode: ASImageNode
private let titleNode: TextNode
private let statusNode: TextNode
private let activityIndicator: ActivityIndicator
private let removeButton: HighlightTrackingButtonNode
private let removeButtonIcon: ASImageNode
private var item: GroupStickerPackCurrentItem?
private var editableControlNode: ItemListEditableControlNode?
private let fetchDisposable = MetaDisposable()
override var canBeSelected: Bool {
if let item = self.item {
if case .found = item.content {
return true
}
}
return false
}
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.imageNode = TransformImageNode()
self.imageNode.isLayerBacked = !smartInvertColorsEnabled()
self.placeholderNode = StickerShimmerEffectNode()
self.placeholderNode?.isUserInteractionEnabled = false
self.notFoundNode = ASImageNode()
self.notFoundNode.isLayerBacked = true
self.notFoundNode.displayWithoutProcessing = true
self.notFoundNode.displaysAsynchronously = false
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.displaysAsynchronously = false
self.titleNode.contentMode = .left
self.titleNode.contentsScale = UIScreen.main.scale
self.statusNode = TextNode()
self.statusNode.isUserInteractionEnabled = false
self.statusNode.displaysAsynchronously = false
self.statusNode.contentMode = .left
self.statusNode.contentsScale = UIScreen.main.scale
self.activityIndicator = ActivityIndicator(type: .custom(.blue, 22.0, 1.0, false))
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.removeButton = HighlightTrackingButtonNode(pointerStyle: nil)
self.removeButtonIcon = ASImageNode()
self.removeButtonIcon.displaysAsynchronously = false
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
if let placeholderNode = self.placeholderNode {
self.addSubnode(placeholderNode)
}
self.addSubnode(self.imageNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.statusNode)
self.addSubnode(self.notFoundNode)
self.addSubnode(self.activityIndicator)
self.addSubnode(self.removeButtonIcon)
self.addSubnode(self.removeButton)
var firstTime = true
self.imageNode.imageUpdated = { [weak self] image in
guard let strongSelf = self else {
return
}
if image != nil {
strongSelf.removePlaceholder(animated: !firstTime)
if firstTime {
strongSelf.imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
firstTime = false
}
self.removeButton.addTarget(self, action: #selector(self.removeButtonPressed), forControlEvents: .touchUpInside)
self.removeButton.highligthedChanged = { [weak self] highlighted in
if let self {
if highlighted {
self.removeButtonIcon.layer.removeAnimation(forKey: "opacity")
self.removeButtonIcon.alpha = 0.4
} else {
self.removeButtonIcon.alpha = 1.0
self.removeButtonIcon.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
}
deinit {
self.fetchDisposable.dispose()
}
@objc private func removeButtonPressed() {
self.item?.remove?()
}
private func removePlaceholder(animated: Bool) {
if let placeholderNode = self.placeholderNode {
self.placeholderNode = nil
if !animated {
placeholderNode.removeFromSupernode()
} else {
placeholderNode.allowsGroupOpacity = true
placeholderNode.alpha = 0.0
placeholderNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak placeholderNode] _ in
placeholderNode?.removeFromSupernode()
placeholderNode?.allowsGroupOpacity = false
})
}
}
}
private var absoluteLocation: (CGRect, CGSize)?
override func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
self.absoluteLocation = (rect, containerSize)
if let placeholderNode = placeholderNode {
placeholderNode.updateAbsoluteRect(CGRect(origin: CGPoint(x: rect.minX + placeholderNode.frame.minX, y: rect.minY + placeholderNode.frame.minY), size: placeholderNode.frame.size), within: containerSize)
}
}
func asyncLayout() -> (_ item: GroupStickerPackCurrentItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeImageLayout = self.imageNode.asyncLayout()
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeStatusLayout = TextNode.asyncLayout(self.statusNode)
let currentItem = self.item
return { item, params, neighbors in
var titleAttributedString: NSAttributedString?
var statusAttributedString: NSAttributedString?
var updatedTheme: PresentationTheme?
var updatedNotFoundImage: UIImage?
if currentItem?.theme !== item.theme {
updatedTheme = item.theme
updatedNotFoundImage = generateTintedImage(image: UIImage(bundleImageName: "Peer Info/GroupStickerPackNotFound"), color: item.theme.list.freeMonoIconColor)
}
let rightInset: CGFloat = params.rightInset
var file: TelegramMediaFile?
var previousFile: TelegramMediaFile?
if let currentItem = currentItem, case let .found(_, topItem, _) = currentItem.content {
previousFile = topItem?.file._parse()
}
switch item.content {
case let .notFound(isEmoji):
titleAttributedString = NSAttributedString(string: isEmoji ? item.strings.Group_Emoji_NotFound : item.strings.Channel_Stickers_NotFound, font: titleFont, textColor: item.theme.list.itemDestructiveColor)
statusAttributedString = NSAttributedString(string: isEmoji ? item.strings.Group_Emoji_NotFoundHelp : item.strings.Channel_Stickers_NotFoundHelp, font: statusFont, textColor: item.theme.list.itemSecondaryTextColor)
case .searching:
titleAttributedString = NSAttributedString(string: item.strings.Channel_Stickers_Searching, font: titleFont, textColor: item.theme.list.itemPrimaryTextColor)
statusAttributedString = NSAttributedString(string: "", font: statusFont, textColor: item.theme.list.itemSecondaryTextColor)
case let .found(packInfo, topItem, subtitle):
file = topItem?.file._parse()
titleAttributedString = NSAttributedString(string: packInfo.title, font: titleFont, textColor: item.theme.list.itemPrimaryTextColor)
statusAttributedString = NSAttributedString(string: subtitle, font: statusFont, textColor: item.theme.list.itemSecondaryTextColor)
}
var fileUpdated = false
if let file = file, let previousFile = previousFile {
fileUpdated = !file.isEqual(to: previousFile)
} else if (file != nil) != (previousFile != nil) {
fileUpdated = true
}
let leftInset: CGFloat = 65.0 + params.leftInset
let insets = itemListNeighborsGroupedInsets(neighbors, params)
var verticalInset: CGFloat = 0.0
if case .glass = item.systemStyle {
verticalInset += 4.0
}
let contentSize = CGSize(width: params.width, height: 59.0 + verticalInset * 2.0)
let separatorHeight = UIScreenPixel
let separatorRightInset: CGFloat = item.systemStyle == .glass ? 16.0 : 0.0
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
let layoutSize = layout.size
let editingOffset: CGFloat = 0.0
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - 8.0 - rightInset - 10.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (statusLayout, statusApply) = makeStatusLayout(TextNodeLayoutArguments(attributedString: statusAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - 8.0 - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
var imageApply: (() -> Void)?
var imageSize: CGSize = CGSize(width: 34.0, height: 34.0)
if let file = file, let dimensions = file.dimensions {
let imageBoundingSize = CGSize(width: 34.0, height: 34.0)
imageSize = dimensions.cgSize.aspectFitted(imageBoundingSize)
imageApply = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets()))
}
var updatedImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
var updatedFetchSignal: Signal<FetchResourceSourceType, FetchResourceError>?
if fileUpdated {
if let file = file {
updatedImageSignal = chatMessageSticker(account: item.account, userLocation: .other, file: file, small: false)
updatedFetchSignal = fetchedMediaResource(mediaBox: item.account.postbox.mediaBox, userLocation: .other, userContentType: .sticker, reference: stickerPackFileReference(file).resourceReference(file.resource))
} else {
updatedImageSignal = .single({ _ in return nil })
updatedFetchSignal = .complete()
}
}
return (layout, { [weak self] animated in
if let strongSelf = self {
strongSelf.item = item
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.theme.list.itemHighlightedBackgroundColor
strongSelf.activityIndicator.type = .custom(item.theme.list.itemAccentColor, 22.0, 1.0, false)
}
if case .notFound = item.content {
strongSelf.notFoundNode.isHidden = false
} else {
strongSelf.notFoundNode.isHidden = true
}
if case .searching = item.content {
strongSelf.activityIndicator.isHidden = false
strongSelf.imageNode.isHidden = true
} else {
strongSelf.activityIndicator.isHidden = true
}
if case .found = item.content {
strongSelf.removeButtonIcon.isHidden = false
strongSelf.removeButton.isHidden = false
strongSelf.imageNode.isHidden = false
} else {
strongSelf.removeButtonIcon.isHidden = true
strongSelf.removeButton.isHidden = true
}
let revealOffset = strongSelf.revealOffset
let transition: ContainedViewLayoutTransition = .immediate
imageApply?()
let _ = titleApply()
let _ = statusApply()
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.addSubnode(strongSelf.maskNode)
}
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
let bottomStripeOffset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = leftInset + editingOffset
bottomStripeOffset = -separatorHeight
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
bottomStripeOffset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners, glass: item.systemStyle == .glass) : nil
strongSelf.removeButtonIcon.image = PresentationResourcesItemList.itemListRemoveIconImage(item.theme)
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)))
transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset - params.rightInset - separatorRightInset, height: separatorHeight)))
let titleVerticalOffset: CGFloat
if statusLayout.size.width.isZero {
titleVerticalOffset = 19.0
} else {
titleVerticalOffset = 11.0
}
transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftInset, y: verticalInset + titleVerticalOffset), size: titleLayout.size))
transition.updateFrame(node: strongSelf.statusNode, frame: CGRect(origin: CGPoint(x: leftInset, y: verticalInset + 32.0), size: statusLayout.size))
let boundingSize = CGSize(width: 34.0, height: 34.0)
let indicatorSize = CGSize(width: 22.0, height: 22.0)
transition.updateFrame(node: strongSelf.activityIndicator, frame: CGRect(origin: CGPoint(x: params.leftInset + 15.0 + floor((boundingSize.width - indicatorSize.width) / 2.0), y: verticalInset + 11.0 + floor((boundingSize.height - indicatorSize.height) / 2.0)), size: indicatorSize))
if let image = updatedNotFoundImage {
strongSelf.notFoundNode.image = image
}
if let image = strongSelf.notFoundNode.image {
transition.updateFrame(node: strongSelf.notFoundNode, frame: CGRect(origin: CGPoint(x: params.leftInset + 15.0 + floor((boundingSize.width - image.size.width) / 2.0), y: verticalInset + 13.0 + floor((boundingSize.height - image.size.height) / 2.0)), size: image.size))
}
if let updatedImageSignal = updatedImageSignal {
strongSelf.imageNode.setSignal(updatedImageSignal)
}
let imageFrame = CGRect(origin: CGPoint(x: params.leftInset + revealOffset + editingOffset + 15.0 + floor((boundingSize.width - imageSize.width) / 2.0), y: verticalInset + 11.0 + floor((boundingSize.height - imageSize.height) / 2.0)), size: imageSize)
transition.updateFrame(node: strongSelf.imageNode, frame: imageFrame)
strongSelf.removeButton.frame = CGRect(origin: CGPoint(x: layoutSize.width - params.rightInset - layoutSize.height, y: 0.0), size: CGSize(width: layoutSize.height, height: layoutSize.height))
if let icon = strongSelf.removeButtonIcon.image {
strongSelf.removeButtonIcon.frame = CGRect(origin: CGPoint(x: layoutSize.width - params.rightInset - icon.size.width - 18.0, y: floor((layoutSize.height - icon.size.height) / 2.0)), size: icon.size)
}
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: contentSize.height + UIScreenPixel + UIScreenPixel))
strongSelf.updateLayout(size: layout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset)
if let updatedFetchSignal = updatedFetchSignal {
strongSelf.fetchDisposable.set(updatedFetchSignal.start())
}
}
})
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
var anchorNode: ASDisplayNode?
if self.bottomStripeNode.supernode != nil {
anchorNode = self.bottomStripeNode
} else if self.topStripeNode.supernode != nil {
anchorNode = self.topStripeNode
} else if self.backgroundNode.supernode != nil {
anchorNode = self.backgroundNode
}
if let anchorNode = anchorNode {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode)
} else {
self.addSubnode(self.highlightedBackgroundNode)
}
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
}
@@ -0,0 +1,590 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import ItemListUI
import PresentationDataUtils
import AccountContext
import StickerPackPreviewUI
import ItemListStickerPackItem
import UndoUI
private final class GroupStickerPackSetupControllerArguments {
let context: AccountContext
let selectStickerPack: (StickerPackCollectionInfo) -> Void
let openStickerPack: (StickerPackCollectionInfo) -> Void
let updateSearchText: (String) -> Void
let openStickersBot: () -> Void
init(context: AccountContext, selectStickerPack: @escaping (StickerPackCollectionInfo) -> Void, openStickerPack: @escaping (StickerPackCollectionInfo) -> Void, updateSearchText: @escaping (String) -> Void, openStickersBot: @escaping () -> Void) {
self.context = context
self.selectStickerPack = selectStickerPack
self.openStickerPack = openStickerPack
self.updateSearchText = updateSearchText
self.openStickersBot = openStickersBot
}
}
private enum GroupStickerPackSection: Int32 {
case search
case stickers
}
private enum GroupStickerPackEntryId: Hashable {
case index(Int32)
case pack(ItemCollectionId)
static func ==(lhs: GroupStickerPackEntryId, rhs: GroupStickerPackEntryId) -> Bool {
switch lhs {
case let .index(index):
if case .index(index) = rhs {
return true
} else {
return false
}
case let .pack(id):
if case .pack(id) = rhs {
return true
} else {
return false
}
}
}
}
private enum GroupStickerPackEntry: ItemListNodeEntry {
case search(PresentationTheme, PresentationStrings, String, String, String)
case currentPack(Int32, PresentationTheme, PresentationStrings, GroupStickerPackCurrentItemContent)
case searchInfo(PresentationTheme, String)
case packsTitle(PresentationTheme, String)
case pack(Int32, PresentationTheme, PresentationStrings, StickerPackCollectionInfo, StickerPackItem?, String, Bool, Bool)
var section: ItemListSectionId {
switch self {
case .search, .currentPack, .searchInfo:
return GroupStickerPackSection.search.rawValue
case .packsTitle, .pack:
return GroupStickerPackSection.stickers.rawValue
}
}
var stableId: GroupStickerPackEntryId {
switch self {
case .search:
return .index(0)
case .currentPack:
return .index(1)
case .searchInfo:
return .index(2)
case .packsTitle:
return .index(3)
case let .pack(_, _, _, info, _, _, _, _):
return .pack(info.id)
}
}
static func ==(lhs: GroupStickerPackEntry, rhs: GroupStickerPackEntry) -> Bool {
switch lhs {
case let .search(lhsTheme, lhsStrings, lhsPrefix, lhsPlaceholder, lhsValue):
if case let .search(rhsTheme, rhsStrings, rhsPrefix, rhsPlaceholder, rhsValue) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsPrefix == rhsPrefix, lhsPlaceholder == rhsPlaceholder, lhsValue == rhsValue {
return true
} else {
return false
}
case let .searchInfo(lhsTheme, lhsText):
if case let .searchInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
return true
} else {
return false
}
case let .packsTitle(lhsTheme, lhsText):
if case let .packsTitle(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
return true
} else {
return false
}
case let .currentPack(lhsIndex, lhsTheme, lhsStrings, lhsContent):
if case let .currentPack(rhsIndex, rhsTheme, rhsStrings, rhsContent) = rhs {
if lhsIndex != rhsIndex {
return false
}
if lhsTheme !== rhsTheme {
return false
}
if lhsStrings !== rhsStrings {
return false
}
if lhsContent != rhsContent {
return false
}
return true
} else {
return false
}
case let .pack(lhsIndex, lhsTheme, lhsStrings, lhsInfo, lhsTopItem, lhsCount, lhsPlayAnimatedStickers, lhsSelected):
if case let .pack(rhsIndex, rhsTheme, rhsStrings, rhsInfo, rhsTopItem, rhsCount, rhsPlayAnimatedStickers, rhsSelected) = rhs {
if lhsIndex != rhsIndex {
return false
}
if lhsTheme !== rhsTheme {
return false
}
if lhsStrings !== rhsStrings {
return false
}
if lhsInfo != rhsInfo {
return false
}
if lhsTopItem != rhsTopItem {
return false
}
if lhsCount != rhsCount {
return false
}
if lhsPlayAnimatedStickers != rhsPlayAnimatedStickers {
return false
}
if lhsSelected != rhsSelected {
return false
}
return true
} else {
return false
}
}
}
static func <(lhs: GroupStickerPackEntry, rhs: GroupStickerPackEntry) -> Bool {
switch lhs {
case .search:
switch rhs {
case .search:
return false
default:
return true
}
case .currentPack:
switch rhs {
case .search, .currentPack:
return false
default:
return true
}
case .searchInfo:
switch rhs {
case .search, .currentPack, .searchInfo:
return false
default:
return true
}
case .packsTitle:
switch rhs {
case .search, .currentPack, .searchInfo, .packsTitle:
return false
default:
return true
}
case let .pack(lhsIndex, _, _, _, _, _, _, _):
switch rhs {
case let .pack(rhsIndex, _, _, _, _, _, _, _):
return lhsIndex < rhsIndex
default:
return false
}
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let arguments = arguments as! GroupStickerPackSetupControllerArguments
switch self {
case let .search(theme, _, prefix, placeholder, value):
let isEmoji = prefix.contains("addemoji")
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: prefix, textColor: theme.list.itemPrimaryTextColor), text: value, placeholder: placeholder, type: .regular(capitalization: false, autocorrection: false), spacing: 0.0, clearType: .none, tag: nil, sectionId: self.section, textUpdated: { value in
arguments.updateSearchText(value)
}, processPaste: { text in
if let url = (URL(string: text) ?? URL(string: "http://" + text)), url.host == "t.me" || url.host == "telegram.me" {
let prefix = isEmoji ? "/addemoji/" : "/addstickers/"
if url.path.hasPrefix(prefix) {
return String(url.path[url.path.index(url.path.startIndex, offsetBy: prefix.count)...])
}
}
return text
}, action: {})
case let .searchInfo(_, text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section, linkAction: nil)
case let .packsTitle(_, text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case let .pack(_, _, _, info, topItem, count, playAnimatedStickers, selected):
return ItemListStickerPackItem(presentationData: presentationData, context: arguments.context, systemStyle: .glass, packInfo: StickerPackCollectionInfo.Accessor(info), itemCount: count, topItem: topItem, unread: false, control: selected ? .selection : .none, editing: ItemListStickerPackItemEditing(editable: false, editing: false, revealed: false, reorderable: false, selectable: false), enabled: true, playAnimatedStickers: playAnimatedStickers, sectionId: self.section, action: {
if selected {
arguments.openStickerPack(info)
} else {
arguments.selectStickerPack(info)
}
}, setPackIdWithRevealedOptions: { _, _ in
}, addPack: {
}, removePack: {
}, toggleSelected: {
})
case let .currentPack(_, theme, strings, content):
return GroupStickerPackCurrentItem(theme: theme, strings: strings, systemStyle: .glass, account: arguments.context.account, content: content, sectionId: self.section, action: {
if case let .found(packInfo, _, _) = content {
arguments.openStickerPack(packInfo)
}
}, remove: {
arguments.updateSearchText("")
})
}
}
}
private struct StickerPackData: Equatable {
let info: StickerPackCollectionInfo
let item: StickerPackItem?
}
private enum InitialStickerPackData {
case noData
case data(StickerPackData)
}
private enum GroupStickerPackSearchState: Equatable {
case none
case found(StickerPackData)
case notFound
case searching
}
private struct GroupStickerPackSetupControllerState: Equatable {
var isSaving: Bool
var searchingPacks: Bool
}
private func groupStickerPackSetupControllerEntries(context: AccountContext, presentationData: PresentationData, searchText: String, view: CombinedView, initialData: InitialStickerPackData?, searchState: GroupStickerPackSearchState, stickerSettings: StickerSettings, isEmoji: Bool) -> [GroupStickerPackEntry] {
if initialData == nil {
return []
}
var entries: [GroupStickerPackEntry] = []
entries.append(.search(presentationData.theme, presentationData.strings, isEmoji ? "t.me/addemoji/" : "t.me/addstickers/", isEmoji ? "emojiset" : presentationData.strings.Channel_Stickers_Placeholder, searchText))
switch searchState {
case .none:
break
case .notFound:
entries.append(.currentPack(0, presentationData.theme, presentationData.strings, .notFound(isEmoji: isEmoji)))
case .searching:
entries.append(.currentPack(0, presentationData.theme, presentationData.strings, .searching))
case let .found(data):
entries.append(.currentPack(0, presentationData.theme, presentationData.strings, .found(packInfo: data.info, topItem: data.item, subtitle: isEmoji ? presentationData.strings.StickerPack_EmojiCount(data.info.count) : presentationData.strings.StickerPack_StickerCount(data.info.count))))
}
entries.append(.searchInfo(presentationData.theme, isEmoji ? presentationData.strings.Group_Emoji_Info : presentationData.strings.Channel_Stickers_CreateYourOwn))
let namespace = isEmoji ? Namespaces.ItemCollection.CloudEmojiPacks : Namespaces.ItemCollection.CloudStickerPacks
if let stickerPacksView = view.views[.itemCollectionInfos(namespaces: [namespace])] as? ItemCollectionInfosView {
if let packsEntries = stickerPacksView.entriesByNamespace[namespace] {
if !packsEntries.isEmpty {
entries.append(.packsTitle(presentationData.theme, isEmoji ? presentationData.strings.Group_Emoji_YourEmoji : presentationData.strings.Channel_Stickers_YourStickers))
}
var index: Int32 = 0
for entry in packsEntries {
if let info = entry.info as? StickerPackCollectionInfo {
var selected = false
if case let .found(found) = searchState {
selected = found.info.id == info.id
}
let count = info.count == 0 ? entry.count : info.count
let thumbnail: StickerPackItem?
if let thumbnailRep = info.thumbnail {
thumbnail = StickerPackItem(index: ItemCollectionItemIndex(index: 0, id: 0), file: TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: thumbnailRep.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: info.immediateThumbnailData, mimeType: "", size: nil, attributes: [], alternativeRepresentations: []), indexKeys: [])
} else {
thumbnail = entry.firstItem as? StickerPackItem
}
entries.append(.pack(index, presentationData.theme, presentationData.strings, info, thumbnail, isEmoji ? presentationData.strings.StickerPack_EmojiCount(count) : presentationData.strings.StickerPack_StickerCount(count), context.sharedContext.energyUsageSettings.loopStickers, selected))
index += 1
}
}
}
}
return entries
}
public func groupStickerPackSetupController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, peerId: PeerId, isEmoji: Bool = false, currentPackInfo: StickerPackCollectionInfo?, completion: ((StickerPackCollectionInfo?) -> Void)? = nil) -> ViewController {
let initialState = GroupStickerPackSetupControllerState(isSaving: false, searchingPacks: false)
let statePromise = ValuePromise(initialState, ignoreRepeated: true)
let stateValue = Atomic(value: initialState)
let updateState: ((GroupStickerPackSetupControllerState) -> GroupStickerPackSetupControllerState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
let searchText = ValuePromise<String>(currentPackInfo?.shortName ?? "", ignoreRepeated: true)
let initialData = Promise<InitialStickerPackData?>()
if let currentPackInfo = currentPackInfo {
initialData.set(context.engine.stickers.cachedStickerPack(reference: .id(id: currentPackInfo.id.id, accessHash: currentPackInfo.accessHash), forceRemote: false)
|> map { result -> InitialStickerPackData? in
switch result {
case .none:
return .noData
case .fetching:
return nil
case let .result(info, items, _):
return InitialStickerPackData.data(StickerPackData(info: info._parse(), item: items.first))
}
})
} else {
initialData.set(.single(.noData))
}
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var completionImpl: ((StickerPackCollectionInfo?) -> Void)?
if let completion {
completionImpl = { value in
completion(value)
}
}
let stickerPacks = Promise<CombinedView>()
stickerPacks.set(context.account.postbox.combinedView(keys: [.itemCollectionInfos(namespaces: [isEmoji ? Namespaces.ItemCollection.CloudEmojiPacks : Namespaces.ItemCollection.CloudStickerPacks])]))
let searchState = Promise<(String, GroupStickerPackSearchState)>()
searchState.set(combineLatest(searchText.get(), initialData.get(), stickerPacks.get())
|> mapToSignal { searchText, initialData, view -> Signal<(String, GroupStickerPackSearchState), NoError> in
if let initialData = initialData {
if searchText.isEmpty {
return .single((searchText, .none))
} else if case let .data(data) = initialData, searchText.lowercased() == data.info.shortName {
Queue.mainQueue().async {
completionImpl?(data.info)
}
return .single((searchText, .found(StickerPackData(info: data.info, item: data.item))))
} else {
let namespace = isEmoji ? Namespaces.ItemCollection.CloudEmojiPacks : Namespaces.ItemCollection.CloudStickerPacks
if let stickerPacksView = view.views[.itemCollectionInfos(namespaces: [namespace])] as? ItemCollectionInfosView {
if let packsEntries = stickerPacksView.entriesByNamespace[namespace] {
for entry in packsEntries {
if let info = entry.info as? StickerPackCollectionInfo {
if info.shortName.lowercased() == searchText.lowercased() {
return .single((searchText, .found(StickerPackData(info: info, item: entry.firstItem as? StickerPackItem))))
}
}
}
}
}
return .single((searchText, .searching))
|> then((context.engine.stickers.loadedStickerPack(reference: .name(searchText.lowercased()), forceActualized: false) |> delay(0.3, queue: Queue.concurrentDefaultQueue()))
|> mapToSignal { value -> Signal<(String, GroupStickerPackSearchState), NoError> in
switch value {
case .fetching:
return .complete()
case .none:
return .single((searchText, .notFound))
case let .result(info, items, _):
return .single((searchText, .found(StickerPackData(info: info._parse(), item: items.first))))
}
}
|> afterNext { value in
if case let .found(data) = value.1 {
Queue.mainQueue().async {
completionImpl?(data.info)
}
}
})
}
} else {
return .single((searchText, .none))
}
})
var navigateToChatControllerImpl: ((PeerId) -> Void)?
var dismissInputImpl: (() -> Void)?
var dismissImpl: (() -> Void)?
let actionsDisposable = DisposableSet()
let resolveDisposable = MetaDisposable()
actionsDisposable.add(resolveDisposable)
let saveDisposable = MetaDisposable()
actionsDisposable.add(saveDisposable)
var presentStickerPackController: ((StickerPackCollectionInfo) -> Void)?
let arguments = GroupStickerPackSetupControllerArguments(context: context, selectStickerPack: { info in
searchText.set(info.shortName)
completionImpl?(info)
}, openStickerPack: { info in
presentStickerPackController?(info)
}, updateSearchText: { text in
searchText.set(text)
if text == "" {
completionImpl?(nil)
}
}, openStickersBot: {
resolveDisposable.set((context.engine.peers.resolvePeerByName(name: "stickers", referrer: nil)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
|> deliverOnMainQueue).start(next: { peer in
if let peer = peer {
dismissImpl?()
navigateToChatControllerImpl?(peer.id)
}
}))
})
let previousHadData = Atomic<Bool>(value: false)
let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData
let signal = combineLatest(presentationData, statePromise.get() |> deliverOnMainQueue, initialData.get() |> deliverOnMainQueue, stickerPacks.get() |> deliverOnMainQueue, searchState.get() |> deliverOnMainQueue, context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.stickerSettings]) |> deliverOnMainQueue)
|> map { presentationData, state, initialData, view, searchState, sharedData -> (ItemListControllerState, (ItemListNodeState, Any)) in
var stickerSettings = StickerSettings.defaultSettings
if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.stickerSettings]?.get(StickerSettings.self) {
stickerSettings = value
}
let leftNavigationButton: ItemListNavigationButton?
if isEmoji {
leftNavigationButton = nil
} else {
leftNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: {
dismissImpl?()
})
}
var rightNavigationButton: ItemListNavigationButton?
if let _ = completion {
rightNavigationButton = ItemListNavigationButton(content: .icon(.search), style: .regular, enabled: true, action: {
updateState { state in
var updatedState = state
updatedState.searchingPacks = true
return updatedState
}
})
} else {
if initialData != nil {
if state.isSaving {
rightNavigationButton = ItemListNavigationButton(content: .text(""), style: .activity, enabled: true, action: {})
} else {
let enabled: Bool
var info: StickerPackCollectionInfo?
switch searchState.1 {
case .searching, .notFound:
enabled = false
case .none:
enabled = true
case let .found(data):
enabled = true
info = data.info
}
rightNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Done), style: .bold, enabled: enabled, action: {
if info?.id == currentPackInfo?.id {
dismissImpl?()
} else {
updateState { state in
var state = state
state.isSaving = true
return state
}
saveDisposable.set((context.engine.peers.updateGroupSpecificStickerset(peerId: peerId, info: info)
|> deliverOnMainQueue).start(error: { _ in
updateState { state in
var state = state
state.isSaving = false
return state
}
}, completed: {
dismissImpl?()
}))
}
})
}
}
}
var searchItem: ItemListControllerSearch?
if state.searchingPacks {
searchItem = GroupStickerSearchItem(context: context, cancel: {
updateState { state in
var updatedState = state
updatedState.searchingPacks = false
return updatedState
}
}, select: { pack in
arguments.selectStickerPack(pack)
}, dismissInput: {
dismissInputImpl?()
})
}
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(isEmoji ? presentationData.strings.Group_Emoji_Title : presentationData.strings.Channel_Info_Stickers), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true)
let hasData = initialData != nil
let hadData = previousHadData.swap(hasData)
var emptyStateItem: ItemListLoadingIndicatorEmptyStateItem?
if !hasData {
emptyStateItem = ItemListLoadingIndicatorEmptyStateItem(theme: presentationData.theme)
}
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: groupStickerPackSetupControllerEntries(context: context, presentationData: presentationData, searchText: searchState.0, view: view, initialData: initialData, searchState: searchState.1, stickerSettings: stickerSettings, isEmoji: isEmoji), style: .blocks, emptyStateItem: emptyStateItem, searchItem: searchItem, animateChanges: hasData && hadData)
return (controllerState, (listState, arguments))
} |> afterDisposed {
actionsDisposable.dispose()
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, p in
if let controller = controller {
if c is UndoOverlayController {
controller.window?.forEachController { c in
if let controller = c as? UndoOverlayController {
controller.dismiss()
}
}
}
controller.present(c, in: .window(.root), with: p)
}
}
dismissInputImpl = { [weak controller] in
controller?.view.endEditing(true)
}
presentStickerPackController = { [weak controller] info in
dismissInputImpl?()
let packReference: StickerPackReference = .id(id: info.id.id, accessHash: info.accessHash)
presentControllerImpl?(StickerPackScreen(context: context, updatedPresentationData: updatedPresentationData, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: controller?.navigationController as? NavigationController), nil)
}
navigateToChatControllerImpl = { [weak controller] peerId in
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> deliverOnMainQueue).start(next: { peer in
guard let peer = peer else {
return
}
if let controller = controller, let navigationController = controller.navigationController as? NavigationController {
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer)))
}
})
}
dismissImpl = { [weak controller] in
dismissInputImpl?()
controller?.dismiss()
}
return controller
}
@@ -0,0 +1,328 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import AccountContext
import SearchUI
import ItemListUI
import ItemListStickerPackItem
private final class GroupStickerSearchContainerInteraction {
let packSelected: (StickerPackCollectionInfo) -> Void
init(packSelected: @escaping (StickerPackCollectionInfo) -> Void) {
self.packSelected = packSelected
}
}
private final class GroupStickerSearchEntry: Comparable, Identifiable {
let index: Int
let pack: StickerPackCollectionInfo
let topItem: ItemCollectionItem?
init(index: Int, pack: StickerPackCollectionInfo, topItem: ItemCollectionItem?) {
self.index = index
self.pack = pack
self.topItem = topItem
}
var stableId: ItemCollectionId {
return self.pack.id
}
static func ==(lhs: GroupStickerSearchEntry, rhs: GroupStickerSearchEntry) -> Bool {
return lhs.index == rhs.index && lhs.pack == rhs.pack
}
static func <(lhs: GroupStickerSearchEntry, rhs: GroupStickerSearchEntry) -> Bool {
return lhs.index < rhs.index
}
func item(context: AccountContext, presentationData: PresentationData, interaction: GroupStickerSearchContainerInteraction) -> ListViewItem {
let pack = self.pack
let count = presentationData.strings.StickerPack_EmojiCount(pack.count)
return ItemListStickerPackItem(presentationData: ItemListPresentationData(presentationData), context: context, packInfo: StickerPackCollectionInfo.Accessor(pack), itemCount: count, topItem: self.topItem as? StickerPackItem, unread: false, control: .none, editing: ItemListStickerPackItemEditing(editable: false, editing: false, revealed: false, reorderable: false, selectable: false), enabled: true, playAnimatedStickers: true, style: .plain, sectionId: 0, action: {
interaction.packSelected(pack)
}, setPackIdWithRevealedOptions: { _, _ in
}, addPack: {
}, removePack: {
}, toggleSelected: {
})
}
}
struct GroupStickerSearchContainerTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let isSearching: Bool
let isEmpty: Bool
let query: String
}
private func groupStickerSearchContainerPreparedRecentTransition(from fromEntries: [GroupStickerSearchEntry], to toEntries: [GroupStickerSearchEntry], isSearching: Bool, isEmpty: Bool, query: String, context: AccountContext, presentationData: PresentationData, interaction: GroupStickerSearchContainerInteraction) -> GroupStickerSearchContainerTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) }
return GroupStickerSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query)
}
public final class GroupStickerSearchContainerNode: SearchDisplayControllerContentNode {
private let context: AccountContext
private let packSelected: (StickerPackCollectionInfo) -> Void
private let listNode: ListView
private let emptyResultsTitleNode: ImmediateTextNode
private let emptyResultsTextNode: ImmediateTextNode
private var enqueuedTransitions: [(GroupStickerSearchContainerTransition, Bool)] = []
private var validLayout: (ContainerViewLayout, CGFloat)?
private let searchQuery = Promise<String?>()
private let searchDisposable = MetaDisposable()
private let forceTheme: PresentationTheme?
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private let removeMemberDisposable = MetaDisposable()
private let presentationDataPromise: Promise<PresentationData>
private var _hasDim: Bool = false
override public var hasDim: Bool {
return _hasDim
}
public init(context: AccountContext, forceTheme: PresentationTheme?, packSelected: @escaping (StickerPackCollectionInfo) -> Void, updateActivity: @escaping (Bool) -> Void, pushController: @escaping (ViewController) -> Void) {
self.context = context
self.packSelected = packSelected
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.presentationData = presentationData
self.forceTheme = forceTheme
if let forceTheme = self.forceTheme {
self.presentationData = self.presentationData.withUpdated(theme: forceTheme)
}
self.presentationDataPromise = Promise(self.presentationData)
self.listNode = ListView()
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
self.emptyResultsTitleNode = ImmediateTextNode()
self.emptyResultsTitleNode.displaysAsynchronously = false
self.emptyResultsTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.ChatList_Search_NoResults, font: Font.semibold(17.0), textColor: self.presentationData.theme.list.freeTextColor)
self.emptyResultsTitleNode.textAlignment = .center
self.emptyResultsTitleNode.isHidden = true
self.emptyResultsTextNode = ImmediateTextNode()
self.emptyResultsTextNode.displaysAsynchronously = false
self.emptyResultsTextNode.maximumNumberOfLines = 0
self.emptyResultsTextNode.textAlignment = .center
self.emptyResultsTextNode.isHidden = true
super.init()
self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.listNode.isHidden = true
self._hasDim = true
self.addSubnode(self.listNode)
self.addSubnode(self.emptyResultsTitleNode)
self.addSubnode(self.emptyResultsTextNode)
let interaction = GroupStickerSearchContainerInteraction(packSelected: { [weak self] pack in
packSelected(pack)
self?.listNode.clearHighlightAnimated(true)
})
let foundItems = self.searchQuery.get()
|> mapToSignal { query -> Signal<[GroupStickerSearchEntry]?, NoError> in
guard let query, !query.isEmpty else {
return .single(nil)
}
return context.engine.stickers.searchEmojiSets(query: query)
|> mapToSignal { localResult in
return context.engine.stickers.searchEmojiSetsRemotely(query: query)
|> map { remoteResult -> [GroupStickerSearchEntry]? in
let mergedResult = localResult.merge(with: remoteResult)
var entries: [GroupStickerSearchEntry] = []
var index = 0
for info in mergedResult.infos {
if let pack = info.1 as? StickerPackCollectionInfo {
entries.append(GroupStickerSearchEntry(index: index, pack: pack, topItem: info.2))
index += 1
}
}
return entries
}
}
}
let previousSearchItems = Atomic<[GroupStickerSearchEntry]?>(value: nil)
self.searchDisposable.set((combineLatest(self.searchQuery.get(), foundItems, self.presentationDataPromise.get())
|> deliverOnMainQueue).start(next: { [weak self] query, entries, presentationData in
if let strongSelf = self {
let previousEntries = previousSearchItems.swap(entries)
updateActivity(false)
let firstTime = previousEntries == nil
let transition = groupStickerSearchContainerPreparedRecentTransition(from: previousEntries ?? [], to: entries ?? [], isSearching: entries != nil, isEmpty: entries?.isEmpty ?? false, query: query ?? "", context: context, presentationData: presentationData, interaction: interaction)
strongSelf.enqueueTransition(transition, firstTime: firstTime)
}
}))
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
var presentationData = presentationData
let previousTheme = strongSelf.presentationData.theme
let previousStrings = strongSelf.presentationData.strings
if let forceTheme = strongSelf.forceTheme {
presentationData = presentationData.withUpdated(theme: forceTheme)
}
strongSelf.presentationData = presentationData
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
strongSelf.updateThemeAndStrings(theme: presentationData.theme, strings: presentationData.strings)
}
}
})
self.listNode.beganInteractiveDragging = { [weak self] _ in
self?.dismissInput?()
}
}
deinit {
self.searchDisposable.dispose()
self.presentationDataDisposable?.dispose()
}
private func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
self.listNode.backgroundColor = theme.chatList.backgroundColor
}
override public func searchTextUpdated(text: String) {
if text.isEmpty {
self.searchQuery.set(.single(nil))
} else {
self.searchQuery.set(.single(text))
}
}
private func enqueueTransition(_ transition: GroupStickerSearchContainerTransition, firstTime: Bool) {
enqueuedTransitions.append((transition, firstTime))
if let _ = self.validLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let (transition, firstTime) = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
options.insert(.PreferSynchronousDrawing)
options.insert(.PreferSynchronousResourceLoading)
if firstTime {
} else {
//options.insert(.AnimateAlpha)
}
let isSearching = transition.isSearching
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.listNode.isHidden = !isSearching
strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.ChatList_Search_NoResultsQueryDescription(transition.query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor)
let emptyResults = transition.isSearching && transition.isEmpty
strongSelf.emptyResultsTitleNode.isHidden = !emptyResults
strongSelf.emptyResultsTextNode.isHidden = !emptyResults
if let (layout, navigationBarHeight) = strongSelf.validLayout {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
})
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
let hadValidLayout = self.validLayout == nil
self.validLayout = (layout, navigationBarHeight)
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
var insets = layout.insets(options: [.input])
insets.top += navigationBarHeight
insets.left += layout.safeInsets.left
insets.right += layout.safeInsets.right
self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
let padding: CGFloat = 16.0
let emptyTitleSize = self.emptyResultsTitleNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyTextSpacing: CGFloat = 8.0
let emptyTotalHeight = emptyTitleSize.height + emptyTextSize.height + emptyTextSpacing
let emptyTitleY = navigationBarHeight + floorToScreenPixels((layout.size.height - navigationBarHeight - max(insets.bottom, layout.intrinsicInsets.bottom) - emptyTotalHeight) / 2.0)
transition.updateFrame(node: self.emptyResultsTitleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTitleSize.width) / 2.0, y: emptyTitleY), size: emptyTitleSize))
transition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyTitleY + emptyTitleSize.height + emptyTextSpacing), size: emptyTextSize))
if !hadValidLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
override public func scrollToTop() {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancel?()
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let result = self.view.hitTest(point, with: event) else {
return nil
}
if result === self.view {
return nil
}
return result
}
}
@@ -0,0 +1,119 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import ItemListUI
import PresentationDataUtils
import AccountContext
final class GroupStickerSearchItem: ItemListControllerSearch {
let context: AccountContext
let cancel: () -> Void
let select: (StickerPackCollectionInfo) -> Void
let dismissInput: () -> Void
private var updateActivity: ((Bool) -> Void)?
private var activity: ValuePromise<Bool> = ValuePromise(ignoreRepeated: false)
private let activityDisposable = MetaDisposable()
init(
context: AccountContext,
cancel: @escaping () -> Void,
select: @escaping (StickerPackCollectionInfo) -> Void,
dismissInput: @escaping () -> Void
) {
self.context = context
self.cancel = cancel
self.select = select
self.dismissInput = dismissInput
self.activityDisposable.set((self.activity.get() |> mapToSignal { value -> Signal<Bool, NoError> in
if value {
return .single(value) |> delay(0.2, queue: Queue.mainQueue())
} else {
return .single(value)
}
}).start(next: { [weak self] value in
self?.updateActivity?(value)
}))
}
deinit {
self.activityDisposable.dispose()
}
func isEqual(to: ItemListControllerSearch) -> Bool {
if let to = to as? GroupStickerSearchItem {
if self.context !== to.context {
return false
}
return true
} else {
return false
}
}
func titleContentNode(current: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)? {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
if let current = current as? GroupStickerSearchNavigationContentNode {
current.updateTheme(presentationData.theme)
return current
} else {
return GroupStickerSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, cancel: self.cancel, updateActivity: { [weak self] value in
self?.updateActivity = value
})
}
}
func node(current: ItemListControllerSearchNode?, titleContentNode: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> ItemListControllerSearchNode {
return GroupStickerSearchItemNode(context: self.context, packSelected: self.select, cancel: self.cancel, updateActivity: { [weak self] value in
self?.activity.set(value)
}, pushController: { c in
}, dismissInput: self.dismissInput)
}
}
private final class GroupStickerSearchItemNode: ItemListControllerSearchNode {
private let containerNode: GroupStickerSearchContainerNode
init(context: AccountContext, packSelected: @escaping (StickerPackCollectionInfo) -> Void, cancel: @escaping () -> Void, updateActivity: @escaping(Bool) -> Void, pushController: @escaping (ViewController) -> Void, dismissInput: @escaping () -> Void) {
self.containerNode = GroupStickerSearchContainerNode(context: context, forceTheme: nil, packSelected: { pack in
packSelected(pack)
cancel()
}, updateActivity: updateActivity, pushController: pushController)
self.containerNode.cancel = {
cancel()
}
super.init()
self.addSubnode(self.containerNode)
self.containerNode.dismissInput = {
dismissInput()
}
}
override func queryUpdated(_ query: String) {
self.containerNode.searchTextUpdated(text: query)
}
override func scrollToTop() {
self.containerNode.scrollToTop()
}
override func updateLayout(layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)))
self.containerNode.containerLayoutUpdated(layout.withUpdatedSize(CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)), navigationBarHeight: 0.0, transition: transition)
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let result = self.containerNode.hitTest(self.view.convert(point, to: self.containerNode.view), with: event) {
return result
}
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,88 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import SearchBarNode
private let searchBarFont = Font.regular(17.0)
final class GroupStickerSearchNavigationContentNode: NavigationBarContentNode, ItemListControllerSearchNavigationContentNode {
private var theme: PresentationTheme
private let strings: PresentationStrings
private let cancel: () -> Void
private let searchBar: SearchBarNode
private var queryUpdated: ((String) -> Void)?
var activity: Bool = false {
didSet {
self.searchBar.activity = activity
}
}
init(theme: PresentationTheme, strings: PresentationStrings, cancel: @escaping () -> Void, updateActivity: @escaping(@escaping(Bool)->Void) -> Void) {
self.theme = theme
self.strings = strings
self.cancel = cancel
self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: theme, hasSeparator: false), strings: strings, fieldStyle: .modern, displayBackground: false)
super.init()
self.addSubnode(self.searchBar)
self.searchBar.cancel = { [weak self] in
self?.searchBar.deactivate(clear: false)
self?.cancel()
}
self.searchBar.textUpdated = { [weak self] query, _ in
self?.queryUpdated?(query)
}
updateActivity({ [weak self] value in
self?.activity = value
})
self.updatePlaceholder()
}
func setQueryUpdated(_ f: @escaping (String) -> Void) {
self.queryUpdated = f
}
func updateTheme(_ theme: PresentationTheme) {
self.theme = theme
self.searchBar.updateThemeAndStrings(theme: SearchBarNodeTheme(theme: self.theme), strings: self.strings)
self.updatePlaceholder()
}
func updatePlaceholder() {
self.searchBar.placeholderString = NSAttributedString(string: self.strings.Common_Search, font: searchBarFont, textColor: self.theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
}
override var nominalHeight: CGFloat {
return 54.0
}
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - self.nominalHeight), size: CGSize(width: size.width, height: 54.0))
self.searchBar.frame = searchBarFrame
self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition)
}
func activate() {
self.searchBar.activate()
}
func deactivate() {
self.searchBar.deactivate(clear: false)
}
}