import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import AccountContext import ComponentFlow import ViewControllerComponent import EntityKeyboard import PagerComponent import FeaturedStickersScreen import TelegramNotices import ChatEntityKeyboardInputNode import ContextUI import ChatPresentationInterfaceState import MediaEditor import EntityKeyboardGifContent import CameraButtonComponent import BundleIconComponent import UndoUI import GalleryUI private final class StickerSelectionComponent: Component { typealias EnvironmentType = Empty let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings let deviceMetrics: DeviceMetrics let topInset: CGFloat let bottomInset: CGFloat let content: StickerPickerInputData let backgroundColor: UIColor let separatorColor: UIColor let getController: () -> StickerPickerScreen? init( context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, deviceMetrics: DeviceMetrics, topInset: CGFloat, bottomInset: CGFloat, content: StickerPickerInputData, backgroundColor: UIColor, separatorColor: UIColor, getController: @escaping () -> StickerPickerScreen? ) { self.context = context self.theme = theme self.strings = strings self.deviceMetrics = deviceMetrics self.topInset = topInset self.bottomInset = bottomInset self.content = content self.backgroundColor = backgroundColor self.separatorColor = separatorColor self.getController = getController } public static func ==(lhs: StickerSelectionComponent, rhs: StickerSelectionComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings != rhs.strings { return false } if lhs.deviceMetrics != rhs.deviceMetrics { return false } if lhs.topInset != rhs.topInset { return false } if lhs.bottomInset != rhs.bottomInset { return false } if lhs.content != rhs.content { return false } if lhs.backgroundColor != rhs.backgroundColor { return false } if lhs.separatorColor != rhs.separatorColor { return false } return true } final class KeyboardClippingView: UIView { var hitEdgeInsets: UIEdgeInsets = .zero override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let bounds = self.bounds.inset(by: self.hitEdgeInsets) return bounds.contains(point) } } public final class View: UIView { fileprivate let keyboardView: ComponentView private let keyboardClippingView: KeyboardClippingView private let panelHostView: PagerExternalTopPanelContainer private let panelBackgroundView: BlurredBackgroundView private let panelSeparatorView: UIView private var component: StickerSelectionComponent? private weak var state: EmptyComponentState? private var interaction: ChatEntityKeyboardInputNode.Interaction? private var inputNodeInteraction: ChatMediaInputNodeInteraction? private var searchVisible = false private var forceUpdate = false private var ignoreNextZeroScrollingOffset = false private var topPanelScrollingOffset: CGFloat = 0.0 private var keyboardContentId: AnyHashable? override init(frame: CGRect) { self.keyboardView = ComponentView() self.keyboardClippingView = KeyboardClippingView() self.panelHostView = PagerExternalTopPanelContainer() self.panelBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) self.panelBackgroundView.isUserInteractionEnabled = false self.panelSeparatorView = UIView() super.init(frame: frame) self.addSubview(self.keyboardClippingView) self.addSubview(self.panelBackgroundView) self.addSubview(self.panelSeparatorView) self.addSubview(self.panelHostView) self.interaction = ChatEntityKeyboardInputNode.Interaction( sendSticker: { [weak self] file, silent, schedule, query, clearInput, sourceView, sourceRect, sourceLayer, _ in if let self, let controller = self.component?.getController() { controller.forEachController { c in if let c = c as? (ViewController & StickerPackScreen) { c.dismiss(animated: true) } return true } controller.window?.forEachController({ c in if let c = c as? (ViewController & StickerPackScreen) { c.dismiss(animated: true) } }) if controller.completion(.file(file, .sticker)) { controller.dismiss(animated: true) } } return false }, sendEmoji: { _, _, _ in }, sendGif: { [weak self] file, _, _, _, _ in if let self, let controller = self.component?.getController() { if controller.completion(.video(file.media)) { controller.dismiss(animated: true) } } return false }, sendBotContextResultAsGif: { [weak self] collection, result, _, _, _, _ in if let self, let controller = self.component?.getController() { if case let .internalReference(reference) = result { if let file = reference.file { if controller.completion(.video(file)) { controller.dismiss(animated: true) } } } } return false }, updateChoosingSticker: { _ in }, switchToTextInput: {}, dismissTextInput: {}, insertText: { _ in }, backwardsDeleteText: {}, openStickerEditor: {}, presentController: { [weak self] c, a in if let self, let controller = self.component?.getController() { controller.present(c, in: .window(.root), with: a) } }, presentGlobalOverlayController: { [weak self] c, a in if let self, let controller = self.component?.getController() { controller.presentInGlobalOverlay(c, with: a) } }, getNavigationController: { return nil }, requestLayout: { transition in let _ = transition } ) self.inputNodeInteraction = ChatMediaInputNodeInteraction( navigateToCollectionId: { _ in }, navigateBackToStickers: { }, setGifMode: { _ in }, openSettings: { }, openTrending: { _ in }, dismissTrendingPacks: { _ in }, toggleSearch: { _, _, _ in }, openPeerSpecificSettings: { }, dismissPeerSpecificSettings: { }, clearRecentlyUsedStickers: { } ) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } func scrolledToItemGroup() { self.topPanelScrollingOffset = 30.0 self.ignoreNextZeroScrollingOffset = true self.state?.updated(transition: .easeInOut(duration: 0.2)) } func update(component: StickerSelectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.backgroundColor = component.backgroundColor let panelBackgroundColor = component.backgroundColor.withMultipliedAlpha(0.85) self.panelBackgroundView.updateColor(color: panelBackgroundColor, transition: .immediate) self.panelSeparatorView.backgroundColor = component.separatorColor self.component = component self.state = state let topPanelHeight: CGFloat = 42.0 let topInset = component.topInset let controller = component.getController() let defaultToEmoji = controller?.defaultToEmoji ?? false let context = component.context let stickerPeekBehavior = EmojiContentPeekBehaviorImpl( context: context, forceTheme: controller?.forceDark == true ? defaultDarkColorPresentationTheme : nil, interaction: nil, chatPeerId: nil, present: { c, a in } ) let isFullscreen = controller?.isFullscreen == true let keyboardSize = self.keyboardView.update( transition: transition.withUserData(EmojiPagerContentComponent.SynchronousLoadBehavior(isDisabled: true)), component: AnyComponent(EntityKeyboardComponent( theme: component.theme, strings: component.strings, isContentInFocus: true, containerInsets: UIEdgeInsets(top: topPanelHeight - 34.0 + topInset, left: 0.0, bottom: component.bottomInset, right: 0.0), topPanelInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0), emojiContent: component.content.emoji, stickerContent: component.content.stickers, maskContent: nil, gifContent: component.content.gifs, hasRecentGifs: !isFullscreen, availableGifSearchEmojies: [], defaultToEmojiTab: defaultToEmoji, externalTopPanelContainer: self.panelHostView, externalBottomPanelContainer: nil, displayTopPanelBackground: .blur, topPanelExtensionUpdated: { _, _ in }, topPanelScrollingOffset: { [weak self] offset, transition in if let self { if self.ignoreNextZeroScrollingOffset && offset == 0.0 { } else { self.ignoreNextZeroScrollingOffset = false self.topPanelScrollingOffset = offset } } }, hideInputUpdated: { [weak self] _, searchVisible, transition in guard let self else { return } self.forceUpdate = true self.searchVisible = searchVisible self.state?.updated(transition: transition) }, hideTopPanelUpdated: { _, _ in }, switchToTextInput: {}, switchToGifSubject: { _ in }, reorderItems: { _, _ in }, makeSearchContainerNode: { [weak self] content in guard let self, let interaction = self.interaction, let inputNodeInteraction = self.inputNodeInteraction, let component = self.component, let controller = component.getController() else { return nil } let mappedMode: ChatMediaInputSearchMode switch content { case .stickers: mappedMode = .sticker case .gifs: mappedMode = .gif } var presentationData = context.sharedContext.currentPresentationData.with { $0 } if controller.forceDark == true { presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme) } let searchContainerNode = PaneSearchContainerNode( context: context, theme: presentationData.theme, strings: presentationData.strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction, mode: mappedMode, stickerActionTitle: presentationData.strings.StickerPack_AddSticker, trendingGifsPromise: self.component?.getController()?.node.trendingGifsPromise ?? Promise(nil), cancel: { }, peekBehavior: stickerPeekBehavior ) searchContainerNode.openGifContextMenu = { [weak self] item, sourceNode, sourceRect, gesture, isSaved in guard let self, let node = self.component?.getController()?.node else { return } node.openGifContextMenu(file: item.file, contextResult: item.contextResult, sourceView: sourceNode.view, sourceRect: sourceRect, gesture: gesture, isSaved: isSaved) } return searchContainerNode }, contentIdUpdated: { [weak self] id in guard let self else { return } self.keyboardContentId = id }, deviceMetrics: component.deviceMetrics, hiddenInputHeight: 0.0, inputHeight: 0.0, displayBottomPanel: !isFullscreen, isExpanded: true, clipContentToTopPanel: false, useExternalSearchContainer: false )), environment: {}, forceUpdate: self.forceUpdate, containerSize: availableSize ) self.forceUpdate = false if let keyboardComponentView = self.keyboardView.view { if keyboardComponentView.superview == nil { self.keyboardClippingView.addSubview(keyboardComponentView) } if panelBackgroundColor.alpha < 0.01 { self.keyboardClippingView.clipsToBounds = true } else { self.keyboardClippingView.clipsToBounds = false } transition.setFrame(view: self.keyboardClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight + topInset), size: CGSize(width: availableSize.width, height: availableSize.height - topPanelHeight - topInset))) self.keyboardClippingView.hitEdgeInsets = UIEdgeInsets(top: -topPanelHeight - topInset, left: 0.0, bottom: 0.0, right: 0.0) transition.setFrame(view: keyboardComponentView, frame: CGRect(origin: CGPoint(x: 0.0, y: -topPanelHeight - topInset), size: keyboardSize)) transition.setFrame(view: self.panelHostView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight + topInset - 34.0), size: CGSize(width: keyboardSize.width, height: 0.0))) transition.setFrame(view: self.panelBackgroundView, frame: CGRect(origin: CGPoint(), size: CGSize(width: keyboardSize.width, height: topPanelHeight + topInset))) self.panelBackgroundView.update(size: self.panelBackgroundView.bounds.size, transition: transition.containedViewLayoutTransition) let topPanelAlpha: CGFloat if self.searchVisible || self.keyboardContentId == AnyHashable("gifs") { topPanelAlpha = 0.0 if isFullscreen, let navigationBar = controller?.navigationBar, navigationBar.alpha > 0.0 { transition.setAlpha(view: navigationBar.view, alpha: 0.0) } } else if isFullscreen { topPanelAlpha = 1.0 if let navigationBar = controller?.navigationBar, navigationBar.alpha < 1.0 { transition.setAlpha(view: navigationBar.view, alpha: 1.0) } } else { topPanelAlpha = max(0.0, min(1.0, (self.topPanelScrollingOffset / 20.0))) } transition.setAlpha(view: self.panelBackgroundView, alpha: topPanelAlpha) transition.setAlpha(view: self.panelSeparatorView, alpha: topPanelAlpha) transition.setFrame(view: self.panelSeparatorView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight + topInset - UIScreenPixel), size: CGSize(width: keyboardSize.width, height: UIScreenPixel))) } return availableSize } public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if self.searchVisible, let keyboardView = self.keyboardView.view, let keyboardResult = keyboardView.hitTest(self.convert(point, to: keyboardView), with: event) { return keyboardResult } return result } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public class StickerPickerScreen: ViewController { final class Node: ViewControllerTracingNode, ASScrollViewDelegate, ASGestureRecognizerDelegate { private var presentationData: PresentationData private weak var controller: StickerPickerScreen? private let theme: PresentationTheme let dim: ASDisplayNode let wrappingView: UIView let containerView: UIView let hostView: ComponentHostView fileprivate var content: StickerPickerInputData? private let contentDisposable = MetaDisposable() private var hasRecentGifsDisposable: Disposable? fileprivate let trendingGifsPromise = Promise(nil) private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation? private(set) var isExpanded = false private var panGestureRecognizer: UIPanGestureRecognizer? private var panGestureArguments: (topInset: CGFloat, offset: CGFloat, scrollView: UIScrollView?, listNode: ListView?)? private var currentIsVisible: Bool = false private var currentLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)? fileprivate var temporaryDismiss = false private var gifMode: GifPagerContentComponent.Subject? { didSet { if let gifMode = self.gifMode, gifMode != oldValue { self.reloadGifContext() } } } private var gifContext: GifContext? { didSet { if let gifContext = self.gifContext { self.gifComponent.set(gifContext.component) } } } private let gifComponent = Promise() private var gifInputInteraction: GifPagerContentComponent.InputInteraction? private struct EmojiSearchResult { var groups: [EmojiPagerContentComponent.ItemGroup] var id: AnyHashable var version: Int var isPreset: Bool } private struct EmojiSearchState { var result: EmojiSearchResult? var isSearching: Bool init(result: EmojiSearchResult?, isSearching: Bool) { self.result = result self.isSearching = isSearching } } private let emojiSearchDisposable = MetaDisposable() private let emojiSearchState = Promise(EmojiSearchState(result: nil, isSearching: false)) private var emojiSearchStateValue = EmojiSearchState(result: nil, isSearching: false) { didSet { self.emojiSearchState.set(.single(self.emojiSearchStateValue)) } } private let stickerSearchDisposable = MetaDisposable() private let stickerSearchState = Promise(EmojiSearchState(result: nil, isSearching: false)) private var stickerSearchStateValue = EmojiSearchState(result: nil, isSearching: false) { didSet { self.stickerSearchState.set(.single(self.stickerSearchStateValue)) } } private var storyStickersContentView: StoryStickersContentView? init(context: AccountContext, controller: StickerPickerScreen, theme: PresentationTheme) { self.presentationData = context.sharedContext.currentPresentationData.with { $0 } self.controller = controller self.theme = theme self.dim = ASDisplayNode() self.dim.alpha = 0.0 self.dim.backgroundColor = UIColor(white: 0.0, alpha: 0.25) self.wrappingView = SparseContainerView() self.containerView = SparseContainerView() self.hostView = ComponentHostView() super.init() self.containerView.clipsToBounds = true self.containerView.backgroundColor = .clear if !controller.isFullscreen { self.addSubnode(self.dim) } self.view.addSubview(self.wrappingView) self.wrappingView.addSubview(self.containerView) self.containerView.addSubview(self.hostView) if controller.hasInteractiveStickers { self.storyStickersContentView = StoryStickersContentView(frame: .zero) self.storyStickersContentView?.locationAction = { [weak self] in self?.controller?.presentLocationPicker() } self.storyStickersContentView?.audioAction = { [weak self] in self?.controller?.presentAudioPicker() } self.storyStickersContentView?.reactionAction = { [weak self] in self?.controller?.addReaction() } self.storyStickersContentView?.linkAction = { [weak self] in self?.controller?.addLink() } } let gifItems: Signal if controller.hasGifs { let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) |> map { savedGifs -> Bool in return !savedGifs.isEmpty } self.hasRecentGifsDisposable = (hasRecentGifs |> deliverOnMainQueue).start(next: { [weak self] hasRecentGifs in guard let strongSelf = self else { return } if let gifMode = strongSelf.gifMode { if !hasRecentGifs, case .recent = gifMode { strongSelf.gifMode = .trending } } else { strongSelf.gifMode = hasRecentGifs ? .recent : .trending } }).strict() self.trendingGifsPromise.set(.single(nil)) self.trendingGifsPromise.set(paneGifSearchForQuery(context: context, query: "", offset: nil, incompleteResults: true, delayRequest: false, updateActivity: nil) |> map { items -> ChatMediaInputGifPaneTrendingState? in if let items = items { return ChatMediaInputGifPaneTrendingState(files: items.files, nextOffset: items.nextOffset) } else { return nil } }) let gifInputInteraction = GifPagerContentComponent.InputInteraction( performItemAction: { [weak self] item, view, rect in guard let self, let controller = self.controller else { return } if controller.completion(.video(item.file.media)) { controller.dismiss(animated: true) } }, openGifContextMenu: { [weak self] item, sourceView, sourceRect, gesture, isSaved in guard let self else { return } self.openGifContextMenu(file: item.file, contextResult: item.contextResult, sourceView: sourceView, sourceRect: sourceRect, gesture: gesture, isSaved: isSaved) }, loadMore: { [weak self] token in guard let strongSelf = self, let gifContext = strongSelf.gifContext else { return } gifContext.loadMore(token: token) }, openSearch: { [weak self] in if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View { if let pagerView = componentView.keyboardView.view as? EntityKeyboardComponent.View { pagerView.openSearch() } self.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) } }, updateSearchQuery: { [weak self] query in guard let self else { return } if let query { self.gifMode = .emojiSearch(query) } else { self.gifMode = .recent } }, hideBackground: true, hasSearch: true ) self.gifInputInteraction = gifInputInteraction gifItems = .single(EntityKeyboardGifContent( hasRecentGifs: true, component: GifPagerContentComponent( context: context, inputInteraction: gifInputInteraction, subject: .recent, items: [], isLoading: false, loadMoreToken: nil, displaySearchWithPlaceholder: nil, searchCategories: nil, searchInitiallyHidden: true, searchState: .empty(hasResults: false), hideBackground: true ) )) } else { gifItems = .single(nil) } let data = combineLatest( queue: Queue.mainQueue(), controller.inputData, gifItems |> then(self.gifComponent.get() |> map(Optional.init)), self.stickerSearchState.get(), self.emojiSearchState.get() ) self.contentDisposable.set(data.start(next: { [weak self] inputData, gifData, stickerSearchState, emojiSearchState in if let strongSelf = self { guard var inputData = inputData as? StickerPickerInputData else { return } let presentationData = strongSelf.presentationData inputData.gifs = gifData?.component if let emoji = inputData.emoji { if let emojiSearchResult = emojiSearchState.result { var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults? if !emojiSearchResult.groups.contains(where: { !$0.items.isEmpty || $0.fillWithLoadingPlaceholders }) { emptySearchResults = EmojiPagerContentComponent.EmptySearchResults( text: presentationData.strings.EmojiSearch_SearchEmojiEmptyResult, iconFile: nil ) } let defaultSearchState: EmojiPagerContentComponent.SearchState = emojiSearchResult.isPreset ? .active : .empty(hasResults: true) inputData.emoji = emoji.withUpdatedItemGroups(panelItemGroups: emoji.panelItemGroups, contentItemGroups: emojiSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: emojiSearchResult.id, version: emojiSearchResult.version), emptySearchResults: emptySearchResults, searchState: emojiSearchState.isSearching ? .searching : defaultSearchState) } else if emojiSearchState.isSearching { inputData.emoji = emoji.withUpdatedItemGroups(panelItemGroups: emoji.panelItemGroups, contentItemGroups: emoji.contentItemGroups, itemContentUniqueId: emoji.itemContentUniqueId, emptySearchResults: emoji.emptySearchResults, searchState: .searching) } } if let stickers = inputData.stickers { if let stickerSearchResult = stickerSearchState.result { var stickerSearchResults: EmojiPagerContentComponent.EmptySearchResults? if !stickerSearchResult.groups.contains(where: { !$0.items.isEmpty || $0.fillWithLoadingPlaceholders }) { stickerSearchResults = EmojiPagerContentComponent.EmptySearchResults( text: presentationData.strings.EmojiSearch_SearchStickersEmptyResult, iconFile: nil ) } let defaultSearchState: EmojiPagerContentComponent.SearchState = stickerSearchResult.isPreset ? .active : .empty(hasResults: true) inputData.stickers = stickers.withUpdatedItemGroups(panelItemGroups: stickers.panelItemGroups, contentItemGroups: stickerSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: stickerSearchResult.id, version: stickerSearchResult.version), emptySearchResults: stickerSearchResults, searchState: stickerSearchState.isSearching ? .searching : defaultSearchState) } else if stickerSearchState.isSearching { inputData.stickers = stickers.withUpdatedItemGroups(panelItemGroups: stickers.panelItemGroups, contentItemGroups: stickers.contentItemGroups, itemContentUniqueId: stickers.itemContentUniqueId, emptySearchResults: stickers.emptySearchResults, searchState: .searching) } } strongSelf.updateContent(inputData) } })) } deinit { self.contentDisposable.dispose() self.emojiSearchDisposable.dispose() self.stickerSearchDisposable.dispose() self.hasRecentGifsDisposable?.dispose() } private func reloadGifContext() { if let gifInputInteraction = self.gifInputInteraction, let gifMode = self.gifMode, let context = self.controller?.context { self.gifContext = GifContext(context: context, subject: gifMode, gifInputInteraction: gifInputInteraction, trendingGifs: self.trendingGifsPromise.get()) } } fileprivate func openGifContextMenu(file: FileMediaReference, contextResult: (ChatContextResultCollection, ChatContextResult)?, sourceView: UIView, sourceRect: CGRect, gesture: ContextGesture, isSaved: Bool) { guard let controller = self.controller else { return } let context = controller.context let canSaveGif: Bool if file.media.fileId.namespace == Namespaces.Media.CloudFile { canSaveGif = true } else { canSaveGif = false } let _ = (context.engine.stickers.isGifSaved(id: file.media.fileId) |> deliverOnMainQueue).start(next: { [weak self] isGifSaved in var isGifSaved = isGifSaved if !canSaveGif { isGifSaved = false } let presentationData = context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkPresentationTheme) let message = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: PeerId(0), namespace: Namespaces.Message.Local, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file.media], peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) let gallery = GalleryController(context: context, source: .standaloneMessage(message), streamSingleVideo: true, replaceRootController: { _, _ in }, baseNavigationController: nil) gallery.setHintWillBePresentedInPreviewingContext(true) var items: [ContextMenuItem] = [] items.append(.action(ContextMenuActionItem(text: presentationData.strings.MediaEditor_AddGif, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in f(.default) if let self, let controller = self.controller { if isSaved { if controller.completion(.video(file.media)) { self.controller?.dismiss(animated: true) } } else if let (_, result) = contextResult { if case let .internalReference(reference) = result { if let file = reference.file { if controller.completion(.video(file)) { self.controller?.dismiss(animated: true) } } } } } }))) if isSaved || isGifSaved { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_ContextMenuDelete, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.actionSheet.destructiveActionTextColor) }, action: { _, f in f(.dismissWithoutContent) let _ = removeSavedGif(postbox: context.account.postbox, mediaId: file.media.fileId).start() }))) } else if canSaveGif && !isGifSaved { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Preview_SaveGif, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in f(.dismissWithoutContent) guard let self else { return } let presentationData = context.sharedContext.currentPresentationData.with { $0 } let _ = (toggleGifSaved(account: context.account, fileReference: file, saved: true) |> deliverOnMainQueue).start(next: { [weak self] result in guard let controller = self?.controller else { return } switch result { case .generic: controller.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: nil, text: presentationData.strings.Gallery_GifSaved, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let text: String if limit == premiumLimit || premiumConfiguration.isPremiumDisabled { text = presentationData.strings.Premium_MaxSavedGifsFinalText } else { text = presentationData.strings.Premium_MaxSavedGifsText("\(premiumLimit)").string } controller.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: presentationData.strings.Premium_MaxSavedGifsTitle("\(limit)").string, text: text, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { [weak controller] action in if case .info = action, let controller { let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .savedGifs, forceDark: controller.forceDark, dismissed: nil) controller.push(premiumController) return true } return false }), in: .window(.root)) } }) }))) } let contextController = ContextController(presentationData: presentationData, source: .controller(ContextControllerContentSourceImpl(controller: gallery, sourceView: sourceView, sourceRect: sourceRect)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) controller.presentInGlobalOverlay(contextController) }) } func updateContent(_ content: StickerPickerInputData) { self.content = content guard let controller = self.controller else { return } content.emoji?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( performItemAction: { [weak self] groupId, item, _, _, _, _ in guard let strongSelf = self, let controller = strongSelf.controller else { return } let context = controller.context if groupId == AnyHashable("featuredTop"), let file = item.itemFile { let _ = ( combineLatest( ChatEntityKeyboardInputNode.hasPremium(context: context, chatPeerId: controller.context.account.peerId, premiumIfSavedMessages: true), ChatEntityKeyboardInputNode.hasPremium(context: context, chatPeerId: controller.context.account.peerId, premiumIfSavedMessages: false) ) |> take(1) |> deliverOnMainQueue).start(next: { [weak self] hasPremium, hasGlobalPremium in guard let self else { return } let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks) let _ = (combineLatest( context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), context.account.postbox.combinedView(keys: [viewKey]) ) |> take(1) |> deliverOnMainQueue).start(next: { [weak self] emojiPacksView, views in guard let view = views.views[viewKey] as? OrderedItemListView else { return } guard let self else { return } var installedCollectionIds = Set() for (id, _, _) in emojiPacksView.collectionInfos { installedCollectionIds.insert(id) } let stickerPacks = view.items.map({ $0.contents.get(FeaturedStickerPackItem.self)! }).filter({ !installedCollectionIds.contains($0.info.id) }) for featuredStickerPack in stickerPacks { if featuredStickerPack.topItems.contains(where: { $0.file.fileId == file.fileId }) { if let componentView = self.hostView.componentView as? StickerSelectionComponent.View { if let pagerView = componentView.keyboardView.view as? EntityKeyboardComponent.View, let emojiInputInteraction = self.content?.emoji?.inputInteractionHolder.inputInteraction, let controller = self.controller { pagerView.openCustomSearch(content: EmojiSearchContent( context: context, forceTheme: controller.forceDark ? defaultDarkPresentationTheme : nil, items: stickerPacks, initialFocusId: featuredStickerPack.info.id, hasPremiumForUse: hasPremium, hasPremiumForInstallation: hasGlobalPremium, parentInputInteraction: emojiInputInteraction )) } } break } } }) }) } else if let file = item.itemFile { if controller.completion(.file(.standalone(media: file), .sticker)) { controller.dismiss(animated: true) } } else if case let .staticEmoji(emoji) = item.content { if let image = generateImage(CGSize(width: 256.0, height: 256.0), scale: 1.0, rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) let attributedString = NSAttributedString(string: emoji, attributes: [NSAttributedString.Key.font: Font.regular(200), NSAttributedString.Key.foregroundColor: UIColor.white]) let line = CTLineCreateWithAttributedString(attributedString) let lineBounds = CTLineGetBoundsWithOptions(line, [.useOpticalBounds]) let lineOffset = CGPoint(x: 1.0 - UIScreenPixel, y: 0.0) let lineOrigin = CGPoint(x: floorToScreenPixels(-lineBounds.origin.x + (size.width - lineBounds.size.width) / 2.0) + lineOffset.x, y: floorToScreenPixels(-lineBounds.origin.y + (size.height - lineBounds.size.height) / 2.0) + lineOffset.y) context.translateBy(x: size.width / 2.0, y: size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) context.translateBy(x: lineOrigin.x, y: lineOrigin.y) CTLineDraw(line, context) context.translateBy(x: -lineOrigin.x, y: -lineOrigin.y) }) { if controller.completion(.image(image, .sticker)) { controller.dismiss(animated: true) } } else { controller.dismiss(animated: true) } } }, deleteBackwards: nil, openStickerSettings: nil, openFeatured: nil, openSearch: { }, addGroupAction: { [weak self] groupId, isPremiumLocked, _ in guard let strongSelf = self, let controller = strongSelf.controller, let collectionId = groupId.base as? ItemCollectionId else { return } let context = controller.context let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks) let _ = (context.account.postbox.combinedView(keys: [viewKey]) |> take(1) |> deliverOnMainQueue).start(next: { views in guard let view = views.views[viewKey] as? OrderedItemListView else { return } for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { if featuredStickerPack.info.id == collectionId { let _ = (context.engine.stickers.loadedStickerPack(reference: .id(id: featuredStickerPack.info.id.id, accessHash: featuredStickerPack.info.accessHash), forceActualized: false) |> mapToSignal { result -> Signal in switch result { case let .result(info, items, installed): if installed { return .complete() } else { return context.engine.stickers.addStickerPackInteractively(info: info, items: items) } case .fetching: break case .none: break } return .complete() } |> deliverOnMainQueue).start(completed: { }) break } } }) }, clearGroup: { [weak self] groupId in guard let strongSelf = self, let controller = strongSelf.controller else { return } var presentationData = controller.context.sharedContext.currentPresentationData.with { $0 } if controller.forceDark { presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme) } let context = controller.context if groupId == AnyHashable("recent") { let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) var items: [ActionSheetItem] = [] items.append(ActionSheetButtonItem(title: presentationData.strings.Emoji_ClearRecent, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() let _ = context.engine.stickers.clearRecentlyUsedEmoji().start() })) actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ])]) context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet) } else if groupId == AnyHashable("popular") { let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) var items: [ActionSheetItem] = [] items.append(ActionSheetTextItem(title: presentationData.strings.Chat_ClearReactionsAlertText, parseMarkdown: true)) items.append(ActionSheetButtonItem(title: presentationData.strings.Chat_ClearReactionsAlertAction, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() guard let strongSelf = self else { return } strongSelf.scheduledEmojiContentAnimationHint = EmojiPagerContentComponent.ContentAnimation(type: .groupRemoved(id: "popular")) let _ = context.engine.stickers.clearRecentlyUsedReactions().start() })) actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ])]) context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet) } }, editAction: { _ in }, pushController: { c in }, presentController: { c in }, presentGlobalOverlayController: { c in }, navigationController: { [weak self] in return self?.controller?.navigationController as? NavigationController }, requestUpdate: { [weak self] transition in guard let strongSelf = self else { return } if !transition.animation.isImmediate, let (layout, navigationHeight) = strongSelf.currentLayout { strongSelf.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: transition) } }, updateSearchQuery: { [weak self] query in guard let self, let controller = self.controller else { return } let context = controller.context switch query { case .none: self.emojiSearchDisposable.set(nil) self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) case let .text(rawQuery, languageCode): let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) if query.isEmpty { self.emojiSearchDisposable.set(nil) self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) } else { var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: false) if !languageCode.lowercased().hasPrefix("en") { signal = signal |> mapToSignal { keywords in return .single(keywords) |> then( context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3) |> map { englishKeywords in return keywords + englishKeywords } ) } } let hasPremium: Signal = .single(true) let resultSignal = combineLatest( signal, hasPremium ) |> mapToSignal { keywords, hasPremium -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in var allEmoticons: [String: String] = [:] for keyword in keywords { for emoticon in keyword.emoticons { allEmoticons[emoticon] = keyword.keyword } } let remoteSignal: Signal<(items: [TelegramMediaFile], isFinalResult: Bool), NoError> if hasPremium { remoteSignal = context.engine.stickers.searchEmoji(emojiString: Array(allEmoticons.keys)) } else { remoteSignal = .single(([], true)) } return remoteSignal |> mapToSignal { foundEmoji -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in if foundEmoji.items.isEmpty && !foundEmoji.isFinalResult { return .complete() } var items: [EmojiPagerContentComponent.Item] = [] let appendUnicodeEmoji = { for (_, list) in EmojiPagerContentComponent.staticEmojiMapping { for emojiString in list { if allEmoticons[emojiString] != nil { let item = EmojiPagerContentComponent.Item( animationData: nil, content: .staticEmoji(emojiString), itemFile: nil, subgroupId: nil, icon: .none, tintMode: .none ) items.append(item) } } } } if !hasPremium { appendUnicodeEmoji() } var existingIds = Set() for itemFile in foundEmoji.items { if existingIds.contains(itemFile.fileId) { continue } existingIds.insert(itemFile.fileId) if itemFile.isPremiumEmoji && !hasPremium { continue } let animationData = EntityKeyboardAnimationData(file: itemFile) let item = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, icon: .none, tintMode: animationData.isTemplate ? .primary : .none ) items.append(item) } if hasPremium { appendUnicodeEmoji() } return .single([EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: false, items: items )]) } } var version = 0 self.emojiSearchStateValue.isSearching = true self.emojiSearchDisposable.set((resultSignal |> delay(0.15, queue: .mainQueue()) |> deliverOnMainQueue).start(next: { [weak self] result in guard let self else { return } self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result, id: AnyHashable(query), version: version, isPreset: false), isSearching: false) version += 1 })) } case let .category(value): let resultSignal = context.engine.stickers.searchEmoji(category: value) |> mapToSignal { files, isFinalResult -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in var items: [EmojiPagerContentComponent.Item] = [] var existingIds = Set() for itemFile in files { if existingIds.contains(itemFile.fileId) { continue } existingIds.insert(itemFile.fileId) let animationData = EntityKeyboardAnimationData(file: itemFile) let item = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, icon: .none, tintMode: animationData.isTemplate ? .primary : .none ) items.append(item) } return .single(([EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: false, items: items )], isFinalResult)) } var version = 0 self.emojiSearchDisposable.set((resultSignal |> deliverOnMainQueue).start(next: { [weak self] result in guard let self else { return } guard let group = result.items.first else { return } if group.items.isEmpty && !result.isFinalResult { self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: [ EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: true, items: [] ) ], id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) return } self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) version += 1 })) } }, updateScrollingToItemGroup: { [weak self] in if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View { componentView.scrolledToItemGroup() } self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) }, onScroll: {}, chatPeerId: nil, peekBehavior: nil, customLayout: nil, externalBackground: nil, externalExpansionView: nil, customContentView: nil, useOpaqueTheme: false, hideBackground: true, stateContext: nil, addImage: controller.hasGifs ? { [weak self] in if let self, let controller = self.controller { let _ = controller.completion(nil) controller.dismiss(animated: true) controller.presentGallery() } } : nil ) var stickerPeekBehavior: EmojiContentPeekBehaviorImpl? if let controller = self.controller { stickerPeekBehavior = EmojiContentPeekBehaviorImpl( context: controller.context, forceTheme: controller.forceDark ? defaultDarkColorPresentationTheme : nil, interaction: nil, chatPeerId: nil, present: { [weak controller] c, a in controller?.presentInGlobalOverlay(c, with: a) } ) } content.stickers?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( performItemAction: { [weak self] groupId, item, _, _, _, _ in guard let self, let controller = self.controller, let file = item.itemFile else { return } let presentationData = controller.context.sharedContext.currentPresentationData.with { $0 } if groupId == AnyHashable("featuredTop") { let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) let _ = (controller.context.account.postbox.combinedView(keys: [viewKey]) |> take(1) |> deliverOnMainQueue).start(next: { [weak self] views in guard let self, let controller = self.controller, let view = views.views[viewKey] as? OrderedItemListView else { return } for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { if featuredStickerPack.topItems.contains(where: { $0.file.fileId == file.fileId }) { controller.pushController(FeaturedStickersScreen( context: controller.context, highlightedPackId: featuredStickerPack.info.id, forceTheme: defaultDarkColorPresentationTheme, stickerActionTitle: presentationData.strings.StickerPack_AddSticker, sendSticker: { [weak self] fileReference, _, _ in guard let self, let controller = self.controller else { return false } if controller.completion(.file(fileReference, .sticker)) { controller.dismiss(animated: true) } return true } )) break } } }) } else { let reference: FileMediaReference if groupId == AnyHashable("saved") { reference = .savedSticker(media: file) } else if groupId == AnyHashable("recent") { reference = .recentSticker(media: file) } else { reference = .standalone(media: file) } let _ = controller.completion(.file(reference, .sticker)) controller.dismiss(animated: true) } }, deleteBackwards: nil, openStickerSettings: nil, openFeatured: nil, openSearch: { [weak self] in if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View { if let pagerView = componentView.keyboardView.view as? EntityKeyboardComponent.View { pagerView.openSearch() } self.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) } }, addGroupAction: { [weak self] groupId, isPremiumLocked, _ in guard let strongSelf = self, let controller = strongSelf.controller, let collectionId = groupId.base as? ItemCollectionId else { return } let context = controller.context let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) let _ = (context.account.postbox.combinedView(keys: [viewKey]) |> take(1) |> deliverOnMainQueue).start(next: { views in guard let view = views.views[viewKey] as? OrderedItemListView else { return } for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { if featuredStickerPack.info.id == collectionId { let _ = (context.engine.stickers.loadedStickerPack(reference: .id(id: featuredStickerPack.info.id.id, accessHash: featuredStickerPack.info.accessHash), forceActualized: false) |> mapToSignal { result -> Signal in switch result { case let .result(info, items, installed): if installed { return .complete() } else { return context.engine.stickers.addStickerPackInteractively(info: info, items: items) } case .fetching: break case .none: break } return .complete() } |> deliverOnMainQueue).start(completed: { }) break } } }) }, clearGroup: { [weak self] groupId in guard let strongSelf = self, let controller = strongSelf.controller else { return } let context = controller.context if groupId == AnyHashable("recent") { var presentationData = context.sharedContext.currentPresentationData.with { $0 } if controller.forceDark { presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme) } let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) var items: [ActionSheetItem] = [] items.append(ActionSheetButtonItem(title: presentationData.strings.Stickers_ClearRecent, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() let _ = context.engine.stickers.clearRecentlyUsedStickers().start() })) actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ])]) context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet) } else if groupId == AnyHashable("featuredTop") { let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) let _ = (context.account.postbox.combinedView(keys: [viewKey]) |> take(1) |> deliverOnMainQueue).start(next: { views in guard let view = views.views[viewKey] as? OrderedItemListView else { return } var stickerPackIds: [Int64] = [] for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { stickerPackIds.append(featuredStickerPack.info.id.id) } let _ = ApplicationSpecificNotice.setDismissedTrendingStickerPacks(accountManager: context.sharedContext.accountManager, values: stickerPackIds).start() }) } else if groupId == AnyHashable("peerSpecific") { } }, editAction: { _ in }, pushController: { c in }, presentController: { c in }, presentGlobalOverlayController: { c in }, navigationController: { [weak self] in return self?.controller?.navigationController as? NavigationController }, requestUpdate: { [weak self] transition in guard let strongSelf = self else { return } if !transition.animation.isImmediate, let (layout, navigationHeight) = strongSelf.currentLayout { strongSelf.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: transition) } }, updateSearchQuery: { [weak self] query in guard let strongSelf = self, let controller = strongSelf.controller else { return } let context = controller.context switch query { case .none: strongSelf.stickerSearchDisposable.set(nil) strongSelf.stickerSearchStateValue = EmojiSearchState(result: nil, isSearching: false) case .text: strongSelf.stickerSearchDisposable.set(nil) strongSelf.stickerSearchStateValue = EmojiSearchState(result: nil, isSearching: false) case let .category(value): let resultSignal = context.engine.stickers.searchStickers(category: value, scope: [.installed, .remote]) |> mapToSignal { files -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in var items: [EmojiPagerContentComponent.Item] = [] var existingIds = Set() for item in files.items { let itemFile = item.file if existingIds.contains(itemFile.fileId) { continue } existingIds.insert(itemFile.fileId) let animationData = EntityKeyboardAnimationData(file: itemFile) let item = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, icon: .none, tintMode: animationData.isTemplate ? .primary : .none ) items.append(item) } return .single(([EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: false, items: items )], files.isFinalResult)) } var version = 0 strongSelf.stickerSearchDisposable.set((resultSignal |> deliverOnMainQueue).start(next: { [weak self] result in guard let strongSelf = self else { return } guard let group = result.items.first else { return } if group.items.isEmpty && !result.isFinalResult { strongSelf.stickerSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: [ EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: true, items: [] ) ], id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) return } strongSelf.stickerSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) version += 1 })) } }, updateScrollingToItemGroup: { [weak self] in if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View { componentView.scrolledToItemGroup() } self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) }, onScroll: {}, chatPeerId: nil, peekBehavior: stickerPeekBehavior, customLayout: nil, externalBackground: nil, externalExpansionView: nil, customContentView: self.storyStickersContentView, useOpaqueTheme: false, hideBackground: true, stateContext: nil, addImage: controller.hasGifs ? { [weak self] in if let self, let controller = self.controller { let _ = controller.completion(nil) controller.dismiss(animated: true) controller.presentGallery() } } : nil ) if let (layout, navigationHeight) = self.currentLayout { self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate) } } override func didLoad() { super.didLoad() let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) panRecognizer.delegate = self.wrappedGestureRecognizerDelegate panRecognizer.delaysTouchesBegan = false panRecognizer.cancelsTouchesInView = true self.panGestureRecognizer = panRecognizer self.wrappingView.addGestureRecognizer(panRecognizer) self.dim.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) if let controller = self.controller { controller.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate) } } @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { let _ = self.controller?.completion(nil) self.controller?.dismiss(animated: true) } } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let controller = self.controller, !controller.isFullscreen else { return false } if let (layout, _) = self.currentLayout { if layout.metrics.isTablet { return false } } return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { if otherGestureRecognizer is PagerPanGestureRecognizer { return false } else if otherGestureRecognizer is UIPanGestureRecognizer, let scrollView = otherGestureRecognizer.view, scrollView.frame.width > scrollView.frame.height { return false } else if otherGestureRecognizer is PeekControllerGestureRecognizer { return false } return true } return false } private var isDismissing = false func animateIn() { guard let controller = self.controller, !controller.isFullscreen else { return } ContainedViewLayoutTransition.animated(duration: 0.3, curve: .linear).updateAlpha(node: self.dim, alpha: 1.0) let targetPosition = self.containerView.center let startPosition = targetPosition.offsetBy(dx: 0.0, dy: self.bounds.height) self.containerView.center = startPosition let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) transition.animateView(allowUserInteraction: true, { self.containerView.center = targetPosition }, completion: { _ in }) } func animateOut(completion: @escaping () -> Void = {}) { self.isDismissing = true let positionTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) positionTransition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.center.x, y: self.bounds.height + self.containerView.bounds.height / 2.0), completion: { [weak self] _ in self?.controller?.dismiss(animated: false, completion: completion) }) let alphaTransition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut) alphaTransition.updateAlpha(node: self.dim, alpha: 0.0) if !self.temporaryDismiss { self.controller?.updateModalStyleOverlayTransitionFactor(0.0, transition: positionTransition) } } func containerLayoutUpdated(layout: ContainerViewLayout, navigationHeight: CGFloat, transition: Transition) { guard let controller = self.controller else { return } self.currentLayout = (layout, navigationHeight) self.dim.frame = CGRect(origin: CGPoint(x: 0.0, y: -layout.size.height), size: CGSize(width: layout.size.width, height: layout.size.height * 3.0)) let effectiveExpanded = self.isExpanded || layout.metrics.isTablet let isLandscape = layout.orientation == .landscape let edgeTopInset = isLandscape ? 0.0 : self.defaultTopInset let topInset: CGFloat var bottomInset = layout.intrinsicInsets.bottom if let (panInitialTopInset, panOffset, _, _) = self.panGestureArguments { if effectiveExpanded { topInset = min(edgeTopInset, panInitialTopInset + max(0.0, panOffset)) } else { topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) } } else { topInset = effectiveExpanded ? 0.0 : edgeTopInset } transition.setFrame(view: self.wrappingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: layout.size), completion: nil) var modalProgress = isLandscape ? 0.0 : (1.0 - topInset / self.defaultTopInset) if self.isDismissing { modalProgress = 0.0 } self.controller?.updateModalStyleOverlayTransitionFactor(modalProgress, transition: transition.containedViewLayoutTransition) if self.isDismissing { return } let clipFrame: CGRect let contentFrame: CGRect if controller.isFullscreen { clipFrame = CGRect(origin: CGPoint(), size: layout.size) contentFrame = clipFrame } else if layout.metrics.widthClass == .compact { self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.25) if isLandscape { self.containerView.layer.cornerRadius = 0.0 } else { self.containerView.layer.cornerRadius = 10.0 } if #available(iOS 11.0, *) { if layout.safeInsets.bottom.isZero { self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] } else { self.containerView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] } } if isLandscape { clipFrame = CGRect(origin: CGPoint(), size: layout.size) contentFrame = clipFrame } else { let coveredByModalTransition: CGFloat = 0.0 var containerTopInset: CGFloat = 10.0 if let statusBarHeight = layout.statusBarHeight { containerTopInset += statusBarHeight } let unscaledFrame = CGRect(origin: CGPoint(x: 0.0, y: containerTopInset - coveredByModalTransition * 10.0), size: CGSize(width: layout.size.width, height: layout.size.height - containerTopInset)) let maxScale: CGFloat = (layout.size.width - 16.0 * 2.0) / layout.size.width let containerScale = 1.0 * (1.0 - coveredByModalTransition) + maxScale * coveredByModalTransition let maxScaledTopInset: CGFloat = containerTopInset - 10.0 let scaledTopInset: CGFloat = containerTopInset * (1.0 - coveredByModalTransition) + maxScaledTopInset * coveredByModalTransition let containerFrame = unscaledFrame.offsetBy(dx: 0.0, dy: scaledTopInset - (unscaledFrame.midY - containerScale * unscaledFrame.height / 2.0)) clipFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height) contentFrame = CGRect(x: containerFrame.minX, y: containerFrame.minY, width: containerFrame.width, height: containerFrame.height - topInset) } } else { self.dim.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4) self.containerView.layer.cornerRadius = 10.0 let verticalInset: CGFloat = 44.0 let maxSide = max(layout.size.width, layout.size.height) let minSide = min(layout.size.width, layout.size.height) let containerSize = CGSize(width: floorToScreenPixels(min(layout.size.width - 20.0, floor(maxSide / 2.0)) * 0.66), height: floorToScreenPixels((min(layout.size.height, minSide) - verticalInset * 2.0) * 0.66)) clipFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - containerSize.width) / 2.0), y: floor((layout.size.height - containerSize.height) / 2.0)), size: containerSize) contentFrame = clipFrame bottomInset = 0.0 } transition.setFrame(view: self.containerView, frame: clipFrame) if let content = self.content { var stickersTransition: Transition = transition if let scheduledEmojiContentAnimationHint = self.scheduledEmojiContentAnimationHint { self.scheduledEmojiContentAnimationHint = nil let contentAnimation = scheduledEmojiContentAnimationHint stickersTransition = Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(contentAnimation) } var contentSize = self.hostView.update( transition: stickersTransition, component: AnyComponent( StickerSelectionComponent( context: controller.context, theme: self.theme, strings: self.presentationData.strings, deviceMetrics: layout.deviceMetrics, topInset: controller.isFullscreen ? navigationHeight : 0.0, bottomInset: bottomInset, content: content, backgroundColor: self.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.85), separatorColor: self.theme.rootController.navigationBar.separatorColor, getController: { [weak self] in if let self { return self.controller } else { return nil } } ) ), environment: {}, forceUpdate: true, containerSize: CGSize(width: contentFrame.size.width, height: contentFrame.height) ) contentSize.height = max(layout.size.height - navigationHeight, contentSize.height) transition.setFrame(view: self.hostView, frame: CGRect(origin: CGPoint(), size: contentSize), completion: nil) } } private var didPlayAppearAnimation = false func updateIsVisible(isVisible: Bool) { if self.currentIsVisible == isVisible { return } self.currentIsVisible = isVisible guard let currentLayout = self.currentLayout else { return } self.containerLayoutUpdated(layout: currentLayout.layout, navigationHeight: currentLayout.navigationHeight, transition: .immediate) if !self.didPlayAppearAnimation { self.didPlayAppearAnimation = true self.animateIn() } } private var defaultTopInset: CGFloat { guard let (layout, _) = self.currentLayout else { return 210.0 } if let controller = self.controller, controller.isFullscreen { return 0.0 } if case .compact = layout.metrics.widthClass { var factor: CGFloat = 0.2488 if layout.size.width <= 320.0 { factor = 0.15 } return floor(max(layout.size.width, layout.size.height) * factor) } else { return 210.0 } } private func findScrollView(view: UIView?) -> (UIScrollView, ListView?)? { if let view = view { if let view = view as? PagerExpandableScrollView { return (view, nil) } if let view = view as? GridNodeScrollerView { return (view, nil) } if let node = view.asyncdisplaykit_node as? ListView { return (node.scroller, node) } return findScrollView(view: view.superview) } else { return nil } } @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { guard let (layout, navigationHeight) = self.currentLayout else { return } guard let controller = self.controller, !controller.isFullscreen else { return } let isLandscape = layout.orientation == .landscape let edgeTopInset = isLandscape ? 0.0 : defaultTopInset switch recognizer.state { case .began: let point = recognizer.location(in: self.view) let currentHitView = self.hitTest(point, with: nil) var scrollViewAndListNode = self.findScrollView(view: currentHitView) if scrollViewAndListNode?.0.frame.height == self.frame.width { scrollViewAndListNode = nil } let scrollView = scrollViewAndListNode?.0 let listNode = scrollViewAndListNode?.1 let topInset: CGFloat if self.isExpanded { topInset = 0.0 } else { topInset = edgeTopInset } self.panGestureArguments = (topInset, 0.0, scrollView, listNode) case .changed: guard let (topInset, panOffset, scrollView, listNode) = self.panGestureArguments else { return } let visibleContentOffset = listNode?.visibleContentOffset() let contentOffset = scrollView?.contentOffset.y ?? 0.0 var translation = recognizer.translation(in: self.view).y var currentOffset = topInset + translation let epsilon = 1.0 if case let .known(value) = visibleContentOffset, value <= epsilon { if let scrollView = scrollView { scrollView.bounces = false scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: 0.0), animated: false) } } else if let scrollView = scrollView, contentOffset <= -scrollView.contentInset.top + epsilon { scrollView.bounces = false scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } else if let scrollView = scrollView { translation = panOffset currentOffset = topInset + translation if self.isExpanded { recognizer.setTranslation(CGPoint(), in: self.view) } else if currentOffset > 0.0 { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } } self.panGestureArguments = (topInset, translation, scrollView, listNode) if !self.isExpanded { if currentOffset > 0.0, let scrollView = scrollView { scrollView.panGestureRecognizer.setTranslation(CGPoint(), in: scrollView) } } var bounds = self.bounds if self.isExpanded { bounds.origin.y = -max(0.0, translation - edgeTopInset) } else { bounds.origin.y = -translation } bounds.origin.y = min(0.0, bounds.origin.y) self.bounds = bounds self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: .immediate) case .ended: guard let (currentTopInset, panOffset, scrollView, listNode) = self.panGestureArguments else { return } self.panGestureArguments = nil let visibleContentOffset = listNode?.visibleContentOffset() let contentOffset = scrollView?.contentOffset.y ?? 0.0 let translation = recognizer.translation(in: self.view).y var velocity = recognizer.velocity(in: self.view) if self.isExpanded { if case let .known(value) = visibleContentOffset, value > 0.1 { velocity = CGPoint() } else if case .unknown = visibleContentOffset { velocity = CGPoint() } else if contentOffset > 0.1 { velocity = CGPoint() } } var bounds = self.bounds if self.isExpanded { bounds.origin.y = -max(0.0, translation - edgeTopInset) } else { bounds.origin.y = -translation } bounds.origin.y = min(0.0, bounds.origin.y) scrollView?.bounces = true let offset = currentTopInset + panOffset let topInset: CGFloat = edgeTopInset var dismissing = false if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) || (self.isExpanded && bounds.minY.isZero && velocity.y > 1800.0) { let _ = self.controller?.completion(nil) self.controller?.dismiss(animated: true, completion: nil) dismissing = true } else if self.isExpanded { if velocity.y > 300.0 || offset > topInset / 2.0 { self.isExpanded = false if let listNode = listNode { listNode.scroller.setContentOffset(CGPoint(), animated: false) } else if let scrollView = scrollView { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } let distance = topInset - offset let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } else { self.isExpanded = true self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) } } else if (velocity.y < -300.0 || offset < topInset / 2.0) { let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) self.isExpanded = true self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } else { if let listNode = listNode { listNode.scroller.setContentOffset(CGPoint(), animated: false) } else if let scrollView = scrollView { scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) } self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) } if !dismissing { var bounds = self.bounds let previousBounds = bounds bounds.origin.y = 0.0 self.bounds = bounds self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) } case .cancelled: self.panGestureArguments = nil self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(.animated(duration: 0.3, curve: .easeInOut))) default: break } } func update(isExpanded: Bool, transition: ContainedViewLayoutTransition) { guard isExpanded != self.isExpanded else { return } self.isExpanded = isExpanded guard let (layout, navigationHeight) = self.currentLayout else { return } self.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } } var node: Node { return self.displayNode as! Node } private let context: AccountContext private let theme: PresentationTheme fileprivate let forceDark: Bool private let inputData: Signal fileprivate let defaultToEmoji: Bool let isFullscreen: Bool let hasEmoji: Bool let hasGifs: Bool let hasInteractiveStickers: Bool private var currentLayout: ContainerViewLayout? public var pushController: (ViewController) -> Void = { _ in } public var presentController: (ViewController) -> Void = { _ in } public var completion: (DrawingStickerEntity.Content?) -> Bool = { _ in return true } public var presentGallery: () -> Void = { } public var presentLocationPicker: () -> Void = { } public var presentAudioPicker: () -> Void = { } public var addReaction: () -> Void = { } public var addLink: () -> Void = { } public init(context: AccountContext, inputData: Signal, forceDark: Bool = false, expanded: Bool = false, defaultToEmoji: Bool = false, hasEmoji: Bool = true, hasGifs: Bool = false, hasInteractiveStickers: Bool = true) { self.context = context let presentationData = context.sharedContext.currentPresentationData.with { $0 } self.theme = forceDark ? defaultDarkColorPresentationTheme : presentationData.theme self.forceDark = forceDark self.inputData = inputData self.isFullscreen = expanded self.defaultToEmoji = defaultToEmoji self.hasEmoji = hasEmoji self.hasGifs = hasGifs self.hasInteractiveStickers = hasInteractiveStickers super.init(navigationBarPresentationData: expanded ? NavigationBarPresentationData(presentationData: presentationData) : nil) self.statusBar.statusBarStyle = .Ignore if expanded { self.title = presentationData.strings.Stickers_ChooseSticker_Title self.navigationPresentation = .modal } } required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func loadDisplayNode() { self.displayNode = Node(context: self.context, controller: self, theme: self.theme) self.displayNodeDidLoad() self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) } public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { self.view.endEditing(true) if self.isFullscreen { super.dismiss(animated: flag, completion: completion) } else { if flag { self.node.animateOut(completion: { super.dismiss(animated: false, completion: {}) completion?() }) } else { super.dismiss(animated: false, completion: {}) completion?() } } } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.node.updateIsVisible(isVisible: true) } public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.node.updateIsVisible(isVisible: false) } public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.currentLayout = layout super.containerLayoutUpdated(layout, transition: transition) let navigationHeight: CGFloat = 56.0 self.node.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } } private final class InteractiveStickerButtonContent: Component { let theme: PresentationTheme let title: String let iconName: String let useOpaqueTheme: Bool weak var tintContainerView: UIView? public init( theme: PresentationTheme, title: String, iconName: String, useOpaqueTheme: Bool, tintContainerView: UIView ) { self.theme = theme self.title = title self.iconName = iconName self.useOpaqueTheme = useOpaqueTheme self.tintContainerView = tintContainerView } public static func ==(lhs: InteractiveStickerButtonContent, rhs: InteractiveStickerButtonContent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.title != rhs.title { return false } if lhs.iconName != rhs.iconName { return false } if lhs.useOpaqueTheme != rhs.useOpaqueTheme { return false } return true } final class View: UIView { override public static var layerClass: AnyClass { return PassthroughLayer.self } private let backgroundLayer = SimpleLayer() let tintBackgroundLayer = SimpleLayer() private var icon: ComponentView private var title: ComponentView private var component: InteractiveStickerButtonContent? override init(frame: CGRect) { self.icon = ComponentView() self.title = ComponentView() super.init(frame: frame) self.isExclusiveTouch = true self.layer.addSublayer(self.backgroundLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: InteractiveStickerButtonContent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.backgroundLayer.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.11).cgColor let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( name: component.iconName, tintColor: .white, maxSize: CGSize(width: 20.0, height: 20.0) )), environment: {}, containerSize: availableSize ) let titleSize = self.title.update( transition: .immediate, component: AnyComponent(Text( text: component.title.uppercased(), font: Font.with(size: 23.0, design: .camera), color: .white )), environment: {}, containerSize: availableSize ) let padding: CGFloat = 7.0 let spacing: CGFloat = 4.0 let buttonSize = CGSize(width: padding + iconSize.width + spacing + titleSize.width + padding, height: 34.0) if let view = self.icon.view { if view.superview == nil { self.addSubview(view) } transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: padding, y: floorToScreenPixels((buttonSize.height - iconSize.height) / 2.0)), size: iconSize)) } if let view = self.title.view { if view.superview == nil { self.addSubview(view) } transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: padding + iconSize.width + spacing, y: floorToScreenPixels((buttonSize.height - titleSize.height) / 2.0)), size: titleSize)) } self.backgroundLayer.cornerRadius = 6.0 self.tintBackgroundLayer.cornerRadius = 6.0 self.backgroundLayer.frame = CGRect(origin: .zero, size: buttonSize) if self.tintBackgroundLayer.superlayer == nil, let tintContainerView = component.tintContainerView { Queue.mainQueue().justDispatch { let mappedFrame = self.convert(self.bounds, to: tintContainerView) self.tintBackgroundLayer.frame = mappedFrame } } return buttonSize } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class InteractiveReactionButtonContent: Component { let theme: PresentationTheme public init( theme: PresentationTheme ) { self.theme = theme } public static func ==(lhs: InteractiveReactionButtonContent, rhs: InteractiveReactionButtonContent) -> Bool { if lhs.theme !== rhs.theme { return false } return true } final class View: UIView { override public static var layerClass: AnyClass { return PassthroughLayer.self } private var icon: ComponentView private var component: InteractiveReactionButtonContent? override init(frame: CGRect) { self.icon = ComponentView() super.init(frame: frame) self.isExclusiveTouch = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: InteractiveReactionButtonContent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let bounds = CGRect(origin: .zero, size: CGSize(width: 54.0, height: 54.0)) let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( name: "Media Editor/Reaction", tintColor: nil, maxSize: CGSize(width: 52.0, height: 52.0) )), environment: {}, containerSize: availableSize ) if let view = self.icon.view { if view.superview == nil { self.addSubview(view) } transition.setFrame(view: view, frame: CGRect(origin: CGPoint(x: 2.0, y: 0.0), size: iconSize)) } return bounds.size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private final class RoundVideoButtonContent: Component { let theme: PresentationTheme public init( theme: PresentationTheme ) { self.theme = theme } public static func ==(lhs: RoundVideoButtonContent, rhs: RoundVideoButtonContent) -> Bool { if lhs.theme !== rhs.theme { return false } return true } final class View: UIView { override public static var layerClass: AnyClass { return PassthroughLayer.self } private let backgroundLayer = SimpleLayer() private var icon: ComponentView private var component: InteractiveReactionButtonContent? override init(frame: CGRect) { self.icon = ComponentView() super.init(frame: frame) self.isExclusiveTouch = true self.layer.addSublayer(self.backgroundLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: RoundVideoButtonContent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.backgroundLayer.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.11).cgColor let bounds = CGRect(origin: .zero, size: CGSize(width: 54.0, height: 54.0)) let backgroundSize = CGSize(width: 50.0, height: 50.0) self.backgroundLayer.frame = backgroundSize.centered(in: bounds) self.backgroundLayer.cornerRadius = backgroundSize.width / 2.0 let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( name: "Chat List/Tabs/IconCamera", tintColor: nil, maxSize: CGSize(width: 30.0, height: 30.0) )), environment: {}, containerSize: availableSize ) if let view = self.icon.view { if view.superview == nil { self.addSubview(view) } transition.setFrame(view: view, frame: iconSize.centered(in: bounds)) } return bounds.size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } final class ItemStack: CombinedComponent { typealias EnvironmentType = ChildEnvironment private let items: [AnyComponentWithIdentity] private let padding: CGFloat private let minSpacing: CGFloat private let verticalSpacing: CGFloat init(_ items: [AnyComponentWithIdentity], padding: CGFloat, minSpacing: CGFloat, verticalSpacing: CGFloat) { self.items = items self.padding = padding self.minSpacing = minSpacing self.verticalSpacing = verticalSpacing } static func ==(lhs: ItemStack, rhs: ItemStack) -> Bool { if lhs.items != rhs.items { return false } if lhs.padding != rhs.padding { return false } if lhs.minSpacing != rhs.minSpacing { return false } if lhs.verticalSpacing != rhs.verticalSpacing { return false } return true } static var body: Body { let children = ChildMap(environment: ChildEnvironment.self, keyedBy: AnyHashable.self) return { context in let updatedChildren = context.component.items.map { item in return children[item.id].update( component: item.component, environment: { context.environment[ChildEnvironment.self] }, availableSize: context.availableSize, transition: context.transition ) } var groups: [[Int]] = [] var currentGroup: [Int] = [] for i in 0 ..< updatedChildren.count { var itemsWidth: CGFloat = 0.0 for j in currentGroup { itemsWidth += updatedChildren[j].size.width } itemsWidth += updatedChildren[i].size.width let rowItemsCount = currentGroup.count + 1 let remainingWidth = context.availableSize.width - itemsWidth - context.component.padding * 2.0 let spacing = remainingWidth / CGFloat(rowItemsCount - 1) if spacing < context.component.minSpacing { groups.append(currentGroup) currentGroup = [] } currentGroup.append(i) } if !currentGroup.isEmpty { groups.append(currentGroup) } var size = CGSize(width: context.availableSize.width, height: 0.0) for group in groups { var groupHeight: CGFloat = 0.0 var spacing = context.component.minSpacing var itemsWidth = 0.0 for i in group { let childSize = updatedChildren[i].size groupHeight = max(groupHeight, childSize.height) itemsWidth += childSize.width } let remainingWidth = context.availableSize.width - itemsWidth - context.component.padding * 2.0 spacing = remainingWidth / CGFloat(group.count - 1) var useCenteredLayout = false if spacing > 30.0 || group.count == 1 { spacing = 30.0 useCenteredLayout = true } var nextX: CGFloat if useCenteredLayout { let totalWidth = itemsWidth + spacing * CGFloat(group.count - 1) nextX = floorToScreenPixels((size.width - totalWidth) / 2.0) } else { nextX = context.component.padding } for i in group { let child = updatedChildren[i] let frame = CGRect(origin: CGPoint(x: nextX, y: size.height + floorToScreenPixels((groupHeight - child.size.height) / 2.0)), size: child.size) context.add(child .position(child.size.centered(in: frame).center) ) nextX += child.size.width + spacing } size.height += groupHeight + context.component.verticalSpacing } return size } } } final class StoryStickersContentView: UIView, EmojiCustomContentView { let tintContainerView = UIView() private let container = ComponentView() var locationAction: () -> Void = {} var audioAction: () -> Void = {} var reactionAction: () -> Void = {} var linkAction: () -> Void = {} func update(theme: PresentationTheme, strings: PresentationStrings, useOpaqueTheme: Bool, availableSize: CGSize, transition: Transition) -> CGSize { //TODO:localize let padding: CGFloat = 22.0 let size = self.container.update( transition: transition, component: AnyComponent( ItemStack( [ AnyComponentWithIdentity( id: "link", component: AnyComponent( CameraButton( content: AnyComponentWithIdentity( id: "content", component: AnyComponent( InteractiveStickerButtonContent( theme: theme, title: "LINK", iconName: "Premium/Link", useOpaqueTheme: useOpaqueTheme, tintContainerView: self.tintContainerView ) ) ), action: { [weak self] in if let self { self.linkAction() } }) ) ), AnyComponentWithIdentity( id: "location", component: AnyComponent( CameraButton( content: AnyComponentWithIdentity( id: "content", component: AnyComponent( InteractiveStickerButtonContent( theme: theme, title: strings.MediaEditor_AddLocationShort, iconName: "Chat/Attach Menu/Location", useOpaqueTheme: useOpaqueTheme, tintContainerView: self.tintContainerView ) ) ), action: { [weak self] in if let self { self.locationAction() } }) ) ), AnyComponentWithIdentity( id: "audio", component: AnyComponent( CameraButton( content: AnyComponentWithIdentity( id: "audio", component: AnyComponent( InteractiveStickerButtonContent( theme: theme, title: strings.MediaEditor_AddAudio, iconName: "Media Editor/Audio", useOpaqueTheme: useOpaqueTheme, tintContainerView: self.tintContainerView ) ) ), action: { [weak self] in if let self { self.audioAction() } }) ) ), AnyComponentWithIdentity( id: "reaction", component: AnyComponent( CameraButton( content: AnyComponentWithIdentity( id: "reaction", component: AnyComponent( InteractiveReactionButtonContent(theme: theme) ) ), action: { [weak self] in if let self { self.reactionAction() } }) ) ) ], padding: 18.0, minSpacing: 8.0, verticalSpacing: 12.0 ) ), environment: {}, containerSize: availableSize ) if let view = self.container.view { if view.superview == nil { self.addSubview(view) } view.frame = CGRect(origin: CGPoint(x: 0.0, y: padding), size: size) } return CGSize(width: size.width, height: size.height + padding * 2.0 - 12.0) } } private final class ContextControllerContentSourceImpl: ContextControllerContentSource { let controller: ViewController weak var sourceView: UIView? let sourceRect: CGRect let navigationController: NavigationController? = nil let passthroughTouches: Bool = false init(controller: ViewController, sourceView: UIView?, sourceRect: CGRect) { self.controller = controller self.sourceView = sourceView self.sourceRect = sourceRect } func transitionInfo() -> ContextControllerTakeControllerInfo? { let sourceView = self.sourceView let sourceRect = self.sourceRect return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceView] in if let sourceView = sourceView { return (sourceView, sourceRect) } else { return nil } }) } func animatedIn() { if let controller = self.controller as? GalleryController { controller.viewDidAppear(false) } } }