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 ContextUI private struct ChatContextResultStableId: Hashable { let result: ChatContextResult var hashValue: Int { return 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(account: Account, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem { return HorizontalListContextResultsChatInputPanelItem(account: account, 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, account: Account, 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(account: account, resultSelected: resultSelected), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, resultSelected: resultSelected), directionHint: nil) } return HorizontalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates, entryCount: toEntries.count, hasMore: hasMore) } final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputContextPanelNode { private var strings: PresentationStrings 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) { self.strings = strings 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) 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: (ASDisplayNode, PeekControllerContent)? 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, sourceNode, sourceRect in if let strongSelf = self { return strongSelf.interfaceInteraction?.sendSticker(file, false, sourceNode, sourceRect) ?? false } else { return false } }) strongSelf.interfaceInteraction?.getNavigationController()?.view.window?.endEditing(true) strongSelf.interfaceInteraction?.presentController(controller, nil) } }))) } selectedItemNodeAndContent = (itemNode, StickerPreviewPeekContent(account: item.account, item: .found(FoundStickerItem(file: file, stringRepresentations: [])), menu: menuItems)) } 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 _ = addSavedGif(postbox: strongSelf.context.account.postbox, fileReference: .standalone(media: file)).start() }))) } 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, ChatContextResultPeekContent(account: item.account, contextResult: item.result, menu: menuItems)) } } } return .single(selectedItemNodeAndContent) } return nil }, present: { [weak self] content, sourceNode in if let strongSelf = self { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let controller = PeekController(presentationData: presentationData, content: content, sourceNode: { return sourceNode }) 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).start(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, 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, account: self.context.account, 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) } }