import Foundation import UIKit import AsyncDisplayKit import Postbox import TelegramCore import Display import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import MergeLists import AccountContext import StickerPackPreviewUI import StickerPeekUI import ContextUI import ChatPresentationInterfaceState import UndoUI import PremiumUI import ChatControllerInteraction import ChatContextResultPeekContent import ChatInputContextPanelNode private struct ChatContextResultStableId: Hashable { let result: ChatContextResult func hash(into hasher: inout Hasher) { hasher.combine(result.id.hashValue) } static func ==(lhs: ChatContextResultStableId, rhs: ChatContextResultStableId) -> Bool { return lhs.result == rhs.result } } private struct HorizontalListContextResultsChatInputContextPanelEntry: Comparable, Identifiable { let index: Int let theme: PresentationTheme let result: ChatContextResult var stableId: ChatContextResultStableId { return ChatContextResultStableId(result: self.result) } static func ==(lhs: HorizontalListContextResultsChatInputContextPanelEntry, rhs: HorizontalListContextResultsChatInputContextPanelEntry) -> Bool { return lhs.index == rhs.index && lhs.theme === rhs.theme && lhs.result == rhs.result } static func <(lhs: HorizontalListContextResultsChatInputContextPanelEntry, rhs: HorizontalListContextResultsChatInputContextPanelEntry) -> Bool { return lhs.index < rhs.index } func item(context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem { return HorizontalListContextResultsChatInputPanelItem(context: context, theme: self.theme, result: self.result, resultSelected: resultSelected) } } private struct HorizontalListContextResultsChatInputContextPanelTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let entryCount: Int let hasMore: Bool } private final class HorizontalListContextResultsOpaqueState { let entryCount: Int let hasMore: Bool init(entryCount: Int, hasMore: Bool) { self.entryCount = entryCount self.hasMore = hasMore } } private func preparedTransition(from fromEntries: [HorizontalListContextResultsChatInputContextPanelEntry], to toEntries: [HorizontalListContextResultsChatInputContextPanelEntry], hasMore: Bool, context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> HorizontalListContextResultsChatInputContextPanelTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, resultSelected: resultSelected), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, resultSelected: resultSelected), directionHint: nil) } return HorizontalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates, entryCount: toEntries.count, hasMore: hasMore) } final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputContextPanelNode { private let listView: ListView private let separatorNode: ASDisplayNode private var currentExternalResults: ChatContextResultCollection? private var currentProcessedResults: ChatContextResultCollection? private var currentEntries: [HorizontalListContextResultsChatInputContextPanelEntry]? private var isLoadingMore = false private let loadMoreDisposable = MetaDisposable() private var enqueuedTransitions: [(HorizontalListContextResultsChatInputContextPanelTransition, Bool)] = [] private var hasValidLayout = false override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true self.separatorNode.backgroundColor = theme.list.itemPlainSeparatorColor self.separatorNode.isHidden = true self.listView = ListView() self.listView.isOpaque = true self.listView.backgroundColor = theme.list.plainBackgroundColor self.listView.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) self.listView.isHidden = true self.listView.accessibilityPageScrolledString = { row, count in return strings.VoiceOver_ScrollStatus(row, count).string } super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true self.addSubnode(self.listView) self.addSubnode(self.separatorNode) self.listView.displayedItemRangeChanged = { [weak self] displayedRange, opaqueTransactionState in if let strongSelf = self, let state = opaqueTransactionState as? HorizontalListContextResultsOpaqueState { if let visible = displayedRange.visibleRange { if state.hasMore && visible.lastIndex >= state.entryCount - 10 { strongSelf.loadMore() } } } } } deinit { self.loadMoreDisposable.dispose() } override func didLoad() { super.didLoad() self.listView.view.disablesInteractiveTransitionGestureRecognizer = true self.listView.view.disablesInteractiveKeyboardGestureRecognizer = true self.view.addGestureRecognizer(PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in if let strongSelf = self { let convertedPoint = strongSelf.listView.view.convert(point, from: strongSelf.view) if !strongSelf.listView.bounds.contains(convertedPoint) { return nil } var selectedItemNodeAndContent: (UIView, CGRect, PeekControllerContent)? selectedItemNodeAndContent = nil strongSelf.listView.forEachItemNode { itemNode in if itemNode.frame.contains(convertedPoint), let itemNode = itemNode as? HorizontalListContextResultsChatInputPanelItemNode, let item = itemNode.item { if case let .internalReference(internalReference) = item.result, let file = internalReference.file, file.isSticker { var menuItems: [ContextMenuItem] = [] menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.StickerPack_Send, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) let _ = item.resultSelected(item.result, itemNode, itemNode.bounds) }))) for case let .Sticker(_, packReference, _) in file.attributes { guard let packReference = packReference else { continue } menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.StickerPack_ViewPack, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Sticker"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.default) if let strongSelf = self { let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: strongSelf.interfaceInteraction?.getNavigationController(), sendSticker: { file, sourceView, sourceRect in if let strongSelf = self { return strongSelf.interfaceInteraction?.sendSticker(file, false, sourceView, sourceRect, nil, []) ?? false } else { return false } }) strongSelf.interfaceInteraction?.getNavigationController()?.view.window?.endEditing(true) strongSelf.interfaceInteraction?.presentController(controller, nil) } }))) } selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, StickerPreviewPeekContent(context: item.context, theme: strongSelf.theme, strings: strongSelf.strings, item: .found(FoundStickerItem(file: file, stringRepresentations: [])), menu: menuItems, openPremiumIntro: { [weak self] in guard let strongSelf = self else { return } let controller = PremiumIntroScreen(context: strongSelf.context, source: .stickers) strongSelf.interfaceInteraction?.getNavigationController()?.pushViewController(controller) })) } else { var menuItems: [ContextMenuItem] = [] if case let .internalReference(internalReference) = item.result, let file = internalReference.file, file.isAnimated { menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Preview_SaveGif, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.dismissWithoutContent) guard let strongSelf = self else { return } let context = strongSelf.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let interfaceInteraction = strongSelf.interfaceInteraction let _ = (toggleGifSaved(account: context.account, fileReference: .standalone(media: file), saved: true) |> deliverOnMainQueue).startStandalone(next: { result in switch result { case .generic: interfaceInteraction?.presentController(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 }), nil) 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 } interfaceInteraction?.presentController(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: { action in if case .info = action { let controller = PremiumIntroScreen(context: context, source: .savedGifs) interfaceInteraction?.getNavigationController()?.pushViewController(controller) return true } return false }), nil) } }) }))) } menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.ShareMenu_Send, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.default) let _ = item.resultSelected(item.result, itemNode, itemNode.bounds) }))) selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, ChatContextResultPeekContent(account: item.context.account, contextResult: item.result, menu: menuItems)) } } } return .single(selectedItemNodeAndContent) } return nil }, present: { [weak self] content, sourceView, sourceRect in if let strongSelf = self { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let controller = PeekController(presentationData: presentationData, content: content, sourceView: { return (sourceView, sourceRect) }) strongSelf.interfaceInteraction?.presentGlobalOverlayController(controller, nil) return controller } return nil })) } func updateResults(_ results: ChatContextResultCollection) { if self.currentExternalResults == results { return } self.currentExternalResults = results self.currentProcessedResults = results self.isLoadingMore = false self.loadMoreDisposable.set(nil) self.updateInternalResults(results) } private func loadMore() { guard !self.isLoadingMore, let currentProcessedResults = self.currentProcessedResults, let nextOffset = currentProcessedResults.nextOffset else { return } self.isLoadingMore = true let geoPoint = currentProcessedResults.geoPoint.flatMap { geoPoint -> (Double, Double) in return (geoPoint.latitude, geoPoint.longitude) } self.loadMoreDisposable.set((self.context.engine.messages.requestChatContextResults(botId: currentProcessedResults.botId, peerId: currentProcessedResults.peerId, query: currentProcessedResults.query, location: .single(geoPoint), offset: nextOffset) |> map { results -> ChatContextResultCollection? in return results?.results } |> deliverOnMainQueue).startStrict(next: { [weak self] nextResults in guard let strongSelf = self, let nextResults = nextResults else { return } strongSelf.isLoadingMore = false var results: [ChatContextResult] = [] var existingIds = Set() for result in currentProcessedResults.results { results.append(result) existingIds.insert(result.id) } for result in nextResults.results { if !existingIds.contains(result.id) { results.append(result) existingIds.insert(result.id) } } let mergedResults = ChatContextResultCollection(botId: currentProcessedResults.botId, peerId: currentProcessedResults.peerId, query: currentProcessedResults.query, geoPoint: currentProcessedResults.geoPoint, queryId: nextResults.queryId, nextOffset: nextResults.nextOffset, presentation: currentProcessedResults.presentation, switchPeer: currentProcessedResults.switchPeer, webView: currentProcessedResults.webView, results: results, cacheTimeout: currentProcessedResults.cacheTimeout) strongSelf.currentProcessedResults = mergedResults strongSelf.updateInternalResults(mergedResults) })) } private func updateInternalResults(_ results: ChatContextResultCollection) { var entries: [HorizontalListContextResultsChatInputContextPanelEntry] = [] var index = 0 var resultIds = Set() for result in results.results { let entry = HorizontalListContextResultsChatInputContextPanelEntry(index: index, theme: self.theme, result: result) if resultIds.contains(entry.stableId) { continue } else { resultIds.insert(entry.stableId) } entries.append(entry) index += 1 } let firstTime = self.currentEntries == nil let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hasMore: results.nextOffset != nil, context: self.context, resultSelected: { [weak self] result, node, rect in if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { return interfaceInteraction.sendContextResult(results, result, node, rect) } else { return false } }) self.currentEntries = entries self.enqueueTransition(transition, firstTime: firstTime) } private func enqueueTransition(_ transition: HorizontalListContextResultsChatInputContextPanelTransition, firstTime: Bool) { enqueuedTransitions.append((transition, firstTime)) if self.hasValidLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } private func dequeueTransition() { if let (transition, firstTime) = self.enqueuedTransitions.first { self.enqueuedTransitions.remove(at: 0) var options = ListViewDeleteAndInsertOptions() options.insert(.Synchronous) options.insert(.LowLatency) options.insert(.PreferSynchronousResourceLoading) if firstTime { //options.insert(.Synchronous) //options.insert(.LowLatency) } else { //options.insert(.AnimateTopItemPosition) //options.insert(.AnimateCrossfade) } self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: HorizontalListContextResultsOpaqueState(entryCount: transition.entryCount, hasMore: transition.hasMore), completion: { [weak self] _ in if let strongSelf = self, firstTime { let position = strongSelf.listView.position let separatorPosition = strongSelf.separatorNode.layer.position strongSelf.listView.isHidden = false strongSelf.separatorNode.isHidden = false strongSelf.listView.position = CGPoint(x: position.x, y: position.y + strongSelf.listView.bounds.size.width) strongSelf.separatorNode.position = CGPoint(x: separatorPosition.x, y: separatorPosition.y + strongSelf.listView.bounds.size.width) ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring).animateView { strongSelf.listView.position = position strongSelf.separatorNode.position = separatorPosition } } }) } } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { let listHeight: CGFloat = 105.0 transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - listHeight), size: CGSize(width: size.width, height: UIScreenPixel))) self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: listHeight, height: size.width) //transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) transition.updatePosition(node: self.listView, position: CGPoint(x: size.width / 2.0, y: size.height - listHeight / 2.0)) var insets = UIEdgeInsets() insets.top = leftInset insets.bottom = rightInset let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: listHeight, height: size.width), insets: insets, duration: duration, curve: curve) self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) if !hasValidLayout { hasValidLayout = true while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } if self.theme !== interfaceState.theme { self.theme = interfaceState.theme self.separatorNode.backgroundColor = theme.list.itemPlainSeparatorColor self.listView.backgroundColor = theme.list.plainBackgroundColor } } override func animateOut(completion: @escaping () -> Void) { let position = self.listView.layer.position self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + self.listView.bounds.size.width), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in completion() }) let separatorPosition = self.separatorNode.layer.position self.separatorNode.layer.animatePosition(from: separatorPosition, to: CGPoint(x: separatorPosition.x, y: separatorPosition.y + listView.bounds.size.width), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let listViewBounds = self.listView.bounds let listViewPosition = self.listView.position let listViewFrame = CGRect(origin: CGPoint(x: listViewPosition.x - listViewBounds.height / 2.0, y: listViewPosition.y - listViewBounds.width / 2.0), size: CGSize(width: listViewBounds.height, height: listViewBounds.width)) if !listViewFrame.contains(point) { return nil } return super.hitTest(point, with: event) } }