import Foundation import UIKit import AsyncDisplayKit import Display import ButtonComponent import ComponentFlow import EdgeEffect import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import PresentationDataUtils import LegacyComponents import MergeLists import AccountContext import StickerPeekUI import Emoji import AppBundle import OverlayStatusController import UndoUI import ChatControllerInteraction import FeaturedStickersScreen import ChatPresentationInterfaceState import StickerResources import EntityKeyboard import EmojiTextAttachmentView import MultilineTextComponent import TextFormat private let packPanelHeight: CGFloat = 76.0 private let collapsedPackPanelHeight: CGFloat = 40.0 private enum StickerSearchEntryId: Equatable, Hashable { case sticker(String?, Int64) } private enum StickerSearchEntry: Identifiable, Comparable { case sticker(index: Int, code: String?, stickerItem: FoundStickerItem, theme: PresentationTheme) var stableId: StickerSearchEntryId { switch self { case let .sticker(_, code, stickerItem, _): return .sticker(code, stickerItem.file.fileId.id) } } static func ==(lhs: StickerSearchEntry, rhs: StickerSearchEntry) -> Bool { switch (lhs, rhs) { case let (.sticker(lhsIndex, lhsCode, lhsStickerItem, lhsTheme), .sticker(rhsIndex, rhsCode, rhsStickerItem, rhsTheme)): if lhsIndex != rhsIndex { return false } if lhsCode != rhsCode { return false } if lhsStickerItem != rhsStickerItem { return false } if lhsTheme !== rhsTheme { return false } return true } } static func <(lhs: StickerSearchEntry, rhs: StickerSearchEntry) -> Bool { switch (lhs, rhs) { case let (.sticker(lhsIndex, _, _, _), .sticker(rhsIndex, _, _, _)): return lhsIndex < rhsIndex } } func item(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: StickerPaneSearchInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction) -> GridItem { switch self { case let .sticker(_, code, stickerItem, theme): return StickerPaneSearchStickerItem(context: context, theme: theme, code: code, stickerItem: stickerItem, inputNodeInteraction: inputNodeInteraction, selected: { node, layer, rect in interaction.sendSticker(.standalone(media: stickerItem.file), node.view, layer, rect) }) } } } struct StickerPaneSearchSelectedPack { let info: StickerPackCollectionInfo } private struct StickerPaneSearchPack: Equatable { let info: StickerPackCollectionInfo let topItems: [StickerPackItem] let installed: Bool } private final class StickerSearchPackTopPanelItemComponent: Component { typealias EnvironmentType = EntityKeyboardTopPanelItemEnvironment let context: AccountContext let theme: PresentationTheme let info: StickerPackCollectionInfo let topItem: StickerPackItem? let pressed: () -> Void init( context: AccountContext, theme: PresentationTheme, info: StickerPackCollectionInfo, topItem: StickerPackItem?, pressed: @escaping () -> Void ) { self.context = context self.theme = theme self.info = info self.topItem = topItem self.pressed = pressed } static func ==(lhs: StickerSearchPackTopPanelItemComponent, rhs: StickerSearchPackTopPanelItemComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.info != rhs.info { return false } if lhs.topItem != rhs.topItem { return false } return true } final class View: UIView { private var itemLayer: InlineStickerItemLayer? private var itemFileId: MediaId? private var titleView: ComponentView? private var component: StickerSearchPackTopPanelItemComponent? override init(frame: CGRect) { super.init(frame: frame) self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.component?.pressed() } } func update(component: StickerSearchPackTopPanelItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.component = component let itemEnvironment = environment[EntityKeyboardTopPanelItemEnvironment.self].value let file = component.topItem?.file._parse() let fileId = file?.fileId if self.itemFileId != fileId { self.itemFileId = fileId if let itemLayer = self.itemLayer { self.itemLayer = nil itemLayer.removeFromSuperlayer() } if let file { let itemDimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) let displaySize = itemDimensions.aspectFitted(CGSize(width: 44.0, height: 44.0)) let itemLayer = InlineStickerItemLayer( context: component.context, userLocation: .other, attemptSynchronousLoad: false, emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file), file: file, cache: component.context.animationCache, renderer: component.context.animationRenderer, placeholderColor: component.theme.chat.inputPanel.primaryTextColor.withMultipliedAlpha(0.1), pointSize: displaySize, dynamicColor: .white ) self.itemLayer = itemLayer self.layer.addSublayer(itemLayer) } } let iconFitSize: CGSize = itemEnvironment.isExpanded ? CGSize(width: 44.0, height: 44.0) : CGSize(width: 24.0, height: 24.0) if let itemLayer = self.itemLayer, let file { let itemDimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) let iconSize = itemDimensions.aspectFitted(iconFitSize) let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) / 2.0), y: floor((iconFitSize.height - iconSize.height) / 2.0)), size: iconSize) transition.setPosition(layer: itemLayer, position: CGPoint(x: iconFrame.midX, y: iconFrame.midY)) transition.setBounds(layer: itemLayer, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) itemLayer.isVisibleForAnimations = itemEnvironment.isContentInFocus && component.context.sharedContext.energyUsageSettings.loopStickers } if itemEnvironment.isExpanded { let titleView: ComponentView if let current = self.titleView { titleView = current } else { titleView = ComponentView() self.titleView = titleView } let titleSize = titleView.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.info.title, font: Font.regular(10.0), textColor: component.theme.chat.inputPanel.primaryTextColor)), insets: UIEdgeInsets(top: 1.0, left: 1.0, bottom: 1.0, right: 1.0) )), environment: {}, containerSize: CGSize(width: 62.0, height: 100.0) ) if let view = titleView.view { if view.superview == nil { view.alpha = 0.0 self.addSubview(view) } view.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) / 2.0), y: availableSize.height - titleSize.height - 1.0), size: titleSize) transition.setAlpha(view: view, alpha: 1.0) } } else if let titleView = self.titleView { self.titleView = nil if let view = titleView.view { if !transition.animation.isImmediate { view.alpha = 0.0 view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08, completion: { [weak view] _ in view?.removeFromSuperview() }) } else { view.removeFromSuperview() } } } return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private struct StickerPaneSearchGridTransition { let deletions: [Int] let insertions: [GridNodeInsertItem] let updates: [GridNodeUpdateItem] let updateFirstIndexInSectionOffset: Int? let stationaryItems: GridNodeStationaryItems let scrollToItem: GridNodeScrollToItem? let animated: Bool let crossfade: Bool } private struct StickerPaneSearchStickerState { let context: StickerSearchContext? let items: [FoundStickerItem] let isLoadingMore: Bool } private func preparedChatMediaInputGridEntryTransition(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, from fromEntries: [StickerSearchEntry], to toEntries: [StickerSearchEntry], interaction: StickerPaneSearchInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction, crossfade: Bool) -> 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(context: context, 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(context: context, 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, crossfade: crossfade) } final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { private let context: AccountContext private let interaction: ChatEntityKeyboardInputNode.Interaction private let inputNodeInteraction: ChatMediaInputNodeInteraction private var searchInteraction: 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 let packPanel = ComponentView() private let topEdgeEffectView = EdgeEffectView() private let bottomEdgeEffectView = EdgeEffectView() private let selectedPackAddButton = ComponentView() private let packPanelVisibilityFractionUpdated = ActionSlot<(CGFloat, ComponentTransition)>() private let packPanelActiveItemUpdated = ActionSlot<(AnyHashable, AnyHashable?, ComponentTransition)>() private var validLayout: (size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics)? private var enqueuedTransitions: [StickerPaneSearchGridTransition] = [] private let searchDisposable = MetaDisposable() private let selectedPackDisposable = MetaDisposable() private let queue = Queue() private let currentEntries = Atomic<[StickerSearchEntry]?>(value: nil) private let currentRemotePacks = Atomic(value: nil) private var currentSearchEntries: [StickerSearchEntry] = [] private var currentPacks: [StickerPaneSearchPack] = [] private var currentSearchIsFinal: Bool = false private var searchIsActive: Bool = false private var selectedPack: StickerPaneSearchPack? private var isPackPanelExpanded: Bool = true private var installedPackIds = Set() private var stickerSearchContext: StickerSearchContext? private var currentSearchStickerCount: Int = 0 private var currentStickerCount: Int = 0 private let _ready = Promise() var ready: Signal { return self._ready.get() } var deactivateSearchBar: (() -> Void)? var updateActivity: ((Bool) -> Void)? var selectedPackUpdated: ((StickerPaneSearchSelectedPack?) -> Void)? private let installDisposable = MetaDisposable() init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: ChatEntityKeyboardInputNode.Interaction, inputNodeInteraction: ChatMediaInputNodeInteraction, stickerActionTitle: String?) { self.context = context self.interaction = interaction self.inputNodeInteraction = inputNodeInteraction self.theme = theme self.strings = strings let trendingPaneInteraction = ChatMediaInputTrendingPane.Interaction( sendSticker: interaction.sendSticker, presentController: interaction.presentController, getNavigationController: interaction.getNavigationController ) self.trendingPane = ChatMediaInputTrendingPane(context: context, forceTheme: theme, interaction: trendingPaneInteraction, getItemIsPreviewed: { [weak inputNodeInteraction] item in return inputNodeInteraction?.previewedStickerPackItemFile?.id == item.file.id }, isPane: false) self.trendingPane.stickerActionTitle = stickerActionTitle 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 self.topEdgeEffectView.isUserInteractionEnabled = false self.bottomEdgeEffectView.isUserInteractionEnabled = false self.bottomEdgeEffectView.alpha = 0.0 super.init() self.addSubnode(self.trendingPane) self.addSubnode(self.gridNode) self.addSubnode(self.notFoundNode) self.view.addSubview(self.topEdgeEffectView) self.view.addSubview(self.bottomEdgeEffectView) self.gridNode.scrollView.alwaysBounceVertical = true self.gridNode.scrollingInitiated = { [weak self] in self?.deactivateSearchBar?() } self.gridNode.visibleItemsUpdated = { [weak self] visibleItems in guard let self else { return } self.updatePackPanelExpansionFromScroll() guard let (bottomVisible, _) = visibleItems.bottomVisible else { return } guard self.selectedPack == nil, self.currentStickerCount != 0 else { return } if bottomVisible >= max(0, self.currentStickerCount - 8) { self.stickerSearchContext?.loadMore() } } self.trendingPane.scrollingInitiated = { [weak self] in self?.deactivateSearchBar?() } self.searchInteraction = 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 presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: theme) let controller = strongSelf.context.sharedContext.makeStickerPackScreen( context: strongSelf.context, updatedPresentationData: (presentationData, .single(presentationData)), mainStickerPack: packReference, stickerPacks: [packReference], loadedStickerPacks: [], actionTitle: stickerActionTitle, isEditing: false, expandIfNeeded: false, parentNavigationController: strongSelf.interaction.getNavigationController(), sendSticker: { [weak self] fileReference, sourceView, sourceRect in if let strongSelf = self { return strongSelf.interaction.sendSticker(fileReference, false, false, nil, false, sourceView, sourceRect, nil, []) } else { return false } }, actionPerformed: nil ) strongSelf.interaction.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): let info = info._parse() if installed { return .complete() } else { return preloadedStickerPackThumbnail(account: context.account, info: StickerPackCollectionInfo.Accessor(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 { subscriber in let presentationData = context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: theme) let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { cancelImpl?() })) self?.interaction.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.interaction.getNavigationController() { for controller in navigationController.overlayControllers { if let controller = controller as? UndoOverlayController { controller.dismissWithCommitActionAndReplacementAnimation() animateInAsReplacement = true } } } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: theme) strongSelf.interaction.getNavigationController()?.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, sourceLayer, sourceRect in if let self { let sourceRect = sourceView.convert(sourceRect, to: self.view) let _ = self.interaction.sendSticker(file, false, false, nil, false, self.view, sourceRect, sourceLayer, []) } }, getItemIsPreviewed: { item in return inputNodeInteraction.previewedStickerPackItemFile?.id == item.file.id }) self._ready.set(self.trendingPane.ready) self.trendingPane.activate() self.updateThemeAndStrings(theme: theme, strings: strings) } deinit { self.searchDisposable.dispose() self.selectedPackDisposable.dispose() self.installDisposable.dispose() } func updateText(_ text: String, languageCode: String?) { if self.selectedPack != nil { self.clearSelectedPack(applySearchResults: false) } self.stickerSearchContext = nil self.currentSearchStickerCount = 0 self.currentStickerCount = 0 self.isPackPanelExpanded = true let _ = self.currentRemotePacks.swap(nil) let query = text.trimmingCharacters(in: .whitespacesAndNewlines) let signal: Signal<(StickerPaneSearchStickerState, FoundStickerSets, Bool, FoundStickerSets?)?, NoError> if query.count >= 2 { let context = self.context let stickers: Signal if query.isSingleEmoji { let searchContext = context.engine.stickers.stickerSearchContext(query: nil, emoticon: [query.basicEmoji.0]) stickers = searchContext.state |> map { state -> StickerPaneSearchStickerState in return StickerPaneSearchStickerState(context: searchContext, items: state.items, isLoadingMore: state.isLoadingMore) } } else if query.count > 1, let languageCode = languageCode, !languageCode.isEmpty && languageCode != "emoji" { var keywords = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query.lowercased(), completeMatch: query.count < 3) if !languageCode.lowercased().hasPrefix("en") { keywords = keywords |> 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 } ) } } stickers = .single(StickerPaneSearchStickerState(context: nil, items: [], isLoadingMore: true)) |> then( keywords |> mapToSignal { keywords -> Signal in let emoticon = keywords.flatMap { $0.emoticons }.map { $0.basicEmoji.0 } let searchContext = context.engine.stickers.stickerSearchContext(query: query, emoticon: emoticon, inputLanguageCode: languageCode) return searchContext.state |> map { state -> StickerPaneSearchStickerState in return StickerPaneSearchStickerState(context: searchContext, items: state.items, isLoadingMore: state.isLoadingMore) } }) } else { stickers = .single(StickerPaneSearchStickerState(context: nil, items: [], isLoadingMore: false)) } let local = context.engine.stickers.searchStickerSets(query: query) let remote = context.engine.stickers.searchStickerSetsRemotely(query: query) |> 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 in var installedPacks = Set() 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 -> (StickerPaneSearchStickerState, FoundStickerSets, Bool, FoundStickerSets?)? in return (stickers, packs.0, packs.1 && !stickers.isLoadingMore, packs.2) } } 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.searchInteraction else { return } if let (stickers, packs, final, remote) = result { strongSelf.stickerSearchContext = stickers.context strongSelf.currentSearchStickerCount = stickers.items.count strongSelf.currentStickerCount = stickers.items.count strongSelf.updateActivity?(stickers.items.isEmpty && stickers.isLoadingMore) if let remote = remote { let _ = strongSelf.currentRemotePacks.swap(remote) } strongSelf.gridNode.isHidden = false strongSelf.trendingPane.isHidden = true let previousPacks = strongSelf.currentPacks let entries = strongSelf.entries(stickers: stickers.items) var packItems: [StickerPaneSearchPack] = strongSelf.packs(from: packs) if !strongSelf.installedPackIds.isEmpty { packItems = packItems.map { pack in if strongSelf.installedPackIds.contains(pack.info.id) && !pack.installed { return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: true) } else { return pack } } } strongSelf.currentSearchEntries = entries strongSelf.currentPacks = packItems if let selectedPack = strongSelf.selectedPack, let updatedPack = packItems.first(where: { $0.info.id == selectedPack.info.id }), selectedPack.installed != updatedPack.installed { strongSelf.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: updatedPack.installed) } strongSelf.currentSearchIsFinal = final strongSelf.searchIsActive = true if strongSelf.selectedPack == nil { strongSelf.enqueueEntries(entries, interaction: interaction) strongSelf.updateNotFound() } if previousPacks != packItems { strongSelf.requestLayout(transition: .immediate) } } else { let _ = strongSelf.currentRemotePacks.swap(nil) strongSelf.stickerSearchContext = nil strongSelf.currentSearchStickerCount = 0 strongSelf.currentStickerCount = 0 strongSelf.currentSearchEntries = [] strongSelf.currentPacks = [] strongSelf.currentSearchIsFinal = false strongSelf.searchIsActive = false strongSelf.updateActivity?(false) strongSelf.gridNode.isHidden = true strongSelf.notFoundNode.isHidden = true strongSelf.trendingPane.isHidden = false strongSelf.enqueueEntries([], interaction: interaction) strongSelf.requestLayout(transition: .immediate) } } })) } private func entries(stickers: [FoundStickerItem]) -> [StickerSearchEntry] { var entries: [StickerSearchEntry] = [] var index = 0 var existingStickerIds = Set() for sticker in stickers { if let id = sticker.file.id, !existingStickerIds.contains(id) { entries.append(.sticker(index: index, code: nil, stickerItem: sticker, theme: self.theme)) index += 1 existingStickerIds.insert(id) } } return entries } private func packs(from packs: FoundStickerSets) -> [StickerPaneSearchPack] { var result: [StickerPaneSearchPack] = [] var existingIds = Set() for (collectionId, info, _, installed) in packs.infos { guard !existingIds.contains(collectionId), let info = info as? StickerPackCollectionInfo else { continue } existingIds.insert(collectionId) var topItems: [StickerPackItem] = [] for entry in packs.entries { if let item = entry.item as? StickerPackItem, entry.index.collectionId == collectionId { topItems.append(item) } } result.append(StickerPaneSearchPack(info: info, topItems: topItems, installed: installed)) } return result } private func entries(packItems: [StickerPackItem]) -> [StickerSearchEntry] { var entries: [StickerSearchEntry] = [] var existingStickerIds = Set() var index = 0 for item in packItems { let file = item.file._parse() if let id = file.id, !existingStickerIds.contains(id) { entries.append(.sticker(index: index, code: nil, stickerItem: FoundStickerItem(file: file, stringRepresentations: item.getStringRepresentationsOfIndexKeys()), theme: self.theme)) existingStickerIds.insert(id) index += 1 } } return entries } private var shouldDisplayPackPanel: Bool { return self.searchIsActive && !self.currentPacks.isEmpty } private var currentPackPanelHeight: CGFloat { guard self.shouldDisplayPackPanel else { return 0.0 } return packPanelHeight } private var currentVisiblePackPanelHeight: CGFloat { guard self.shouldDisplayPackPanel else { return 0.0 } return self.isPackPanelExpanded ? packPanelHeight : collapsedPackPanelHeight } private var isInstallPackButtonVisible: Bool { guard let selectedPack = self.selectedPack else { return false } return !selectedPack.installed && !self.installedPackIds.contains(selectedPack.info.id) } private func markPackInstalled(_ id: ItemCollectionId) { self.installedPackIds.insert(id) if let selectedPack = self.selectedPack, selectedPack.info.id == id, !selectedPack.installed { self.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: true) } self.currentPacks = self.currentPacks.map { pack in if pack.info.id == id && !pack.installed { return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: true) } else { return pack } } } private func updatePackPanelExpansionFromScroll() { guard self.shouldDisplayPackPanel else { return } let contentOffsetY = self.gridNode.scrollView.contentOffset.y let shouldExpand: Bool if self.gridNode.scrollView.contentInset.top < 10.0 { shouldExpand = true } else { shouldExpand = contentOffsetY <= -packPanelHeight + 20.0 } if self.isPackPanelExpanded != shouldExpand { self.isPackPanelExpanded = shouldExpand self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) } } private func resetGridScrollToTop() { let scrollView = self.gridNode.scrollView scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } private func enqueueEntries(_ entries: [StickerSearchEntry], interaction: StickerPaneSearchInteraction, crossfade: Bool = false) { let previousEntries = self.currentEntries.swap(entries) let transition = preparedChatMediaInputGridEntryTransition(context: self.context, theme: self.theme, strings: self.strings, from: previousEntries ?? [], to: entries, interaction: interaction, inputNodeInteraction: self.inputNodeInteraction, crossfade: crossfade) self.enqueueTransition(transition) } private func updateNotFound() { if self.selectedPack != nil || !self.searchIsActive { self.notFoundNode.isHidden = true } else if self.currentSearchIsFinal || !self.currentSearchEntries.isEmpty || !self.currentPacks.isEmpty { self.notFoundNode.isHidden = !(self.currentSearchEntries.isEmpty && self.currentPacks.isEmpty) } else { self.notFoundNode.isHidden = true } } private func selectPack(_ pack: StickerPaneSearchPack) { guard let interaction = self.searchInteraction else { return } self.view.window?.endEditing(true) self.deactivateSearchBar?() self.selectedPackDisposable.set(nil) self.selectedPack = pack self.currentStickerCount = 0 self.notFoundNode.isHidden = true self.gridNode.isHidden = false self.trendingPane.isHidden = true self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: pack.info)) self.enqueueEntries(self.entries(packItems: pack.topItems), interaction: interaction, crossfade: true) self.isPackPanelExpanded = true self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) self.resetGridScrollToTop() let packId = pack.info.id self.selectedPackDisposable.set((self.context.engine.stickers.loadedStickerPack(reference: .id(id: pack.info.id.id, accessHash: pack.info.accessHash), forceActualized: false) |> deliverOnMainQueue).start(next: { [weak self] result in guard let self, let interaction = self.searchInteraction, self.selectedPack?.info.id == packId else { return } switch result { case let .result(_, items, _): self.enqueueEntries(self.entries(packItems: items), interaction: interaction) case .fetching, .none: break } })) } private func installSelectedStickerPack() { guard let selectedPack = self.selectedPack, !selectedPack.installed, !self.installedPackIds.contains(selectedPack.info.id) else { return } let context = self.context let packId = selectedPack.info.id let accessHash = selectedPack.info.accessHash self.markPackInstalled(packId) self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) let installSignal = (context.engine.stickers.loadedStickerPack(reference: .id(id: packId.id, accessHash: accessHash), forceActualized: false) |> mapToSignal { result -> Signal<(StickerPackCollectionInfo, [StickerPackItem]), NoError> in switch result { case let .result(info, items, installed): let info = info._parse() if installed { return .single((info, items)) } else { return preloadedStickerPackThumbnail(account: context.account, info: StickerPackCollectionInfo.Accessor(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) self.installDisposable.set(installSignal.start(next: { [weak self] info, items in guard let self else { return } var animateInAsReplacement = false if let navigationController = self.interaction.getNavigationController() { for controller in navigationController.overlayControllers { if let controller = controller as? UndoOverlayController { controller.dismissWithCommitActionAndReplacementAnimation() animateInAsReplacement = true } } } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme) self.interaction.getNavigationController()?.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: self.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return true })) })) } func clearSelectedPack(applySearchResults: Bool = true) { guard self.selectedPack != nil else { return } self.selectedPack = nil self.selectedPackDisposable.set(nil) self.selectedPackUpdated?(nil) if applySearchResults, let interaction = self.searchInteraction { self.currentStickerCount = self.currentSearchStickerCount self.gridNode.isHidden = !self.searchIsActive self.trendingPane.isHidden = self.searchIsActive self.enqueueEntries(self.currentSearchEntries, interaction: interaction, crossfade: true) self.updateNotFound() self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) } } func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { self.theme = theme self.strings = strings 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) if transition.crossfade, let snapshotView = self.gridNode.scrollView.snapshotContentTree() { snapshotView.frame = self.gridNode.frame self.gridNode.view.superview?.addSubview(snapshotView) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in snapshotView.removeFromSuperview() }) self.gridNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } 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) } } 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._parse())) } } 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)) } } } return nil } private func updatePackButtonsLayout(size: CGSize, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) { if self.shouldDisplayPackPanel { let componentTransition = ComponentTransition(transition) let panelSize = self.packPanel.update( transition: componentTransition, component: AnyComponent(EntityKeyboardTopPanelComponent( id: AnyHashable("stickerSearchPacks"), theme: self.theme, customTintColor: nil, items: self.currentPacks.map { pack in return EntityKeyboardTopPanelComponent.Item( id: AnyHashable(pack.info.id), isReorderable: false, content: AnyComponent(StickerSearchPackTopPanelItemComponent( context: self.context, theme: self.theme, info: pack.info, topItem: pack.topItems.first, pressed: { [weak self] in self?.selectPack(pack) } )) ) }, containerSideInset: 0.0, forceActiveItemId: self.selectedPack.flatMap { AnyHashable($0.info.id) }, displayHighlightInExpanded: true, automaticallySelectsFirstItem: false, itemSpacing: 14.0, activeContentItemIdUpdated: self.packPanelActiveItemUpdated, reorderItems: { _ in } )), environment: { EntityKeyboardTopContainerPanelEnvironment( isContentInFocus: true, height: collapsedPackPanelHeight, visibilityFractionUpdated: self.packPanelVisibilityFractionUpdated, isExpandedUpdated: { _, _ in } ) }, containerSize: CGSize(width: size.width, height: self.currentVisiblePackPanelHeight) ) if let view = self.packPanel.view { if view.superview == nil { self.view.addSubview(view) } componentTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(), size: panelSize)) } } else if let view = self.packPanel.view { view.removeFromSuperview() } let isVisible = self.isInstallPackButtonVisible let componentTransition = ComponentTransition(transition) let edgeEffectHeight: CGFloat = isVisible ? 88.0 + bottomInset : 0.0 let edgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - edgeEffectHeight), size: CGSize(width: size.width, height: edgeEffectHeight)) transition.updateFrame(view: self.bottomEdgeEffectView, frame: edgeEffectFrame) self.bottomEdgeEffectView.update( content: self.theme.chat.inputMediaPanel.stickersBackgroundColor.withAlphaComponent(1.0), blur: true, alpha: 1.0, rect: edgeEffectFrame, edge: .bottom, edgeSize: min(edgeEffectFrame.height, 80.0), transition: componentTransition ) transition.updateAlpha(layer: self.bottomEdgeEffectView.layer, alpha: isVisible ? 1.0 : 0.0) if isVisible, let selectedPack = self.selectedPack { let buttonTitle = self.strings.StickerPack_AddStickerCount(selectedPack.info.count) let buttonForegroundColor = self.theme.list.itemCheckColors.foregroundColor let buttonBackgroundColor = self.theme.list.itemCheckColors.fillColor let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: bottomInset, innerDiameter: 52.0, sideInset: 30.0) let buttonSize = self.selectedPackAddButton.update( transition: componentTransition, component: AnyComponent(ButtonComponent( background: ButtonComponent.Background( style: .actualGlass, color: buttonBackgroundColor, foreground: buttonForegroundColor, pressedColor: buttonBackgroundColor.withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity( id: AnyHashable(buttonTitle), component: AnyComponent(Text(text: buttonTitle, font: Font.semibold(17.0), color: buttonForegroundColor)) ), action: { [weak self] in self?.installSelectedStickerPack() } )), environment: {}, containerSize: CGSize(width: max(0.0, size.width - buttonInsets.left - buttonInsets.right), height: 52.0) ) if let buttonView = self.selectedPackAddButton.view { if buttonView.superview == nil { self.view.addSubview(buttonView) } buttonView.isUserInteractionEnabled = true buttonView.frame = CGRect(origin: CGPoint(x: floor((size.width - buttonSize.width) / 2.0), y: size.height - bottomInset - buttonInsets.bottom - buttonSize.height), size: buttonSize) componentTransition.setAlpha(view: buttonView, alpha: 1.0) } } else if let buttonView = self.selectedPackAddButton.view { buttonView.isUserInteractionEnabled = false componentTransition.setAlpha(view: buttonView, alpha: 0.0) } } func requestLayout(transition: ContainedViewLayoutTransition) { guard let (size, leftInset, rightInset, bottomInset, inputHeight, deviceMetrics) = self.validLayout else { return } self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, inputHeight: inputHeight, deviceMetrics: deviceMetrics, transition: transition) } func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) { let firstLayout = self.validLayout == nil self.validLayout = (size, leftInset, rightInset, bottomInset, inputHeight, deviceMetrics) let edgeEffectHeight: CGFloat = 80.0 let edgeEffectFrame = CGRect(origin: .zero, size: CGSize(width: size.width, height: edgeEffectHeight)) transition.updateFrame(view: self.topEdgeEffectView, frame: edgeEffectFrame) self.topEdgeEffectView.update( content: self.theme.chat.inputMediaPanel.stickersBackgroundColor.withAlphaComponent(1.0), blur: true, alpha: 1.0, rect: edgeEffectFrame, edge: .top, edgeSize: edgeEffectFrame.height, transition: ComponentTransition(transition) ) transition.updateAlpha(layer: self.topEdgeEffectView.layer, alpha: self.shouldDisplayPackPanel ? 1.0 : 0.0) self.updatePackButtonsLayout(size: size, bottomInset: bottomInset, transition: transition) if let image = self.notFoundNode.image { let areaHeight = max(0.0, 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) let gridTopInset: CGFloat = 4.0 + self.currentPackPanelHeight self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: contentFrame.size, insets: UIEdgeInsets(top: gridTopInset, left: 0.0, bottom: 4.0 + bottomInset + 64.0, 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 }) if let view = self.packPanel.view { view.alpha = 0.0 ComponentTransition(transition).setAlpha(view: view, alpha: 1.0) } self.topEdgeEffectView.alpha = 0.0 ComponentTransition(transition).setAlpha(view: self.topEdgeEffectView, alpha: self.shouldDisplayPackPanel ? 1.0 : 0.0) self.bottomEdgeEffectView.alpha = 0.0 ComponentTransition(transition).setAlpha(view: self.bottomEdgeEffectView, alpha: self.isInstallPackButtonVisible ? 1.0 : 0.0) if let buttonView = self.selectedPackAddButton.view { buttonView.alpha = 0.0 ComponentTransition(transition).setAlpha(view: buttonView, alpha: self.isInstallPackButtonVisible ? 1.0 : 0.0) } 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 }) if let view = self.packPanel.view { ComponentTransition(transition).setAlpha(view: view, alpha: 0.0) } ComponentTransition(transition).setAlpha(view: self.topEdgeEffectView, alpha: 0.0) ComponentTransition(transition).setAlpha(view: self.bottomEdgeEffectView, alpha: 0.0) if let buttonView = self.selectedPackAddButton.view { ComponentTransition(transition).setAlpha(view: buttonView, alpha: 0.0) } transition.updateAlpha(node: self.trendingPane, alpha: 0.0, completion: { _ in }) transition.updateAlpha(node: self.notFoundNode, alpha: 0.0, completion: { _ in }) } }