import Foundation import UIKit import AsyncDisplayKit import Postbox import TelegramCore import Display import TelegramPresentationData import TelegramUIPreferences import MergeLists import AccountContext import Emoji import ChatPresentationInterfaceState import AnimationCache import MultiAnimationRenderer import TextFormat import ChatControllerInteraction import ContextUI import SwiftSignalKit import PremiumUI import StickerPeekUI import UndoUI import Pasteboard import ChatContextQuery import ChatInputContextPanelNode private enum EmojisChatInputContextPanelEntryStableId: Hashable, Equatable { case symbol(String) case media(MediaId) } private func backgroundCenterImage(_ theme: PresentationTheme) -> UIImage? { return generateImage(CGSize(width: 30.0, height: 55.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setStrokeColor(theme.list.itemPlainSeparatorColor.cgColor) context.setFillColor(theme.list.plainBackgroundColor.cgColor) let lineWidth = UIScreenPixel context.setLineWidth(lineWidth) context.translateBy(x: 460.5, y: 364.0 - 27.0) let path: StaticString = "M-490.476836,-365 L-394.167708,-365 L-394.167708,-291.918214 C-394.167708,-291.918214 -383.538396,-291.918214 -397.691655,-291.918214 C-402.778486,-291.918214 -424.555168,-291.918214 -434.037301,-291.918214 C-440.297129,-291.918214 -440.780682,-283.5 -445.999879,-283.5 C-450.393041,-283.5 -452.491241,-291.918214 -456.502636,-291.918214 C-465.083339,-291.918214 -476.209155,-291.918214 -483.779021,-291.918214 C-503.033963,-291.918214 -490.476836,-291.918214 -490.476836,-291.918214 L-490.476836,-365 " let _ = try? drawSvgPath(context, path: path) context.fillPath() context.translateBy(x: 0.0, y: lineWidth / 2.0) let _ = try? drawSvgPath(context, path: path) context.strokePath() context.translateBy(x: -460.5, y: -lineWidth / 2.0 - 364.0 + 27.0) context.move(to: CGPoint(x: 0.0, y: lineWidth / 2.0)) context.addLine(to: CGPoint(x: size.width, y: lineWidth / 2.0)) context.strokePath() }) } private func backgroundLeftImage(_ theme: PresentationTheme) -> UIImage? { return generateImage(CGSize(width: 8.0, height: 16.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setStrokeColor(theme.list.itemPlainSeparatorColor.cgColor) context.setFillColor(theme.list.plainBackgroundColor.cgColor) let lineWidth = UIScreenPixel context.setLineWidth(lineWidth) context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.height, height: size.height))) context.strokeEllipse(in: CGRect(origin: CGPoint(x: lineWidth / 2.0, y: lineWidth / 2.0), size: CGSize(width: size.height - lineWidth, height: size.height - lineWidth))) })?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 8) } private struct EmojisChatInputContextPanelEntry: Comparable, Identifiable { let index: Int let theme: PresentationTheme let symbol: String let text: String let file: TelegramMediaFile? var stableId: EmojisChatInputContextPanelEntryStableId { if let file = self.file { return .media(file.fileId) } else { return .symbol(self.symbol) } } func withUpdatedTheme(_ theme: PresentationTheme) -> EmojisChatInputContextPanelEntry { return EmojisChatInputContextPanelEntry(index: self.index, theme: theme, symbol: self.symbol, text: self.text, file: self.file) } static func ==(lhs: EmojisChatInputContextPanelEntry, rhs: EmojisChatInputContextPanelEntry) -> Bool { return lhs.index == rhs.index && lhs.symbol == rhs.symbol && lhs.text == rhs.text && lhs.theme === rhs.theme && lhs.file?.fileId == rhs.file?.fileId } static func <(lhs: EmojisChatInputContextPanelEntry, rhs: EmojisChatInputContextPanelEntry) -> Bool { return lhs.index < rhs.index } func item(context: AccountContext, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, emojiSelected: @escaping (String, TelegramMediaFile?) -> Void) -> ListViewItem { return EmojisChatInputPanelItem(context: context, theme: self.theme, symbol: self.symbol, text: self.text, file: self.file, animationCache: animationCache, animationRenderer: animationRenderer, emojiSelected: emojiSelected) } } private struct EmojisChatInputContextPanelTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] } private func preparedTransition(from fromEntries: [EmojisChatInputContextPanelEntry], to toEntries: [EmojisChatInputContextPanelEntry], context: AccountContext, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, emojiSelected: @escaping (String, TelegramMediaFile?) -> Void) -> EmojisChatInputContextPanelTransition { 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, animationCache: animationCache, animationRenderer: animationRenderer, emojiSelected: emojiSelected), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, animationCache: animationCache, animationRenderer: animationRenderer, emojiSelected: emojiSelected), directionHint: nil) } return EmojisChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates) } final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { private let backgroundLeftNode: ASImageNode private let backgroundNode: ASImageNode private let backgroundRightNode: ASImageNode private let clippingNode: ASDisplayNode private let listView: ListView private var currentEntries: [EmojisChatInputContextPanelEntry]? private var enqueuedTransitions: [(EmojisChatInputContextPanelTransition, Bool)] = [] private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)? private var presentationInterfaceState: ChatPresentationInterfaceState? private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer private weak var peekController: PeekController? override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.animationCache = chatPresentationContext.animationCache self.animationRenderer = chatPresentationContext.animationRenderer self.backgroundNode = ASImageNode() self.backgroundNode.displayWithoutProcessing = true self.backgroundNode.displaysAsynchronously = false self.backgroundNode.image = backgroundCenterImage(theme) self.backgroundLeftNode = ASImageNode() self.backgroundLeftNode.displayWithoutProcessing = true self.backgroundLeftNode.displaysAsynchronously = false self.backgroundLeftNode.image = backgroundLeftImage(theme) self.backgroundRightNode = ASImageNode() self.backgroundRightNode.displayWithoutProcessing = true self.backgroundRightNode.displaysAsynchronously = false self.backgroundRightNode.image = backgroundLeftImage(theme) self.backgroundRightNode.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) self.clippingNode = ASDisplayNode() self.clippingNode.clipsToBounds = true self.listView = ListView() self.listView.isOpaque = false self.listView.view.disablesInteractiveTransitionGestureRecognizer = true self.listView.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) 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.placement = .overTextInput self.isOpaque = false self.addSubnode(self.backgroundNode) self.addSubnode(self.backgroundLeftNode) self.addSubnode(self.backgroundRightNode) self.addSubnode(self.clippingNode) self.clippingNode.addSubnode(self.listView) let peekRecognizer = PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in guard let self else { return nil } return self.peekContentAtPoint(point: point) }, present: { [weak self] content, sourceView, sourceRect in guard let strongSelf = self else { return nil } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let controller = PeekController(presentationData: presentationData, content: content, sourceView: { return (sourceView, sourceRect) }) /*controller.visibilityUpdated = { [weak self] visible in self?.previewingStickersPromise.set(visible) self?.requestDisableStickerAnimations?(visible) self?.simulateUpdateLayout(isVisible: !visible) }*/ strongSelf.peekController = controller strongSelf.interfaceInteraction?.presentController(controller, nil) return controller }, updateContent: { [weak self] content in guard let strongSelf = self else { return } let _ = strongSelf }) self.view.addGestureRecognizer(peekRecognizer) } private func peekContentAtPoint(point: CGPoint) -> Signal<(UIView, CGRect, PeekControllerContent)?, NoError>? { guard let presentationInterfaceState = self.presentationInterfaceState else { return nil } guard let chatPeerId = presentationInterfaceState.renderedPeer?.peer?.id else { return nil } var maybeFile: TelegramMediaFile? var maybeItemLayer: CALayer? self.listView.forEachItemNode { itemNode in if let itemNode = itemNode as? EmojisChatInputPanelItemNode, let item = itemNode.item { let localPoint = self.view.convert(point, to: itemNode.view) if itemNode.view.bounds.contains(localPoint) { maybeFile = item.file maybeItemLayer = itemNode.layer } } } guard let file = maybeFile else { return nil } guard let itemLayer = maybeItemLayer else { return nil } let _ = chatPeerId let _ = file let _ = itemLayer var collectionId: ItemCollectionId? for attribute in file.attributes { if case let .CustomEmoji(_, _, _, packReference) = attribute { switch packReference { case let .id(id, _): collectionId = ItemCollectionId(namespace: Namespaces.ItemCollection.CloudEmojiPacks, id: id) default: break } } } var bubbleUpEmojiOrStickersets: [ItemCollectionId] = [] if let collectionId { bubbleUpEmojiOrStickersets.append(collectionId) } let context = self.context let accountPeerId = context.account.peerId let _ = bubbleUpEmojiOrStickersets let _ = context let _ = accountPeerId return context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: accountPeerId)) |> map { peer -> Bool in var hasPremium = false if case let .user(user) = peer, user.isPremium { hasPremium = true } return hasPremium } |> deliverOnMainQueue |> map { [weak self, weak itemLayer] hasPremium -> (UIView, CGRect, PeekControllerContent)? in guard let strongSelf = self, let itemLayer = itemLayer else { return nil } let _ = strongSelf let _ = itemLayer var menuItems: [ContextMenuItem] = [] menuItems.removeAll() let presentationData = context.sharedContext.currentPresentationData.with { $0 } let _ = presentationData var isLocked = false if !hasPremium { isLocked = file.isPremiumEmoji if isLocked && chatPeerId == context.account.peerId { isLocked = false } } if let interaction = strongSelf.interfaceInteraction { let _ = interaction let sendEmoji: (TelegramMediaFile) -> Void = { file in guard let self else { return } guard let controller = (self.interfaceInteraction?.chatController() as? ChatControllerImpl) else { return } var text = "." var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? loop: for attribute in file.attributes { switch attribute { case let .CustomEmoji(_, _, displayText, stickerPackReference): text = displayText var packId: ItemCollectionId? if case let .id(id, _) = stickerPackReference { packId = ItemCollectionId(namespace: Namespaces.ItemCollection.CloudEmojiPacks, id: id) } emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: packId, fileId: file.fileId.id, file: file) break loop default: break } } if let emojiAttribute { controller.controllerInteraction?.sendEmoji(text, emojiAttribute, true) } } let setStatus: (TelegramMediaFile) -> Void = { file in guard let self else { return } guard let controller = (self.interfaceInteraction?.chatController() as? ChatControllerImpl) else { return } let _ = self.context.engine.accountData.setEmojiStatus(file: file, expirationDate: nil).startStandalone() var animateInAsReplacement = false animateInAsReplacement = false /*if let currentUndoOverlayController = strongSelf.currentUndoOverlayController { currentUndoOverlayController.dismissWithCommitActionAndReplacementAnimation() strongSelf.currentUndoOverlayController = nil animateInAsReplacement = true }*/ let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let undoController = UndoOverlayController(presentationData: presentationData, content: .sticker(context: self.context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) //strongSelf.currentUndoOverlayController = controller controller.controllerInteraction?.presentController(undoController, nil) } let copyEmoji: (TelegramMediaFile) -> Void = { file in var text = "." var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? loop: for attribute in file.attributes { switch attribute { case let .CustomEmoji(_, _, displayText, _): text = displayText emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file) break loop default: break } } if let _ = emojiAttribute { storeMessageTextInPasteboard(text, entities: [MessageTextEntity(range: 0 ..< (text as NSString).length, type: .CustomEmoji(stickerPack: nil, fileId: file.fileId.id))]) } } menuItems.append(.action(ContextMenuActionItem(text: presentationData.strings.EmojiPreview_SendEmoji, icon: { theme in if let image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) { return generateImage(image.size, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) if let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(), size: size)) } }) } else { return nil } }, action: { _, f in sendEmoji(file) f(.default) }))) menuItems.append(.action(ContextMenuActionItem(text: presentationData.strings.EmojiPreview_SetAsStatus, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Smile"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.default) guard let strongSelf = self else { return } if hasPremium { setStatus(file) } else { var replaceImpl: ((ViewController) -> Void)? let controller = PremiumDemoScreen(context: context, subject: .animatedEmoji, action: { let controller = PremiumIntroScreen(context: context, source: .animatedEmoji) replaceImpl?(controller) }) replaceImpl = { [weak controller] c in controller?.replace(with: c) } strongSelf.interfaceInteraction?.getNavigationController()?.pushViewController(controller) } }))) menuItems.append(.action(ContextMenuActionItem(text: presentationData.strings.EmojiPreview_CopyEmoji, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in copyEmoji(file) f(.default) }))) } if menuItems.isEmpty { return nil } let content = StickerPreviewPeekContent(context: context, theme: presentationData.theme, strings: presentationData.strings, item: .pack(file), isLocked: isLocked, menu: menuItems, openPremiumIntro: { [weak self] in guard let self else { return } guard let interfaceInteraction = self.interfaceInteraction else { return } let _ = self let _ = interfaceInteraction let controller = PremiumIntroScreen(context: context, source: .stickers) //let _ = controller interfaceInteraction.getNavigationController()?.pushViewController(controller) }) let _ = content //return nil return (strongSelf.view, itemLayer.convert(itemLayer.bounds, to: strongSelf.view.layer), content) } } func updateResults(_ results: [(String, TelegramMediaFile?, String)]) { var entries: [EmojisChatInputContextPanelEntry] = [] var index = 0 var stableIds = Set() for (symbol, file, text) in results { let entry = EmojisChatInputContextPanelEntry(index: index, theme: self.theme, symbol: symbol.normalizedEmoji, text: text, file: file) if stableIds.contains(entry.stableId) { continue } stableIds.insert(entry.stableId) entries.append(entry) index += 1 } self.prepareTransition(from: self.currentEntries, to: entries) } private func prepareTransition(from: [EmojisChatInputContextPanelEntry]? , to: [EmojisChatInputContextPanelEntry]) { let firstTime = self.currentEntries == nil let transition = preparedTransition(from: from ?? [], to: to, context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, emojiSelected: { [weak self] text, file in guard let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction else { return } var text = text interfaceInteraction.updateTextInputStateAndMode { textInputState, inputMode in var hashtagQueryRange: NSRange? inner: for (range, type, _) in textInputStateContextQueryRangeAndType(textInputState) { if type == [.emojiSearch] { var range = range range.location -= 1 range.length += 1 hashtagQueryRange = range break inner } } if let range = hashtagQueryRange { let inputText = NSMutableAttributedString(attributedString: textInputState.inputText) var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? if let file = file { loop: for attribute in file.attributes { switch attribute { case let .CustomEmoji(_, _, displayText, _): text = displayText emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file) break loop default: break } } } var replacementText = NSAttributedString(string: text) if let emojiAttribute = emojiAttribute { replacementText = NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute]) } inputText.replaceCharacters(in: range, with: replacementText) let selectionPosition = range.lowerBound + (replacementText.string as NSString).length return (ChatTextInputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition), inputMode) } return (textInputState, inputMode) } }) self.currentEntries = to self.enqueueTransition(transition, firstTime: firstTime) if let presentationInterfaceState = presentationInterfaceState, let (size, leftInset, rightInset, bottomInset) = self.validLayout { self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, transition: .immediate, interfaceState: presentationInterfaceState) } } private func enqueueTransition(_ transition: EmojisChatInputContextPanelTransition, firstTime: Bool) { enqueuedTransitions.append((transition, firstTime)) if self.validLayout != nil { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } private func dequeueTransition() { if let validLayout = self.validLayout, let (transition, _) = self.enqueuedTransitions.first { self.enqueuedTransitions.remove(at: 0) var options = ListViewDeleteAndInsertOptions() options.insert(.Synchronous) let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: validLayout.0, insets: UIEdgeInsets(top: 3.0, left: 0.0, bottom: 3.0, right: 0.0), duration: 0.0, curve: .Default(duration: nil)) self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: updateSizeAndInsets, updateOpaqueState: nil) } } override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { let hadValidLayout = self.validLayout != nil self.validLayout = (size, leftInset, rightInset, bottomInset) self.presentationInterfaceState = interfaceState let sideInsets: CGFloat = 10.0 + leftInset let contentWidth = min(size.width - sideInsets - sideInsets, max(24.0, CGFloat(self.currentEntries?.count ?? 0) * 45.0)) var contentLeftInset: CGFloat = 40.0 var leftOffset: CGFloat = 0.0 if sideInsets + floor(contentWidth / 2.0) < sideInsets + contentLeftInset + 15.0 { let updatedLeftInset = sideInsets + floor(contentWidth / 2.0) - 15.0 - sideInsets leftOffset = contentLeftInset - updatedLeftInset contentLeftInset = updatedLeftInset } let backgroundFrame = CGRect(origin: CGPoint(x: sideInsets + leftOffset, y: size.height - 55.0 + 4.0), size: CGSize(width: contentWidth, height: 55.0)) let backgroundLeftFrame = CGRect(origin: backgroundFrame.origin, size: CGSize(width: contentLeftInset, height: backgroundFrame.size.height - 10.0 + UIScreenPixel)) let backgroundCenterFrame = CGRect(origin: CGPoint(x: backgroundLeftFrame.maxX, y: backgroundFrame.minY), size: CGSize(width: 30.0, height: 55.0)) let backgroundRightFrame = CGRect(origin: CGPoint(x: backgroundCenterFrame.maxX, y: backgroundFrame.minY), size: CGSize(width: max(0.0, backgroundFrame.minX + backgroundFrame.size.width - backgroundCenterFrame.maxX), height: backgroundFrame.size.height - 10.0 + UIScreenPixel)) transition.updateFrame(node: self.backgroundLeftNode, frame: backgroundLeftFrame) transition.updateFrame(node: self.backgroundNode, frame: backgroundCenterFrame) transition.updateFrame(node: self.backgroundRightNode, frame: backgroundRightFrame) let gridFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX, y: backgroundFrame.minY + 2.0), size: CGSize(width: backgroundFrame.size.width, height: 45.0)) transition.updateFrame(node: self.clippingNode, frame: gridFrame) self.listView.frame = CGRect(origin: CGPoint(), size: CGSize(width: gridFrame.size.height, height: gridFrame.size.width)) let gridBounds = self.listView.bounds self.listView.bounds = CGRect(x: gridBounds.minX, y: gridBounds.minY, width: gridFrame.size.height, height: gridFrame.size.width) self.listView.position = CGPoint(x: gridFrame.size.width / 2.0, y: gridFrame.size.height / 2.0) let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: gridFrame.size.height, height: gridFrame.size.width), insets: UIEdgeInsets(top: 3.0, left: 0.0, bottom: 3.0, right: 0.0), duration: 0.0, curve: .Default(duration: 0.0)) self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) if !hadValidLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } if self.theme !== interfaceState.theme { self.theme = interfaceState.theme let updatedEntries = self.currentEntries?.map({$0.withUpdatedTheme(interfaceState.theme)}) ?? [] self.prepareTransition(from: self.currentEntries, to: updatedEntries) } } override func animateOut(completion: @escaping () -> Void) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in completion() }) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.clippingNode.frame.contains(point) { return nil } return super.hitTest(point, with: event) } }