import Foundation import UIKit import Display import AsyncDisplayKit import TelegramPresentationData import ChatPresentationInterfaceState import AccountContext import ComponentFlow import MultilineTextComponent import PlainButtonComponent import UIKitRuntimeUtils import TelegramCore import EmojiStatusComponent import SwiftSignalKit import ContextUI import PromptUI final class ChatSearchTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, UIScrollViewDelegate { private struct Params: Equatable { var width: CGFloat var leftInset: CGFloat var rightInset: CGFloat var interfaceState: ChatPresentationInterfaceState init(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, interfaceState: ChatPresentationInterfaceState) { self.width = width self.leftInset = leftInset self.rightInset = rightInset self.interfaceState = interfaceState } static func ==(lhs: Params, rhs: Params) -> Bool { if lhs.width != rhs.width { return false } if lhs.leftInset != rhs.leftInset { return false } if lhs.rightInset != rhs.rightInset { return false } if lhs.interfaceState != rhs.interfaceState { return false } return true } } private final class Item { let reaction: MessageReaction.Reaction let count: Int let title: String? let file: TelegramMediaFile init(reaction: MessageReaction.Reaction, count: Int, title: String?, file: TelegramMediaFile) { self.reaction = reaction self.count = count self.title = title self.file = file } } private final class ItemView: UIView { private let context: AccountContext private let action: () -> Void private let extractedContainerNode: ContextExtractedContentContainingNode private let containerNode: ContextControllerSourceNode private let containerButton: HighlightTrackingButton private let background: UIImageView private let icon = ComponentView() private let counter = ComponentView() init(context: AccountContext, action: @escaping (() -> Void), contextGesture: @escaping (ContextGesture, ContextExtractedContentContainingNode) -> Void) { self.context = context self.action = action self.extractedContainerNode = ContextExtractedContentContainingNode() self.containerNode = ContextControllerSourceNode() self.containerButton = HighlightTrackingButton() self.background = UIImageView() if let image = UIImage(bundleImageName: "Chat/Title Panels/SearchTagTab") { self.background.image = image.stretchableImage(withLeftCapWidth: 8, topCapHeight: 0).withRenderingMode(.alwaysTemplate) } super.init(frame: CGRect()) self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) self.containerNode.addSubnode(self.extractedContainerNode) self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode self.addSubview(self.containerNode.view) self.containerButton.addSubview(self.background) self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) self.containerButton.highligthedChanged = { [weak self] highlighted in if let self, self.bounds.width > 0.0 { let topScale: CGFloat = (self.bounds.width - 1.0) / self.bounds.width let maxScale: CGFloat = (self.bounds.width + 1.0) / self.bounds.width if highlighted { self.layer.removeAnimation(forKey: "opacity") self.layer.removeAnimation(forKey: "sublayerTransform") let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) transition.updateTransformScale(layer: self.layer, scale: topScale) } else { let transition: ContainedViewLayoutTransition = .immediate transition.updateTransformScale(layer: self.layer, scale: 1.0) self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in guard let self else { return } self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) }) } } } self.containerNode.activated = { [weak self] gesture, _ in guard let self else { return } contextGesture(gesture, self.extractedContainerNode) } } required init?(coder: NSCoder) { preconditionFailure() } @objc private func pressed() { self.action() } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { var mappedPoint = point if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { mappedPoint = self.bounds.center } return super.hitTest(mappedPoint, with: event) } func update(item: Item, isSelected: Bool, theme: PresentationTheme, height: CGFloat, transition: Transition) -> CGSize { let spacing: CGFloat = 4.0 let reactionSize = CGSize(width: 16.0, height: 16.0) var reactionDisplaySize = reactionSize if case .builtin = item.reaction { reactionDisplaySize = CGSize(width: reactionDisplaySize.width * 2.0, height: reactionDisplaySize.height * 2.0) } let _ = self.icon.update( transition: .immediate, component: AnyComponent(EmojiStatusComponent( context: self.context, animationCache: self.context.animationCache, animationRenderer: self.context.animationRenderer, content: .animation( content: .file(file: item.file), size: reactionDisplaySize, placeholderColor: theme.list.mediaPlaceholderColor, themeColor: theme.list.itemPrimaryTextColor, loopMode: .forever ), isVisibleForAnimations: false, useSharedAnimation: true, action: nil, emojiFileUpdated: nil )), environment: {}, containerSize: reactionDisplaySize ) let title: String if let value = item.title, !value.isEmpty { title = "\(value) \(item.count)" } else { title = "\(item.count)" } let counterSize = self.counter.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: title, font: Font.regular(14.0), textColor: isSelected ? theme.list.itemCheckColors.foregroundColor : theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.6))) )), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) let size = CGSize(width: reactionSize.width + spacing + counterSize.width, height: height) let iconFrame = CGRect(origin: CGPoint(x: 0.0, y: floor((size.height - reactionSize.height) * 0.5)), size: reactionSize) let counterFrame = CGRect(origin: CGPoint(x: iconFrame.maxX + spacing, y: floor((size.height - counterSize.height) * 0.5)), size: counterSize) if let iconView = self.icon.view { if iconView.superview == nil { iconView.isUserInteractionEnabled = false self.containerButton.addSubview(iconView) } iconView.frame = reactionDisplaySize.centered(around: iconFrame.center) } if let counterView = self.counter.view { if counterView.superview == nil { counterView.isUserInteractionEnabled = false self.containerButton.addSubview(counterView) } counterView.frame = counterFrame } if theme.overallDarkAppearance { self.background.tintColor = isSelected ? theme.list.itemCheckColors.fillColor : UIColor(white: 1.0, alpha: 0.1) } else { self.background.tintColor = isSelected ? theme.list.itemCheckColors.fillColor : theme.list.controlSecondaryColor } if let image = self.background.image { let backgroundFrame = CGRect(origin: CGPoint(x: -6.0, y: floorToScreenPixels((size.height - image.size.height) * 0.5)), size: CGSize(width: size.width + 6.0 + 9.0, height: image.size.height)) transition.setFrame(view: self.background, frame: backgroundFrame) } transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) self.containerNode.frame = CGRect(origin: CGPoint(), size: size) return size } } private final class ScrollView: UIScrollView { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } override func touchesShouldCancel(in view: UIView) -> Bool { return true } } private let context: AccountContext private let scrollView: ScrollView private var params: Params? private var items: [Item] = [] private var itemViews: [MessageReaction.Reaction: ItemView] = [:] private var itemsDisposable: Disposable? init(context: AccountContext, chatLocation: ChatLocation) { self.context = context self.scrollView = ScrollView(frame: CGRect()) super.init() self.scrollView.delaysContentTouches = false self.scrollView.canCancelContentTouches = true self.scrollView.clipsToBounds = false self.scrollView.contentInsetAdjustmentBehavior = .never if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false self.scrollView.alwaysBounceVertical = false self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.view.addSubview(self.scrollView) self.scrollView.disablesInteractiveTransitionGestureRecognizer = true let tagsAndFiles: Signal<([MessageReaction.Reaction: Int], [Int64: TelegramMediaFile]), NoError> = context.engine.data.subscribe( TelegramEngine.EngineData.Item.Messages.SavedMessageTagStats(peerId: context.account.peerId, threadId: chatLocation.threadId) ) |> distinctUntilChanged |> mapToSignal { tags -> Signal<([MessageReaction.Reaction: Int], [Int64: TelegramMediaFile]), NoError> in var customFileIds: [Int64] = [] for (reaction, _) in tags { switch reaction { case .builtin: break case let .custom(fileId): customFileIds.append(fileId) } } return context.engine.stickers.resolveInlineStickers(fileIds: customFileIds) |> map { files in return (tags, files) } } var isFirstUpdate = true self.itemsDisposable = (combineLatest( context.engine.stickers.availableReactions(), context.engine.stickers.savedMessageTagData(), tagsAndFiles ) |> deliverOnMainQueue).start(next: { [weak self] availableReactions, savedMessageTags, tagsAndFiles in guard let self else { return } self.items.removeAll() let (tags, files) = tagsAndFiles for (reaction, count) in tags { let title = savedMessageTags?.tags.first(where: { $0.reaction == reaction })?.title switch reaction { case .builtin: if let availableReactions { inner: for availableReaction in availableReactions.reactions { if availableReaction.value == reaction { if let file = availableReaction.centerAnimation { self.items.append(Item(reaction: reaction, count: count, title: title, file: file)) } break inner } } } case let .custom(fileId): if let file = files[fileId] { self.items.append(Item(reaction: reaction, count: count, title: title, file: file)) } } } self.items.sort(by: { lhs, rhs in if lhs.count != rhs.count { return lhs.count > rhs.count } return lhs.reaction < rhs.reaction }) self.update(transition: isFirstUpdate ? .immediate : .animated(duration: 0.3, curve: .easeInOut)) isFirstUpdate = false }) } deinit { self.itemsDisposable?.dispose() } private func update(transition: ContainedViewLayoutTransition) { if let params = self.params { self.update(params: params, transition: transition) } } override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult { let params = Params(width: width, leftInset: leftInset, rightInset: rightInset, interfaceState: interfaceState) if self.params != params { self.params = params self.update(params: params, transition: transition) } let panelHeight: CGFloat = 39.0 return LayoutResult(backgroundHeight: panelHeight, insetHeight: panelHeight, hitTestSlop: 0.0) } private func update(params: Params, transition: ContainedViewLayoutTransition) { let panelHeight: CGFloat = 39.0 let containerInsets = UIEdgeInsets(top: 0.0, left: params.leftInset + 16.0, bottom: 0.0, right: params.rightInset + 16.0) let itemSpacing: CGFloat = 26.0 var contentSize = CGSize(width: 0.0, height: panelHeight) contentSize.width += containerInsets.left var validIds: [MessageReaction.Reaction] = [] var isFirst = true for item in self.items { if isFirst { isFirst = false } else { contentSize.width += itemSpacing } let itemId = item.reaction validIds.append(itemId) var itemTransition = transition var animateIn = false let itemView: ItemView if let current = self.itemViews[itemId] { itemView = current } else { itemTransition = .immediate animateIn = true let reaction = item.reaction itemView = ItemView(context: self.context, action: { [weak self] in guard let self else { return } let tag = ReactionsMessageAttribute.messageTag(reaction: reaction) self.interfaceInteraction?.updateHistoryFilter({ filter in var tags: [EngineMessage.CustomTag] = filter?.customTags ?? [] if let index = tags.firstIndex(of: tag) { tags.remove(at: index) } else { tags.append(tag) } if tags.isEmpty { return nil } else { return ChatPresentationInterfaceState.HistoryFilter(customTags: tags, isActive: filter?.isActive ?? true) } }) if let itemView = self.itemViews[reaction] { self.scrollView.scrollRectToVisible(itemView.frame.insetBy(dx: -46.0, dy: 0.0), animated: true) } }, contextGesture: { [weak self] gesture, sourceNode in guard let self, let interfaceInteraction = self.interfaceInteraction, let chatController = interfaceInteraction.chatController() else { gesture.cancel() return } var items: [ContextMenuItem] = [] let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 }) //TODO:localize items.append(.action(ContextMenuActionItem(text: "Edit Title", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, a in guard let self else { a(.default) return } c.dismiss(completion: { [weak self] in guard let self else { return } self.openEditTagTitle(reaction: reaction) }) }))) let controller = ContextController(presentationData: presentationData, source: .extracted(TagContextExtractedContentSource(controller: chatController, sourceNode: sourceNode, keepInPlace: false)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture) interfaceInteraction.presentGlobalOverlayController(controller, nil) }) self.itemViews[itemId] = itemView self.scrollView.addSubview(itemView) } var isSelected = false if let historyFilter = params.interfaceState.historyFilter { if historyFilter.customTags.contains(ReactionsMessageAttribute.messageTag(reaction: item.reaction)) { isSelected = true } } let itemSize = itemView.update(item: item, isSelected: isSelected, theme: params.interfaceState.theme, height: panelHeight, transition: .immediate) let itemFrame = CGRect(origin: CGPoint(x: contentSize.width, y: -5.0), size: itemSize) itemTransition.updatePosition(layer: itemView.layer, position: itemFrame.center) if animateIn && transition.isAnimated { itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) transition.animateTransformScale(view: itemView, from: 0.001) } itemView.bounds = CGRect(origin: CGPoint(), size: itemFrame.size) contentSize.width += itemSize.width } var removedIds: [MessageReaction.Reaction] = [] for (id, itemView) in self.itemViews { if !validIds.contains(id) { removedIds.append(id) if transition.isAnimated { itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak itemView] _ in itemView?.removeFromSuperview() }) transition.updateTransformScale(layer: itemView.layer, scale: 0.001) } else { itemView.removeFromSuperview() } } } for id in removedIds { self.itemViews.removeValue(forKey: id) } contentSize.width += containerInsets.right let scrollSize = CGSize(width: params.width, height: contentSize.height) if self.scrollView.bounds.size != scrollSize { self.scrollView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize) } if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize } } private func openEditTagTitle(reaction: MessageReaction.Reaction) { let _ = (self.context.engine.stickers.savedMessageTagData() |> take(1) |> deliverOnMainQueue).start(next: { [weak self] savedMessageTags in guard let self, let savedMessageTags else { return } //TODO:localize let promptController = promptController(sharedContext: self.context.sharedContext, updatedPresentationData: nil, text: "Edit Title", value: savedMessageTags.tags.first(where: { $0.reaction == reaction })?.title ?? "", characterLimit: 10, apply: { [weak self] value in guard let self else { return } if let value { let _ = self.context.engine.stickers.setSavedMessageTagTitle(reaction: reaction, title: value.isEmpty ? nil : value).start() } }) self.interfaceInteraction?.presentController(promptController, nil) }) } } private final class TagContextExtractedContentSource: ContextExtractedContentSource { let keepInPlace: Bool let ignoreContentTouches: Bool = true let blurBackground: Bool = true let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center private let controller: ViewController private let sourceNode: ContextExtractedContentContainingNode init(controller: ViewController, sourceNode: ContextExtractedContentContainingNode, keepInPlace: Bool) { self.controller = controller self.sourceNode = sourceNode self.keepInPlace = keepInPlace } func takeView() -> ContextControllerTakeViewInfo? { return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds) } func putBack() -> ContextControllerPutBackViewInfo? { return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) } }