diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 38a08c5474..ea22dc9a76 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -46,7 +46,7 @@ public final class ChatMessageItemAssociatedData: Equatable { var defaultReaction: String? if let availableReactions = availableReactions { for reaction in availableReactions.reactions { - if reaction.title.lowercased().contains("heart") { + if reaction.title.lowercased().contains("thumbs up") { defaultReaction = reaction.value } } diff --git a/submodules/Components/ReactionListContextMenuContent/BUILD b/submodules/Components/ReactionListContextMenuContent/BUILD index c82e970bbb..70072765c9 100644 --- a/submodules/Components/ReactionListContextMenuContent/BUILD +++ b/submodules/Components/ReactionListContextMenuContent/BUILD @@ -20,6 +20,7 @@ swift_library( "//submodules/WebPBinding:WebPBinding", "//submodules/AnimatedAvatarSetNode:AnimatedAvatarSetNode", "//submodules/ContextUI:ContextUI", + "//submodules/AvatarNode:AvatarNode", ], visibility = [ "//visibility:public", diff --git a/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift b/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift index bd2d28c56e..d5df070e9d 100644 --- a/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift +++ b/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift @@ -11,36 +11,793 @@ import UIKit import WebPBinding import AnimatedAvatarSetNode import ContextUI +import AvatarNode -public final class ReactionListContextMenuContent: ContextControllerItemsContent { - final class ItemsNode: ASDisplayNode, ContextControllerItemsNode { - private let contentNode: ASDisplayNode - - override init() { - self.contentNode = ASDisplayNode() +private final class ReactionImageNode: ASImageNode { + private var disposable: Disposable? + let size: CGSize + + init(context: AccountContext, availableReactions: AvailableReactions?, reaction: String) { + var file: TelegramMediaFile? + if let availableReactions = availableReactions { + for availableReaction in availableReactions.reactions { + if availableReaction.value == reaction { + file = availableReaction.staticIcon + break + } + } + } + if let file = file { + self.size = file.dimensions?.cgSize ?? CGSize(width: 18.0, height: 18.0) super.init() - self.addSubnode(self.contentNode) - //self.contentNode.backgroundColor = .blue - } - - func update(constrainedWidth: CGFloat, maxHeight: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, visibleSize: CGSize) { - let size = CGSize(width: min(260.0, constrainedWidth), height: maxHeight) - - let contentSize = CGSize(width: size.width, height: size.height + bottomInset + 14.0) - //contentSize.height = 120.0 - - self.contentNode.frame = CGRect(origin: CGPoint(), size: contentSize) - - return (size, contentSize) + self.disposable = (context.account.postbox.mediaBox.resourceData(file.resource) + |> deliverOnMainQueue).start(next: { [weak self] data in + guard let strongSelf = self else { + return + } + + if data.complete, let dataValue = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { + if let image = WebP.convert(fromWebP: dataValue) { + strongSelf.image = image + } + } + }) + } else { + self.size = CGSize(width: 18.0, height: 18.0) + super.init() } } - public init() { - } - - public func node() -> ContextControllerItemsNode { - return ItemsNode() + deinit { + self.disposable?.dispose() + } +} + +private let avatarFont = avatarPlaceholderFont(size: 16.0) + +public final class ReactionListContextMenuContent: ContextControllerItemsContent { + private final class BackButtonNode: HighlightTrackingButtonNode { + let highlightBackgroundNode: ASDisplayNode + let titleLabelNode: ImmediateTextNode + let separatorNode: ASDisplayNode + let iconNode: ASImageNode + + var action: (() -> Void)? + + private var theme: PresentationTheme? + + init() { + self.highlightBackgroundNode = ASDisplayNode() + self.highlightBackgroundNode.alpha = 0.0 + + self.titleLabelNode = ImmediateTextNode() + self.titleLabelNode.maximumNumberOfLines = 1 + self.titleLabelNode.isUserInteractionEnabled = false + + self.iconNode = ASImageNode() + + self.separatorNode = ASDisplayNode() + + super.init() + + self.addSubnode(self.separatorNode) + self.addSubnode(self.highlightBackgroundNode) + self.addSubnode(self.titleLabelNode) + self.addSubnode(self.iconNode) + + self.highligthedChanged = { [weak self] highlighted in + guard let strongSelf = self else { + return + } + if highlighted { + strongSelf.highlightBackgroundNode.alpha = 1.0 + } else { + let previousAlpha = strongSelf.highlightBackgroundNode.alpha + strongSelf.highlightBackgroundNode.alpha = 0.0 + strongSelf.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2) + } + } + + self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) + } + + @objc private func pressed() { + self.action?() + } + + func update(size: CGSize, presentationData: PresentationData, isLast: Bool) { + let standardIconWidth: CGFloat = 32.0 + let sideInset: CGFloat = 16.0 + let iconSideInset: CGFloat = 12.0 + + if self.theme !== presentationData.theme { + self.theme = presentationData.theme + self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: presentationData.theme.contextMenu.primaryColor) + } + + self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor + self.separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor + + self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size) + + self.titleLabelNode.attributedText = NSAttributedString(string: presentationData.strings.Common_Back, font: Font.regular(17.0), textColor: presentationData.theme.contextMenu.primaryColor) + let titleSize = self.titleLabelNode.updateLayout(CGSize(width: size.width - sideInset - standardIconWidth, height: 100.0)) + self.titleLabelNode.frame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) + + if let iconImage = self.iconNode.image { + let iconWidth = max(standardIconWidth, iconImage.size.width) + let iconFrame = CGRect(origin: CGPoint(x: size.width - iconSideInset - iconWidth + floor((iconWidth - iconImage.size.width) / 2.0), y: floor((size.height - iconImage.size.height) / 2.0)), size: iconImage.size) + self.iconNode.frame = iconFrame + } + + self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel)) + self.separatorNode.isHidden = isLast + } + } + + private final class ReactionTabListNode: ASDisplayNode { + private final class ItemNode: ASDisplayNode { + let context: AccountContext + let reaction: String? + let count: Int + + let titleLabelNode: ImmediateTextNode + let iconNode: ASImageNode? + let reactionIconNode: ReactionImageNode? + + private var theme: PresentationTheme? + + var action: ((String?) -> Void)? + + init(context: AccountContext, availableReactions: AvailableReactions?, reaction: String?, count: Int) { + self.context = context + self.reaction = reaction + self.count = count + + self.titleLabelNode = ImmediateTextNode() + self.titleLabelNode.isUserInteractionEnabled = false + + if let reaction = reaction { + self.reactionIconNode = ReactionImageNode(context: context, availableReactions: availableReactions, reaction: reaction) + self.reactionIconNode?.isUserInteractionEnabled = false + self.iconNode = nil + } else { + self.reactionIconNode = nil + self.iconNode = ASImageNode() + self.iconNode?.isUserInteractionEnabled = false + } + + super.init() + + self.addSubnode(self.titleLabelNode) + if let iconNode = self.iconNode { + self.addSubnode(iconNode) + } + if let reactionIconNode = self.reactionIconNode { + self.addSubnode(reactionIconNode) + } + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.action?(self.reaction) + } + } + + func update(presentationData: PresentationData, constrainedSize: CGSize, isSelected: Bool) -> CGSize { + if presentationData.theme !== self.theme { + self.theme = presentationData.theme + + if let iconNode = self.iconNode { + iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reactions"), color: presentationData.theme.contextMenu.primaryColor) + } + } + + let sideInset: CGFloat = 12.0 + let iconSpacing: CGFloat = 4.0 + + var iconSize = CGSize(width: 22.0, height: 22.0) + if let reactionIconNode = self.reactionIconNode { + iconSize = reactionIconNode.size.aspectFitted(iconSize) + } else if let iconNode = self.iconNode, let image = iconNode.image { + iconSize = image.size.aspectFitted(iconSize) + } + + self.titleLabelNode.attributedText = NSAttributedString(string: "\(count)", font: Font.medium(11.0), textColor: presentationData.theme.contextMenu.primaryColor) + let titleSize = self.titleLabelNode.updateLayout(constrainedSize) + + let contentSize = CGSize(width: sideInset * 2.0 + titleSize.width + iconSize.width + iconSpacing, height: titleSize.height) + + self.titleLabelNode.frame = CGRect(origin: CGPoint(x: sideInset + iconSize.width + iconSpacing, y: floor((constrainedSize.height - titleSize.height) / 2.0)), size: titleSize) + + if let reactionIconNode = self.reactionIconNode { + reactionIconNode.frame = CGRect(origin: CGPoint(x: sideInset, y: floor((constrainedSize.height - iconSize.height) / 2.0)), size: iconSize) + } else if let iconNode = self.iconNode { + iconNode.frame = CGRect(origin: CGPoint(x: sideInset, y: floor((constrainedSize.height - iconSize.height) / 2.0)), size: iconSize) + } + + return CGSize(width: contentSize.width, height: constrainedSize.height) + } + } + + private let scrollNode: ASScrollNode + private let selectionHighlightNode: ASDisplayNode + private let itemNodes: [ItemNode] + + var action: ((String?) -> Void)? + + init(context: AccountContext, availableReactions: AvailableReactions?, reactions: [(String?, Int)], message: EngineMessage) { + self.scrollNode = ASScrollNode() + self.scrollNode.canCancelAllTouchesInViews = true + self.scrollNode.view.delaysContentTouches = false + self.scrollNode.view.showsVerticalScrollIndicator = false + self.scrollNode.view.showsHorizontalScrollIndicator = false + if #available(iOS 11.0, *) { + self.scrollNode.view.contentInsetAdjustmentBehavior = .never + } + + self.itemNodes = reactions.map { reaction, count in + return ItemNode(context: context, availableReactions: availableReactions, reaction: reaction, count: count) + } + + self.selectionHighlightNode = ASDisplayNode() + + super.init() + + self.addSubnode(self.scrollNode) + + self.scrollNode.addSubnode(self.selectionHighlightNode) + + for itemNode in self.itemNodes { + self.scrollNode.addSubnode(itemNode) + itemNode.action = { [weak self] reaction in + guard let strongSelf = self else { + return + } + strongSelf.action?(reaction) + } + } + } + + func update(size: CGSize, presentationData: PresentationData, selectedReaction: String?, transition: ContainedViewLayoutTransition) { + let sideInset: CGFloat = 16.0 + let spacing: CGFloat = 0.0 + let verticalInset: CGFloat = 6.0 + + self.selectionHighlightNode.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor + let highlightHeight: CGFloat = size.height - verticalInset * 2.0 + self.selectionHighlightNode.cornerRadius = highlightHeight / 2.0 + + var contentWidth: CGFloat = sideInset + for i in 0 ..< self.itemNodes.count { + if i != 0 { + contentWidth += spacing + } + + let itemNode = self.itemNodes[i] + let itemSize = itemNode.update(presentationData: presentationData, constrainedSize: CGSize(width: size.width, height: size.height), isSelected: itemNode.reaction == selectedReaction) + let itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: itemSize) + itemNode.frame = itemFrame + + if itemNode.reaction == selectedReaction { + transition.updateFrame(node: self.selectionHighlightNode, frame: CGRect(origin: CGPoint(x: itemFrame.minX, y: verticalInset), size: CGSize(width: itemFrame.width, height: highlightHeight))) + } + + contentWidth += itemSize.width + } + contentWidth += sideInset + + self.scrollNode.frame = CGRect(origin: CGPoint(), size: size) + + let contentSize = CGSize(width: contentWidth, height: size.height) + if self.scrollNode.view.contentSize != contentSize { + self.scrollNode.view.contentSize = contentSize + } + } + } + + private final class ReactionsTabNode: ASDisplayNode, UIScrollViewDelegate { + private final class ItemNode: HighlightTrackingButtonNode { + let context: AccountContext + let availableReactions: AvailableReactions? + let highlightBackgroundNode: ASDisplayNode + let avatarNode: AvatarNode + let titleLabelNode: ImmediateTextNode + let separatorNode: ASDisplayNode + var reactionIconNode: ReactionImageNode? + let action: () -> Void + + init(context: AccountContext, availableReactions: AvailableReactions?, action: @escaping () -> Void) { + self.action = action + self.context = context + self.availableReactions = availableReactions + self.avatarNode = AvatarNode(font: avatarFont) + + self.highlightBackgroundNode = ASDisplayNode() + self.highlightBackgroundNode.alpha = 0.0 + + self.titleLabelNode = ImmediateTextNode() + self.titleLabelNode.maximumNumberOfLines = 1 + self.titleLabelNode.isUserInteractionEnabled = false + + self.separatorNode = ASDisplayNode() + + super.init() + + self.addSubnode(self.separatorNode) + self.addSubnode(self.highlightBackgroundNode) + self.addSubnode(self.avatarNode) + self.addSubnode(self.titleLabelNode) + + self.highligthedChanged = { [weak self] highlighted in + guard let strongSelf = self else { + return + } + if highlighted { + strongSelf.highlightBackgroundNode.alpha = 1.0 + } else { + let previousAlpha = strongSelf.highlightBackgroundNode.alpha + strongSelf.highlightBackgroundNode.alpha = 0.0 + strongSelf.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2) + } + } + + self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) + } + + @objc private func pressed() { + self.action() + } + + func update(size: CGSize, presentationData: PresentationData, item: EngineMessageReactionListContext.Item, isLast: Bool, syncronousLoad: Bool) { + let avatarInset: CGFloat = 10.0 + let avatarSpacing: CGFloat = 8.0 + let avatarSize: CGFloat = 28.0 + + let reaction: String? = item.reaction + if let reaction = reaction { + if self.reactionIconNode == nil { + let reactionIconNode = ReactionImageNode(context: self.context, availableReactions: self.availableReactions, reaction: reaction) + self.reactionIconNode = reactionIconNode + self.addSubnode(reactionIconNode) + } + } else if let reactionIconNode = self.reactionIconNode { + reactionIconNode.removeFromSupernode() + } + + self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor + self.separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor + + self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size) + + self.avatarNode.frame = CGRect(origin: CGPoint(x: avatarInset, y: floor((size.height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) + self.avatarNode.setPeer(context: self.context, theme: presentationData.theme, peer: item.peer, synchronousLoad: true) + + let sideInset: CGFloat = 16.0 + self.titleLabelNode.attributedText = NSAttributedString(string: item.peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), font: Font.regular(17.0), textColor: presentationData.theme.contextMenu.primaryColor) + var maxTextWidth: CGFloat = size.width - avatarInset - avatarSize - avatarSpacing - sideInset + if reactionIconNode != nil { + maxTextWidth -= 32.0 + } + let titleSize = self.titleLabelNode.updateLayout(CGSize(width: maxTextWidth, height: 100.0)) + self.titleLabelNode.frame = CGRect(origin: CGPoint(x: avatarInset + avatarSize + avatarSpacing, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) + + if let reactionIconNode = self.reactionIconNode { + let reactionSize = reactionIconNode.size.aspectFitted(CGSize(width: 22.0, height: 22.0)) + reactionIconNode.frame = CGRect(origin: CGPoint(x: size.width - 32.0 - floor((32.0 - reactionSize.width) / 2.0), y: floor((size.height - reactionSize.height) / 2.0)), size: reactionSize) + } + + self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height), size: CGSize(width: size.width, height: UIScreenPixel)) + self.separatorNode.isHidden = isLast + } + } + + private let context: AccountContext + private let availableReactions: AvailableReactions? + let reaction: String? + private let requestUpdate: (ReactionsTabNode, ContainedViewLayoutTransition) -> Void + private let requestUpdateApparentHeight: (ReactionsTabNode, ContainedViewLayoutTransition) -> Void + private let openPeer: (PeerId) -> Void + + private var hasMore: Bool = false + + private let scrollNode: ASScrollNode + private var ignoreScrolling: Bool = false + + private var presentationData: PresentationData? + private var currentSize: CGSize? + private var apparentHeight: CGFloat = 0.0 + + private let listContext: EngineMessageReactionListContext + private var state: EngineMessageReactionListContext.State + private var stateDisposable: Disposable? + + private var itemNodes: [Int: ItemNode] = [:] + + init( + context: AccountContext, + availableReactions: AvailableReactions?, + message: EngineMessage, + reaction: String?, + requestUpdate: @escaping (ReactionsTabNode, ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ReactionsTabNode, ContainedViewLayoutTransition) -> Void, + openPeer: @escaping (PeerId) -> Void + ) { + self.context = context + self.availableReactions = availableReactions + self.reaction = reaction + self.requestUpdate = requestUpdate + self.requestUpdateApparentHeight = requestUpdateApparentHeight + self.openPeer = openPeer + + self.presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + self.listContext = context.engine.messages.messageReactionList(message: message, reaction: reaction) + self.state = EngineMessageReactionListContext.State(message: message, reaction: reaction) + + self.scrollNode = ASScrollNode() + self.scrollNode.canCancelAllTouchesInViews = true + self.scrollNode.view.delaysContentTouches = false + self.scrollNode.view.showsVerticalScrollIndicator = false + if #available(iOS 11.0, *) { + self.scrollNode.view.contentInsetAdjustmentBehavior = .never + } + self.scrollNode.clipsToBounds = false + + super.init() + + self.addSubnode(self.scrollNode) + self.scrollNode.view.delegate = self + + self.clipsToBounds = true + + self.stateDisposable = (self.listContext.state + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let strongSelf = self else { + return + } + var animateIn = false + if strongSelf.state.items.isEmpty && !state.items.isEmpty { + animateIn = true + } + strongSelf.state = state + strongSelf.requestUpdate(strongSelf, .immediate) + if animateIn { + strongSelf.scrollNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + }) + } + + deinit { + self.stateDisposable?.dispose() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if self.ignoreScrolling { + return + } + self.updateVisibleItems(syncronousLoad: false) + + if let size = self.currentSize { + var apparentHeight = -self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentSize.height + apparentHeight = max(apparentHeight, 44.0) + apparentHeight = min(apparentHeight, size.height + 100.0) + if self.apparentHeight != apparentHeight { + self.apparentHeight = apparentHeight + + self.requestUpdateApparentHeight(self, .immediate) + } + } + } + + private func updateVisibleItems(syncronousLoad: Bool) { + guard let size = self.currentSize else { + return + } + guard let presentationData = self.presentationData else { + return + } + let itemHeight: CGFloat = 44.0 + let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -180.0) + + var validIds = Set() + + let minVisibleIndex = max(0, Int(floor(visibleBounds.minY / itemHeight))) + let maxVisibleIndex = Int(ceil(visibleBounds.maxY / itemHeight)) + + if minVisibleIndex <= maxVisibleIndex { + for index in minVisibleIndex ... maxVisibleIndex { + if index >= self.state.items.count { + break + } + + validIds.insert(index) + + let itemNode: ItemNode + if let current = self.itemNodes[index] { + itemNode = current + } else { + let openPeer = self.openPeer + let peerId = self.state.items[index].peer.id + itemNode = ItemNode(context: self.context, availableReactions: self.availableReactions, action: { + openPeer(peerId) + }) + self.itemNodes[index] = itemNode + self.scrollNode.addSubnode(itemNode) + } + + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: CGFloat(index) * itemHeight), size: CGSize(width: size.width, height: itemHeight)) + itemNode.update(size: itemFrame.size, presentationData: presentationData, item: self.state.items[index], isLast: index == self.state.items.count - 1, syncronousLoad: syncronousLoad) + itemNode.frame = itemFrame + } + } + + var removeIds: [Int] = [] + for (id, itemNode) in self.itemNodes { + if !validIds.contains(id) { + removeIds.append(id) + itemNode.removeFromSupernode() + } + } + + for id in removeIds { + self.itemNodes.removeValue(forKey: id) + } + + if self.state.canLoadMore && maxVisibleIndex >= self.state.items.count - 16 { + self.listContext.loadMore() + } + } + + func update(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> (size: CGSize, apparentHeight: CGFloat) { + let itemHeight: CGFloat = 44.0 + let size = CGSize(width: constrainedSize.width, height: CGFloat(self.state.totalCount) * itemHeight) + + let containerSize = CGSize(width: size.width, height: min(constrainedSize.height, size.height)) + self.currentSize = containerSize + + self.ignoreScrolling = true + + if self.scrollNode.frame != CGRect(origin: CGPoint(), size: containerSize) { + self.scrollNode.frame = CGRect(origin: CGPoint(), size: containerSize) + } + if self.scrollNode.view.contentSize != size { + self.scrollNode.view.contentSize = size + } + self.ignoreScrolling = false + + self.updateVisibleItems(syncronousLoad: !transition.isAnimated) + + var apparentHeight = -self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentSize.height + apparentHeight = max(apparentHeight, 44.0) + apparentHeight = min(apparentHeight, containerSize.height + 100.0) + self.apparentHeight = apparentHeight + + return (containerSize, apparentHeight) + } + } + + final class ItemsNode: ASDisplayNode, ContextControllerItemsNode { + private let context: AccountContext + private let availableReactions: AvailableReactions? + private let reactions: [(String?, Int)] + private let requestUpdate: (ContainedViewLayoutTransition) -> Void + private let requestUpdateApparentHeight: (ContainedViewLayoutTransition) -> Void + + private var presentationData: PresentationData + + private var backButtonNode: BackButtonNode? + private var separatorNode: ASDisplayNode? + private var tabListNode: ReactionTabListNode? + private var currentTabNode: ReactionsTabNode + + private var dismissedTabNode: ReactionsTabNode? + + private let openPeer: (PeerId) -> Void + + private(set) var apparentHeight: CGFloat = 0.0 + + init( + context: AccountContext, + availableReactions: AvailableReactions?, + message: EngineMessage, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void, + back: @escaping () -> Void, + openPeer: @escaping (PeerId) -> Void + ) { + self.context = context + self.availableReactions = availableReactions + self.openPeer = openPeer + self.presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + + self.requestUpdate = requestUpdate + self.requestUpdateApparentHeight = requestUpdateApparentHeight + + var requestUpdateTab: ((ReactionsTabNode, ContainedViewLayoutTransition) -> Void)? + var requestUpdateTabApparentHeight: ((ReactionsTabNode, ContainedViewLayoutTransition) -> Void)? + + self.backButtonNode = BackButtonNode() + self.backButtonNode?.action = { + back() + } + + var reactions: [(String?, Int)] = [] + var totalCount: Int = 0 + if let reactionsAttribute = message._asMessage().reactionsAttribute { + for reaction in reactionsAttribute.reactions { + totalCount += Int(reaction.count) + reactions.append((reaction.value, Int(reaction.count))) + } + } + reactions.insert((nil, totalCount), at: 0) + + if reactions.count > 2 { + self.tabListNode = ReactionTabListNode(context: context, availableReactions: availableReactions, reactions: reactions, message: message) + } + + self.reactions = reactions + + self.separatorNode = ASDisplayNode() + + self.currentTabNode = ReactionsTabNode( + context: context, + availableReactions: availableReactions, + message: message, + reaction: nil, + requestUpdate: { tab, transition in + requestUpdateTab?(tab, transition) + }, + requestUpdateApparentHeight: { tab, transition in + requestUpdateTabApparentHeight?(tab, transition) + }, + openPeer: { id in + openPeer(id) + } + ) + + super.init() + + if let backButtonNode = self.backButtonNode { + self.addSubnode(backButtonNode) + } + if let tabListNode = self.tabListNode { + self.addSubnode(tabListNode) + } + if let separatorNode = self.separatorNode { + self.addSubnode(separatorNode) + } + self.addSubnode(self.currentTabNode) + + self.tabListNode?.action = { [weak self] reaction in + guard let strongSelf = self else { + return + } + if strongSelf.currentTabNode.reaction != reaction { + strongSelf.dismissedTabNode = strongSelf.currentTabNode + let currentTabNode = ReactionsTabNode( + context: context, + availableReactions: availableReactions, + message: message, + reaction: reaction, + requestUpdate: { tab, transition in + requestUpdateTab?(tab, transition) + }, + requestUpdateApparentHeight: { tab, transition in + requestUpdateTabApparentHeight?(tab, transition) + }, + openPeer: { id in + openPeer(id) + } + ) + strongSelf.currentTabNode = currentTabNode + strongSelf.addSubnode(currentTabNode) + strongSelf.requestUpdate(.animated(duration: 0.45, curve: .spring)) + } + } + + requestUpdateTab = { [weak self] tab, transition in + guard let strongSelf = self else { + return + } + if strongSelf.currentTabNode == tab { + strongSelf.requestUpdate(transition) + } + } + + requestUpdateTabApparentHeight = { [weak self] tab, transition in + guard let strongSelf = self else { + return + } + if strongSelf.currentTabNode == tab { + strongSelf.requestUpdateApparentHeight(transition) + } + } + } + + func update(constrainedWidth: CGFloat, maxHeight: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, apparentHeight: CGFloat) { + let constrainedSize = CGSize(width: min(260.0, constrainedWidth), height: maxHeight) + + var topContentHeight: CGFloat = 0.0 + if let backButtonNode = self.backButtonNode { + let backButtonFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: 44.0)) + backButtonNode.update(size: backButtonFrame.size, presentationData: self.presentationData, isLast: self.tabListNode == nil) + transition.updateFrame(node: backButtonNode, frame: backButtonFrame) + topContentHeight += backButtonFrame.height + } + if let tabListNode = self.tabListNode { + let tabListFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: 44.0)) + tabListNode.update(size: tabListFrame.size, presentationData: self.presentationData, selectedReaction: self.currentTabNode.reaction, transition: transition) + transition.updateFrame(node: tabListNode, frame: tabListFrame) + topContentHeight += tabListFrame.height + } + if let separatorNode = self.separatorNode { + let separatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: 7.0)) + separatorNode.backgroundColor = self.presentationData.theme.contextMenu.sectionSeparatorColor + transition.updateFrame(node: separatorNode, frame: separatorFrame) + topContentHeight += separatorFrame.height + } + + var currentTabTransition = transition + if self.currentTabNode.bounds.isEmpty { + currentTabTransition = .immediate + } + let currentTabLayout = self.currentTabNode.update(constrainedSize: CGSize(width: constrainedSize.width, height: constrainedSize.height - topContentHeight), transition: currentTabTransition) + currentTabTransition.updateFrame(node: self.currentTabNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: currentTabLayout.size.width, height: currentTabLayout.size.height + 100.0))) + + if let dismissedTabNode = self.dismissedTabNode { + self.dismissedTabNode = nil + if let previousIndex = self.reactions.firstIndex(where: { $0.0 == dismissedTabNode.reaction }), let currentIndex = self.reactions.firstIndex(where: { $0.0 == self.currentTabNode.reaction }) { + let offset = previousIndex < currentIndex ? currentTabLayout.size.width : -currentTabLayout.size.width + transition.updateFrame(node: dismissedTabNode, frame: dismissedTabNode.frame.offsetBy(dx: -offset, dy: 0.0), completion: { [weak dismissedTabNode] _ in + dismissedTabNode?.removeFromSupernode() + }) + transition.animatePositionAdditive(node: self.currentTabNode, offset: CGPoint(x: offset, y: 0.0)) + } else { + dismissedTabNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak dismissedTabNode] _ in + dismissedTabNode?.removeFromSupernode() + }) + self.currentTabNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + + let contentSize = CGSize(width: currentTabLayout.size.width, height: topContentHeight + currentTabLayout.size.height) + + let apparentHeight = topContentHeight + currentTabLayout.apparentHeight + + return (contentSize, apparentHeight) + } + } + + let context: AccountContext + let availableReactions: AvailableReactions? + let message: EngineMessage + let back: () -> Void + let openPeer: (PeerId) -> Void + + public init(context: AccountContext, availableReactions: AvailableReactions?, message: EngineMessage, back: @escaping () -> Void, openPeer: @escaping (PeerId) -> Void) { + self.context = context + self.availableReactions = availableReactions + self.message = message + self.back = back + self.openPeer = openPeer + } + + public func node( + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void + ) -> ContextControllerItemsNode { + return ItemsNode( + context: self.context, + availableReactions: self.availableReactions, + message: self.message, + requestUpdate: requestUpdate, + requestUpdateApparentHeight: requestUpdateApparentHeight, + back: self.back, + openPeer: self.openPeer + ) } } diff --git a/submodules/ContextUI/Sources/ContextActionsContainerNode.swift b/submodules/ContextUI/Sources/ContextActionsContainerNode.swift index 1a90096f49..7dcabb6bb4 100644 --- a/submodules/ContextUI/Sources/ContextActionsContainerNode.swift +++ b/submodules/ContextUI/Sources/ContextActionsContainerNode.swift @@ -41,43 +41,7 @@ private enum ContextItemNode { case separator(ASDisplayNode) } -private protocol ContextInnerActionsContainerNode: ASDisplayNode { - var panSelectionGestureEnabled: Bool { get set } - - func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, bottomInset: CGFloat, minimalWidth: CGFloat?, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, visibleSize: CGSize) - func updateTheme(presentationData: PresentationData) - func actionNode(at point: CGPoint) -> ContextActionNodeProtocol? -} - -private final class InnerCustomActionsContainerNode: ASDisplayNode, ContextInnerActionsContainerNode { - private let node: ContextControllerItemsNode - - var panSelectionGestureEnabled: Bool = false - - init(content: ContextControllerItemsContent) { - self.node = content.node() - - super.init() - - self.addSubnode(self.node) - } - - func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, bottomInset: CGFloat, minimalWidth: CGFloat?, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, visibleSize: CGSize) { - let nodeLayout = self.node.update(constrainedWidth: constrainedWidth, maxHeight: constrainedHeight, bottomInset: bottomInset, transition: transition) - transition.updateFrame(node: self.node, frame: CGRect(origin: CGPoint(), size: nodeLayout.cleanSize)) - return (nodeLayout.cleanSize, nodeLayout.visibleSize) - } - - func updateTheme(presentationData: PresentationData) { - - } - - func actionNode(at point: CGPoint) -> ContextActionNodeProtocol? { - return nil - } -} - -private final class InnerActionsContainerNode: ASDisplayNode, ContextInnerActionsContainerNode { +private final class InnerActionsContainerNode: ASDisplayNode { private let blurBackground: Bool private let presentationData: PresentationData private let containerNode: ASDisplayNode @@ -225,7 +189,7 @@ private final class InnerActionsContainerNode: ASDisplayNode, ContextInnerAction gesture.isEnabled = self.panSelectionGestureEnabled } - func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, bottomInset: CGFloat, minimalWidth: CGFloat?, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, visibleSize: CGSize) { + func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, minimalWidth: CGFloat?, transition: ContainedViewLayoutTransition) -> CGSize { var minActionsWidth: CGFloat = 250.0 if let minimalWidth = minimalWidth, minimalWidth > minActionsWidth { minActionsWidth = minimalWidth @@ -334,7 +298,7 @@ private final class InnerActionsContainerNode: ASDisplayNode, ContextInnerAction if let effectView = self.effectView { transition.updateFrame(view: effectView, frame: bounds) } - return (size, size) + return size } func updateTheme(presentationData: PresentationData) { @@ -517,12 +481,9 @@ final class ContextActionsContainerNode: ASDisplayNode { private let shadowNode: ASImageNode private let additionalShadowNode: ASImageNode? private let additionalActionsNode: InnerActionsContainerNode? - - private let contentContainerNode: ASDisplayNode - - private let actionsNode: ContextInnerActionsContainerNode + private let actionsNode: InnerActionsContainerNode private let textSelectionTipNode: InnerTextSelectionTipContainerNode? - //private let scrollNode: ASScrollNode + private let scrollNode: ASScrollNode var panSelectionGestureEnabled: Bool = true { didSet { @@ -545,11 +506,6 @@ final class ContextActionsContainerNode: ASDisplayNode { self.shadowNode.contentMode = .scaleToFill self.shadowNode.isHidden = true - self.contentContainerNode = ASDisplayNode() - self.contentContainerNode.clipsToBounds = true - self.contentContainerNode.cornerRadius = 14.0 - self.contentContainerNode.backgroundColor = presentationData.theme.contextMenu.backgroundColor - var items = items if case var .list(itemList) = items.content, let firstItem = itemList.first, case let .custom(_, additional) = firstItem, additional { let additionalShadowNode = ASImageNode() @@ -568,74 +524,71 @@ final class ContextActionsContainerNode: ASDisplayNode { self.additionalActionsNode = nil } - switch items.content { - case let .list(itemList): - self.actionsNode = InnerActionsContainerNode(presentationData: presentationData, items: itemList, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground) - if let tip = items.tip { - let textSelectionTipNode = InnerTextSelectionTipContainerNode(presentationData: presentationData, tip: tip) - textSelectionTipNode.isUserInteractionEnabled = false - self.textSelectionTipNode = textSelectionTipNode - } else { - self.textSelectionTipNode = nil - } - case let .custom(customContent): - self.actionsNode = InnerCustomActionsContainerNode(content: customContent) + var itemList: [ContextMenuItem] = [] + if case let .list(list) = items.content { + itemList = list + } + + self.actionsNode = InnerActionsContainerNode(presentationData: presentationData, items: itemList, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground) + if let tip = items.tip { + let textSelectionTipNode = InnerTextSelectionTipContainerNode(presentationData: presentationData, tip: tip) + textSelectionTipNode.isUserInteractionEnabled = false + self.textSelectionTipNode = textSelectionTipNode + } else { self.textSelectionTipNode = nil } - /*self.scrollNode = ASScrollNode() + self.scrollNode = ASScrollNode() self.scrollNode.canCancelAllTouchesInViews = true self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.showsVerticalScrollIndicator = false - self.scrollNode.clipsToBounds = false if #available(iOS 11.0, *) { self.scrollNode.view.contentInsetAdjustmentBehavior = .never - }*/ + } super.init() self.addSubnode(self.shadowNode) self.additionalShadowNode.flatMap(self.addSubnode) - self.additionalActionsNode.flatMap(self.contentContainerNode.addSubnode) - self.contentContainerNode.addSubnode(self.actionsNode) - self.textSelectionTipNode.flatMap(self.addSubnode) - self.addSubnode(self.contentContainerNode) + self.additionalActionsNode.flatMap(self.scrollNode.addSubnode) + self.scrollNode.addSubnode(self.actionsNode) + self.textSelectionTipNode.flatMap(self.scrollNode.addSubnode) + self.addSubnode(self.scrollNode) } - func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize { + func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize { var widthClass = widthClass if !self.blurBackground { widthClass = .regular } var contentSize = CGSize() - let actionsLayout = self.actionsNode.updateLayout(widthClass: widthClass, constrainedWidth: constrainedWidth, constrainedHeight: constrainedHeight, bottomInset: bottomInset, minimalWidth: nil, transition: transition) + let actionsSize = self.actionsNode.updateLayout(widthClass: widthClass, constrainedWidth: constrainedWidth, constrainedHeight: constrainedHeight, minimalWidth: nil, transition: transition) if let additionalActionsNode = self.additionalActionsNode, let additionalShadowNode = self.additionalShadowNode { - let additionalActionsLayout = additionalActionsNode.updateLayout(widthClass: widthClass, constrainedWidth: actionsLayout.cleanSize.width, constrainedHeight: constrainedHeight, bottomInset: 0.0, minimalWidth: actionsLayout.cleanSize.width, transition: transition) - contentSize = additionalActionsLayout.cleanSize + let additionalActionsSize = additionalActionsNode.updateLayout(widthClass: widthClass, constrainedWidth: actionsSize.width, constrainedHeight: constrainedHeight, minimalWidth: actionsSize.width, transition: transition) + contentSize = additionalActionsSize - let bounds = CGRect(origin: CGPoint(), size: additionalActionsLayout.cleanSize) + let bounds = CGRect(origin: CGPoint(), size: additionalActionsSize) transition.updateFrame(node: additionalShadowNode, frame: bounds.insetBy(dx: -30.0, dy: -30.0)) additionalShadowNode.isHidden = widthClass == .compact - transition.updateFrame(node: additionalActionsNode, frame: CGRect(origin: CGPoint(), size: additionalActionsLayout.cleanSize)) + transition.updateFrame(node: additionalActionsNode, frame: CGRect(origin: CGPoint(), size: additionalActionsSize)) contentSize.height += 8.0 } - let bounds = CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: actionsLayout.visibleSize) + let bounds = CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: actionsSize) transition.updateFrame(node: self.shadowNode, frame: bounds.insetBy(dx: -30.0, dy: -30.0)) self.shadowNode.isHidden = widthClass == .compact - contentSize.width = max(contentSize.width, actionsLayout.cleanSize.width) - contentSize.height += actionsLayout.cleanSize.height + contentSize.width = max(contentSize.width, actionsSize.width) + contentSize.height += actionsSize.height transition.updateFrame(node: self.actionsNode, frame: bounds) - transition.updateFrame(node: self.contentContainerNode, frame: bounds) if let textSelectionTipNode = self.textSelectionTipNode { contentSize.height += 8.0 - let textSelectionTipSize = textSelectionTipNode.updateLayout(widthClass: widthClass, width: actionsLayout.cleanSize.width, transition: transition) + let textSelectionTipSize = textSelectionTipNode.updateLayout(widthClass: widthClass, width: actionsSize.width, transition: transition) transition.updateFrame(node: textSelectionTipNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: textSelectionTipSize)) contentSize.height += textSelectionTipSize.height } @@ -644,8 +597,8 @@ final class ContextActionsContainerNode: ASDisplayNode { } func updateSize(containerSize: CGSize, contentSize: CGSize) { - //self.scrollNode.view.contentSize = contentSize - //self.scrollNode.frame = CGRect(origin: CGPoint(), size: containerSize) + self.scrollNode.view.contentSize = contentSize + self.scrollNode.frame = CGRect(origin: CGPoint(), size: containerSize) } func actionNode(at point: CGPoint) -> ContextActionNodeProtocol? { diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index d7908a2483..8a9a1ab98a 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -11,13 +11,15 @@ import AccountContext private let animationDurationFactor: Double = 1.0 -public protocol ContextControllerProtocol { +public protocol ContextControllerProtocol: AnyObject { var useComplexItemsTransitionAnimation: Bool { get set } var immediateItemsTransitionAnimation: Bool { get set } func getActionsMinHeight() -> ContextController.ActionsHeight? func setItems(_ items: Signal, minHeight: ContextController.ActionsHeight?) func setItems(_ items: Signal, minHeight: ContextController.ActionsHeight?, previousActionsTransition: ContextController.PreviousActionsTransition) + func pushItems(items: Signal) + func popItems() func dismiss(completion: (() -> Void)?) } @@ -158,7 +160,7 @@ public enum ContextMenuItem { case separator } -private func convertFrame(_ frame: CGRect, from fromView: UIView, to toView: UIView) -> CGRect { +func convertFrame(_ frame: CGRect, from fromView: UIView, to toView: UIView) -> CGRect { let sourceWindowFrame = fromView.convert(frame, to: nil) var targetWindowFrame = toView.convert(sourceWindowFrame, from: nil) @@ -196,6 +198,9 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi private let dismissNode: ASDisplayNode private let dismissAccessibilityArea: AccessibilityAreaNode + private var presentationNode: ContextControllerPresentationNode? + private var currentPresentationStateTransition: ContextControllerPresentationNodeStateTransition? + private let clippingNode: ASDisplayNode private let scrollNode: ASScrollNode @@ -466,11 +471,6 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } } - self.itemsDisposable.set((items - |> deliverOnMainQueue).start(next: { [weak self] items in - self?.setItems(items: items, minHeight: nil, previousActionsTransition: .scale) - })) - switch source { case .reference, .extracted: self.contentReady.set(.single(true)) @@ -480,6 +480,11 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.initializeContent() + self.itemsDisposable.set((items + |> deliverOnMainQueue).start(next: { [weak self] items in + self?.setItems(items: items, minHeight: nil, previousActionsTransition: .scale) + })) + self.dismissAccessibilityArea.activate = { [weak self] in self?.dimNodeTapped() return true @@ -523,7 +528,40 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.originalProjectedContentViewFrame = (projectedFrame, projectedFrame) } case let .extracted(source): - let takenViewInfo = source.takeView() + let presentationNode = ContextControllerExtractedPresentationNode( + getController: { [weak self] in + return self?.getController() + }, + requestUpdate: { [weak self] transition in + guard let strongSelf = self else { + return + } + if let validLayout = strongSelf.validLayout { + strongSelf.updateLayout( + layout: validLayout, + transition: transition, + previousActionsContainerNode: nil + ) + } + }, + requestDismiss: { [weak self] result in + guard let strongSelf = self else { + return + } + strongSelf.dismissedForCancel?() + strongSelf.beginDismiss(result) + }, + requestAnimateOut: { [weak self] result, completion in + guard let strongSelf = self else { + return + } + strongSelf.animateOut(result: result, completion: completion) + }, + source: source + ) + self.presentationNode = presentationNode + self.addSubnode(presentationNode) + /*let takenViewInfo = source.takeView() if let takenViewInfo = takenViewInfo, let parentSupernode = takenViewInfo.contentContainingNode.supernode { self.contentContainerNode.contentNode = .extracted(node: takenViewInfo.contentContainingNode, keepInPlace: source.keepInPlace) @@ -553,7 +591,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi takenViewInfo.contentContainingNode.isExtractedToContextPreviewUpdated?(true) self.originalProjectedContentViewFrame = (convertFrame(takenViewInfo.contentContainingNode.frame, from: parentSupernode.view, to: self.view), convertFrame(takenViewInfo.contentContainingNode.contentRect, from: takenViewInfo.contentContainingNode.view, to: self.view)) - } + }*/ case let .controller(source): let transitionInfo = source.transitionInfo() if let transitionInfo = transitionInfo, let (sourceNode, sourceNodeRect) = transitionInfo.sourceNode() { @@ -574,12 +612,24 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi func animateIn() { self.gesture?.endPressedAppearance() - self.hapticFeedback.impact() + if let _ = self.presentationNode { + self.didCompleteAnimationIn = true + self.currentPresentationStateTransition = .animateIn + if let validLayout = self.validLayout { + self.updateLayout( + layout: validLayout, + transition: .animated(duration: 0.5, curve: .spring), + previousActionsContainerNode: nil + ) + } + return + } + switch self.source { - case .reference: - break + case .reference: + break case .extracted: if let contentAreaInScreenSpace = self.contentAreaInScreenSpace, let maybeContentNode = self.contentContainerNode.contentNode, case .extracted = maybeContentNode { var updatedContentAreaInScreenSpace = contentAreaInScreenSpace @@ -759,6 +809,18 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.beganAnimatingOut() + if let _ = self.presentationNode { + self.currentPresentationStateTransition = .animateOut(result: initialResult, completion: completion) + if let validLayout = self.validLayout { + self.updateLayout( + layout: validLayout, + transition: .animated(duration: 0.25, curve: .easeInOut), + previousActionsContainerNode: nil + ) + } + return + } + var transitionDuration: Double = 0.2 var transitionCurve: ContainedViewLayoutTransitionCurve = .easeInOut @@ -1152,6 +1214,11 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } func animateOutToReaction(value: String, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) { + if let presentationNode = self.presentationNode { + presentationNode.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, completion: completion) + return + } + guard let reactionContextNode = self.reactionContextNode else { self.animateOut(result: .default, completion: completion) return @@ -1207,6 +1274,16 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } private func setItems(items: ContextController.Items, minHeight: ContextController.ActionsHeight?, previousActionsTransition: ContextController.PreviousActionsTransition) { + if let presentationNode = self.presentationNode { + presentationNode.replaceItems(items: items, animated: self.didCompleteAnimationIn) + + if !self.didSetItemsReady { + self.didSetItemsReady = true + self.itemsReady.set(.single(true)) + } + return + } + if let _ = self.currentItems, !self.didCompleteAnimationIn && self.getController()?.immediateItemsTransitionAnimation == true { return } @@ -1248,7 +1325,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.scrollNode.insertSubnode(self.actionsContainerNode, aboveSubnode: previousActionsContainerNode) if let layout = self.validLayout { - self.updateLayout(layout: layout, transition: .animated(duration: 0.3, curve: .spring), previousActionsContainerNode: previousActionsContainerNode, previousActionsContainerFrame: previousActionsContainerFrame, previousActionsTransition: previousActionsTransition) + self.updateLayout(layout: layout, transition: self.didSetItemsReady ? .animated(duration: 0.3, curve: .spring) : .immediate, previousActionsContainerNode: previousActionsContainerNode, previousActionsContainerFrame: previousActionsContainerFrame, previousActionsTransition: previousActionsTransition) } else { previousActionsContainerNode.removeFromSupernode() } @@ -1259,6 +1336,22 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi } } + func pushItems(items: Signal) { + self.itemsDisposable.set((items + |> deliverOnMainQueue).start(next: { [weak self] items in + guard let strongSelf = self, let presentationNode = strongSelf.presentationNode else { + return + } + presentationNode.pushItems(items: items) + })) + } + + func popItems() { + if let presentationNode = self.presentationNode { + presentationNode.popItems() + } + } + func updateTheme(presentationData: PresentationData) { self.presentationData = presentationData @@ -1283,6 +1376,20 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.validLayout = layout + let presentationStateTransition = self.currentPresentationStateTransition + self.currentPresentationStateTransition = .none + + if let presentationNode = self.presentationNode { + transition.updateFrame(node: presentationNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + presentationNode.update( + presentationData: self.presentationData, + layout: layout, + transition: transition, + stateTransition: presentationStateTransition + ) + return + } + var actionsContainerTransition = transition if previousActionsContainerNode != nil { actionsContainerTransition = .immediate @@ -1340,7 +1447,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi let isInitialLayout = self.actionsContainerNode.frame.size.width.isZero let previousContainerFrame = self.view.convert(self.contentContainerNode.frame, from: self.scrollNode.view) - let realActionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: layout.size.width - actionsSideInset * 2.0, constrainedHeight: layout.size.height, bottomInset: 0.0, transition: actionsContainerTransition) + let realActionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: layout.size.width - actionsSideInset * 2.0, constrainedHeight: layout.size.height, transition: actionsContainerTransition) let adjustedActionsSize = realActionsSize self.actionsContainerNode.updateSize(containerSize: realActionsSize, contentSize: realActionsSize) @@ -1437,7 +1544,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi constrainedActionsBottomInset = 0.0 } - let realActionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: layout.size.width - actionsSideInset * 2.0, constrainedHeight: constrainedActionsHeight, bottomInset: constrainedActionsBottomInset, transition: actionsContainerTransition) + let realActionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: layout.size.width - actionsSideInset * 2.0, constrainedHeight: constrainedActionsHeight, transition: actionsContainerTransition) let adjustedActionsSize = realActionsSize self.actionsContainerNode.updateSize(containerSize: realActionsSize, contentSize: realActionsSize) @@ -1602,7 +1709,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi constrainedWidth = floor(layout.size.width / 2.0) } - let actionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: constrainedWidth - actionsSideInset * 2.0, constrainedHeight: layout.size.height, bottomInset: 0.0, transition: actionsContainerTransition) + let actionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: constrainedWidth - actionsSideInset * 2.0, constrainedHeight: layout.size.height, transition: actionsContainerTransition) let contentScale = (constrainedWidth - actionsSideInset * 2.0) / constrainedWidth var contentUnscaledSize: CGSize if case .compact = layout.metrics.widthClass { @@ -1800,6 +1907,10 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi return nil } + if let presentationNode = self.presentationNode { + return presentationNode.hitTest(self.view.convert(point, to: presentationNode.view), with: event) + } + if let reactionContextNode = self.reactionContextNode { if let result = reactionContextNode.hitTest(self.view.convert(point, to: reactionContextNode.view), with: event) { return result @@ -1965,11 +2076,16 @@ public enum ContextContentSource { } public protocol ContextControllerItemsNode: ASDisplayNode { - func update(constrainedWidth: CGFloat, maxHeight: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, visibleSize: CGSize) + func update(constrainedWidth: CGFloat, maxHeight: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, apparentHeight: CGFloat) + + var apparentHeight: CGFloat { get } } public protocol ContextControllerItemsContent: AnyObject { - func node() -> ContextControllerItemsNode + func node( + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void + ) -> ContextControllerItemsNode } public final class ContextController: ViewController, StandalonePresentableController, ContextControllerProtocol { @@ -2182,6 +2298,20 @@ public final class ContextController: ViewController, StandalonePresentableContr } } + public func pushItems(items: Signal) { + if !self.isNodeLoaded { + return + } + self.controllerNode.pushItems(items: items) + } + + public func popItems() { + if !self.isNodeLoaded { + return + } + self.controllerNode.popItems() + } + public func updateTheme(presentationData: PresentationData) { self.presentationData = presentationData if self.isNodeLoaded { diff --git a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift new file mode 100644 index 0000000000..2cc4332b79 --- /dev/null +++ b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift @@ -0,0 +1,758 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramPresentationData +import TextSelectionNode +import TelegramCore +import SwiftSignalKit +import AccountContext +import ReactionSelectionNode + +public protocol ContextControllerActionsStackItemNode: ASDisplayNode { + func update( + presentationData: PresentationData, + constrainedSize: CGSize, + standardWidth: CGFloat, + transition: ContainedViewLayoutTransition + ) -> (size: CGSize, apparentHeight: CGFloat) +} + +public protocol ContextControllerActionsStackItem: AnyObject { + func node( + getController: @escaping () -> ContextControllerProtocol?, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void + ) -> ContextControllerActionsStackItemNode + + var reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? { get } +} + +protocol ContextControllerActionsListItemNode: ASDisplayNode { + func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) +} + +private final class ContextControllerActionsListActionItemNode: HighlightTrackingButtonNode, ContextControllerActionsListItemNode { + private let getController: () -> ContextControllerProtocol? + private let requestDismiss: (ContextMenuActionResult) -> Void + private let requestUpdateAction: (AnyHashable, ContextMenuActionItem) -> Void + private let item: ContextMenuActionItem + + private let highlightBackgroundNode: ASDisplayNode + private let titleLabelNode: ImmediateTextNode + private let subtitleNode: ImmediateTextNode + private let iconNode: ASImageNode + + init( + getController: @escaping () -> ContextControllerProtocol?, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestUpdateAction: @escaping (AnyHashable, ContextMenuActionItem) -> Void, + item: ContextMenuActionItem + ) { + self.getController = getController + self.requestDismiss = requestDismiss + self.requestUpdateAction = requestUpdateAction + self.item = item + + self.highlightBackgroundNode = ASDisplayNode() + self.highlightBackgroundNode.isUserInteractionEnabled = false + self.highlightBackgroundNode.alpha = 0.0 + + self.titleLabelNode = ImmediateTextNode() + self.titleLabelNode.displaysAsynchronously = false + self.titleLabelNode.isUserInteractionEnabled = false + + self.subtitleNode = ImmediateTextNode() + self.subtitleNode.displaysAsynchronously = false + self.subtitleNode.isUserInteractionEnabled = false + + self.iconNode = ASImageNode() + self.iconNode.isUserInteractionEnabled = false + + super.init() + + self.addSubnode(self.highlightBackgroundNode) + self.addSubnode(self.titleLabelNode) + self.addSubnode(self.subtitleNode) + self.addSubnode(self.iconNode) + + self.highligthedChanged = { [weak self] highlighted in + guard let strongSelf = self else { + return + } + if highlighted { + strongSelf.highlightBackgroundNode.alpha = 1.0 + } else { + let previousAlpha = strongSelf.highlightBackgroundNode.alpha + strongSelf.highlightBackgroundNode.alpha = 0.0 + strongSelf.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2) + } + } + + self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) + } + + @objc private func pressed() { + guard let controller = self.getController() else { + return + } + + self.item.action?(ContextMenuActionItem.Action( + controller: controller, + dismissWithResult: { [weak self] result in + guard let strongSelf = self else { + return + } + strongSelf.requestDismiss(result) + }, + updateAction: { [weak self] id, updatedAction in + guard let strongSelf = self else { + return + } + strongSelf.requestUpdateAction(id, updatedAction) + } + )) + } + + func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) { + let sideInset: CGFloat = 16.0 + let verticalInset: CGFloat = 11.0 + let titleSubtitleSpacing: CGFloat = 1.0 + let iconSideInset: CGFloat = 12.0 + let standardIconWidth: CGFloat = 32.0 + + self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor + + var subtitle: String? + switch self.item.textLayout { + case .singleLine: + self.titleLabelNode.maximumNumberOfLines = 1 + case .twoLinesMax: + self.titleLabelNode.maximumNumberOfLines = 2 + case let .secondLineWithValue(subtitleValue): + self.titleLabelNode.maximumNumberOfLines = 1 + subtitle = subtitleValue + } + + let titleFont: UIFont + switch self.item.textFont { + case let .custom(font): + titleFont = font + case .regular: + titleFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) + } + + let subtitleFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0) + let subtitleColor = presentationData.theme.contextMenu.secondaryColor + + let titleColor: UIColor + switch self.item.textColor { + case .primary: + titleColor = presentationData.theme.contextMenu.primaryColor + case .destructive: + titleColor = presentationData.theme.contextMenu.destructiveColor + case .disabled: + titleColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.4) + } + + self.titleLabelNode.attributedText = NSAttributedString( + string: self.item.text, + font: titleFont, + textColor: titleColor + ) + + self.subtitleNode.attributedText = subtitle.flatMap { subtitle in + return NSAttributedString( + string: self.item.text, + font: subtitleFont, + textColor: subtitleColor + ) + } + + let iconImage = self.iconNode.image ?? self.item.icon(presentationData.theme) + + var maxTextWidth: CGFloat = constrainedSize.width + maxTextWidth -= sideInset + if let iconImage = iconImage { + maxTextWidth -= max(standardIconWidth, iconImage.size.width) + } else { + maxTextWidth -= sideInset + } + maxTextWidth = max(1.0, maxTextWidth) + + let titleSize = self.titleLabelNode.updateLayout(CGSize(width: maxTextWidth, height: 1000.0)) + let subtitleSize = self.subtitleNode.updateLayout(CGSize(width: maxTextWidth, height: 1000.0)) + + var minSize = CGSize() + minSize.width += sideInset + minSize.width += max(titleSize.width, subtitleSize.width) + if let iconImage = iconImage { + minSize.width += max(standardIconWidth, iconImage.size.width) + minSize.width += iconSideInset + } else { + minSize.width += sideInset + } + minSize.height += verticalInset * 2.0 + minSize.height += titleSize.height + if subtitle != nil { + minSize.height += titleSubtitleSpacing + minSize.height += subtitleSize.height + } + + return (minSize: minSize, apply: { size, transition in + let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset), size: titleSize) + let subtitleFrame = CGRect(origin: CGPoint(x: sideInset, y: titleFrame.maxY + titleSubtitleSpacing), size: subtitleSize) + + transition.updateFrame(node: self.highlightBackgroundNode, frame: CGRect(origin: CGPoint(), size: size)) + transition.updateFrameAdditive(node: self.titleLabelNode, frame: titleFrame) + transition.updateFrameAdditive(node: self.subtitleNode, frame: subtitleFrame) + + if let iconImage = iconImage { + if self.iconNode.image !== iconImage { + self.iconNode.image = iconImage + } + let iconWidth = max(standardIconWidth, iconImage.size.width) + let iconFrame = CGRect(origin: CGPoint(x: size.width - iconSideInset - iconWidth + floor((iconWidth - iconImage.size.width) / 2.0), y: floor((size.height - iconImage.size.height) / 2.0)), size: iconImage.size) + transition.updateFrame(node: self.iconNode, frame: iconFrame) + } + }) + } +} + +private final class ContextControllerActionsListSeparatorItemNode: ASDisplayNode, ContextControllerActionsListItemNode { + override init() { + super.init() + } + + func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) { + return (minSize: CGSize(width: 0.0, height: 7.0), apply: { _, _ in + self.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor + }) + } +} + +private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, ContextControllerActionsListItemNode { + private let getController: () -> ContextControllerProtocol? + private let item: ContextMenuCustomItem + + private var presentationData: PresentationData? + private var itemNode: ContextMenuCustomNode? + + init( + getController: @escaping () -> ContextControllerProtocol?, + item: ContextMenuCustomItem + ) { + self.getController = getController + self.item = item + + super.init() + } + + func update(presentationData: PresentationData, constrainedSize: CGSize) -> (minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void) { + if self.presentationData?.theme !== presentationData.theme { + if let itemNode = self.itemNode { + itemNode.updateTheme(presentationData: presentationData) + } + } + self.presentationData = presentationData + + let itemNode: ContextMenuCustomNode + if let current = self.itemNode { + itemNode = current + } else { + itemNode = self.item.node( + presentationData: presentationData, + getController: self.getController, + actionSelected: { result in + let _ = result + } + ) + self.itemNode = itemNode + self.addSubnode(itemNode) + } + + let itemLayoutAndApply = itemNode.updateLayout(constrainedWidth: constrainedSize.width, constrainedHeight: constrainedSize.height) + + return (minSize: itemLayoutAndApply.0, apply: { size, transition in + transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(), size: size)) + itemLayoutAndApply.1(size, transition) + }) + } +} + +final class ContextControllerActionsListStackItem: ContextControllerActionsStackItem { + private final class Node: ASDisplayNode, ContextControllerActionsStackItemNode { + private final class Item { + let node: ContextControllerActionsListItemNode + let separatorNode: ASDisplayNode? + + init(node: ContextControllerActionsListItemNode, separatorNode: ASDisplayNode?) { + self.node = node + self.separatorNode = separatorNode + } + } + + private let requestUpdate: (ContainedViewLayoutTransition) -> Void + private var items: [ContextMenuItem] + private var itemNodes: [Item] + + init( + getController: @escaping () -> ContextControllerProtocol?, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + items: [ContextMenuItem] + ) { + self.requestUpdate = requestUpdate + self.items = items + + var requestUpdateAction: ((AnyHashable, ContextMenuActionItem) -> Void)? + self.itemNodes = items.map { item -> Item in + switch item { + case let .action(actionItem): + return Item( + node: ContextControllerActionsListActionItemNode( + getController: getController, + requestDismiss: requestDismiss, + requestUpdateAction: { id, action in + requestUpdateAction?(id, action) + }, + item: actionItem + ), + separatorNode: ASDisplayNode() + ) + case .separator: + return Item( + node: ContextControllerActionsListSeparatorItemNode(), + separatorNode: nil + ) + case let .custom(customItem, _): + return Item( + node: ContextControllerActionsListCustomItemNode( + getController: getController, + item: customItem + ), + separatorNode: ASDisplayNode() + ) + } + } + + super.init() + + for item in self.itemNodes { + if let separatorNode = item.separatorNode { + self.addSubnode(separatorNode) + } + } + for item in self.itemNodes { + self.addSubnode(item.node) + } + + requestUpdateAction = { [weak self] id, action in + guard let strongSelf = self else { + return + } + loop: for i in 0 ..< strongSelf.items.count { + switch strongSelf.items[i] { + case let .action(currentAction): + if currentAction.id == id { + let previousNode = strongSelf.itemNodes[i] + previousNode.node.removeFromSupernode() + previousNode.separatorNode?.removeFromSupernode() + + let addedNode = Item( + node: ContextControllerActionsListActionItemNode( + getController: getController, + requestDismiss: requestDismiss, + requestUpdateAction: { id, action in + requestUpdateAction?(id, action) + }, + item: action + ), + separatorNode: ASDisplayNode() + ) + strongSelf.itemNodes[i] = addedNode + if let separatorNode = addedNode.separatorNode { + strongSelf.insertSubnode(separatorNode, at: 0) + } + strongSelf.addSubnode(addedNode.node) + + strongSelf.requestUpdate(.immediate) + + break loop + } + default: + break + } + } + } + } + + func update( + presentationData: PresentationData, + constrainedSize: CGSize, + standardWidth: CGFloat, + transition: ContainedViewLayoutTransition + ) -> (size: CGSize, apparentHeight: CGFloat) { + var itemNodeLayouts: [(minSize: CGSize, apply: (_ size: CGSize, _ transition: ContainedViewLayoutTransition) -> Void)] = [] + var combinedSize = CGSize() + for item in self.itemNodes { + item.separatorNode?.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor + + let itemNodeLayout = item.node.update( + presentationData: presentationData, + constrainedSize: constrainedSize + ) + itemNodeLayouts.append(itemNodeLayout) + combinedSize.width = max(combinedSize.width, itemNodeLayout.minSize.width) + combinedSize.height += itemNodeLayout.minSize.height + } + combinedSize.width = max(combinedSize.width, standardWidth) + + var nextItemOrigin = CGPoint() + for i in 0 ..< self.itemNodes.count { + let item = self.itemNodes[i] + let itemNodeLayout = itemNodeLayouts[i] + + var itemTransition = transition + if item.node.frame.isEmpty { + itemTransition = .immediate + } + + let itemSize = CGSize(width: combinedSize.width, height: itemNodeLayout.minSize.height) + let itemFrame = CGRect(origin: nextItemOrigin, size: itemSize) + itemTransition.updateFrame(node: item.node, frame: itemFrame) + + if let separatorNode = item.separatorNode { + itemTransition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: itemFrame.minX, y: itemFrame.maxY), size: CGSize(width: itemFrame.width, height: UIScreenPixel))) + if i != self.itemNodes.count - 1 { + switch self.items[i + 1] { + case .separator: + separatorNode.isHidden = true + case .action: + separatorNode.isHidden = false + case .custom: + separatorNode.isHidden = false + } + } else { + separatorNode.isHidden = true + } + } + + itemNodeLayout.apply(itemSize, itemTransition) + nextItemOrigin.y += itemSize.height + } + + return (combinedSize, combinedSize.height) + } + } + + private let items: [ContextMenuItem] + let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? + + init( + items: [ContextMenuItem], + reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? + ) { + self.items = items + self.reactionItems = reactionItems + } + + func node( + getController: @escaping () -> ContextControllerProtocol?, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void + ) -> ContextControllerActionsStackItemNode { + return Node( + getController: getController, + requestDismiss: requestDismiss, + requestUpdate: requestUpdate, + items: self.items + ) + } +} + +final class ContextControllerActionsCustomStackItem: ContextControllerActionsStackItem { + private final class Node: ASDisplayNode, ContextControllerActionsStackItemNode { + private let requestUpdate: (ContainedViewLayoutTransition) -> Void + private let contentNode: ContextControllerItemsNode + + init( + content: ContextControllerItemsContent, + getController: @escaping () -> ContextControllerProtocol?, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void + ) { + self.requestUpdate = requestUpdate + self.contentNode = content.node(requestUpdate: { transition in + requestUpdate(transition) + }, requestUpdateApparentHeight: { transition in + requestUpdateApparentHeight(transition) + }) + + super.init() + + self.addSubnode(self.contentNode) + } + + func update( + presentationData: PresentationData, + constrainedSize: CGSize, + standardWidth: CGFloat, + transition: ContainedViewLayoutTransition + ) -> (size: CGSize, apparentHeight: CGFloat) { + let contentLayout = self.contentNode.update( + constrainedWidth: constrainedSize.width, + maxHeight: constrainedSize.height, + bottomInset: 0.0, + transition: transition + ) + transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(), size: contentLayout.cleanSize)) + + return (contentLayout.cleanSize, contentLayout.apparentHeight) + } + } + + private let content: ContextControllerItemsContent + let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? + + init( + content: ContextControllerItemsContent, + reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? + ) { + self.content = content + self.reactionItems = reactionItems + } + + func node( + getController: @escaping () -> ContextControllerProtocol?, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void + ) -> ContextControllerActionsStackItemNode { + return Node( + content: self.content, + getController: getController, + requestUpdate: requestUpdate, + requestUpdateApparentHeight: requestUpdateApparentHeight + ) + } +} + +func makeContextControllerActionsStackItem(items: ContextController.Items) -> ContextControllerActionsStackItem { + var reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? + if let context = items.context, !items.reactionItems.isEmpty { + reactionItems = (context, items.reactionItems) + } + switch items.content { + case let .list(listItems): + return ContextControllerActionsListStackItem(items: listItems, reactionItems: reactionItems) + case let .custom(customContent): + return ContextControllerActionsCustomStackItem(content: customContent, reactionItems: reactionItems) + } +} + +final class ContextControllerActionsStackNode: ASDisplayNode { + final class NavigationContainer: ASDisplayNode { + override init() { + super.init() + + self.clipsToBounds = true + self.cornerRadius = 14.0 + } + } + + final class ItemContainer: ASDisplayNode { + let requestUpdate: (ContainedViewLayoutTransition) -> Void + let node: ContextControllerActionsStackItemNode + let reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? + let positionLock: CGFloat? + + init( + getController: @escaping () -> ContextControllerProtocol?, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void, + item: ContextControllerActionsStackItem, + reactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])?, + positionLock: CGFloat? + ) { + self.requestUpdate = requestUpdate + self.node = item.node( + getController: getController, + requestDismiss: requestDismiss, + requestUpdate: requestUpdate, + requestUpdateApparentHeight: requestUpdateApparentHeight + ) + + self.reactionItems = reactionItems + self.positionLock = positionLock + + super.init() + + self.addSubnode(self.node) + } + + func update( + presentationData: PresentationData, + constrainedSize: CGSize, + standardWidth: CGFloat, + transition: ContainedViewLayoutTransition + ) -> (size: CGSize, apparentHeight: CGFloat) { + let (size, apparentHeight) = self.node.update( + presentationData: presentationData, + constrainedSize: constrainedSize, + standardWidth: standardWidth, + transition: transition + ) + transition.updateFrame(node: self.node, frame: CGRect(origin: CGPoint(), size: size)) + + return (size, apparentHeight) + } + } + + private let getController: () -> ContextControllerProtocol? + private let requestDismiss: (ContextMenuActionResult) -> Void + private let requestUpdate: (ContainedViewLayoutTransition) -> Void + + private let navigationContainer: NavigationContainer + private var itemContainers: [ItemContainer] = [] + private var dismissingItemContainers: [(container: ItemContainer, isPopped: Bool)] = [] + + var topReactionItems: (context: AccountContext, reactionItems: [ReactionContextItem])? { + return self.itemContainers.last?.reactionItems + } + + var topPositionLock: CGFloat? { + return self.itemContainers.last?.positionLock + } + + init( + getController: @escaping () -> ContextControllerProtocol?, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void + ) { + self.getController = getController + self.requestDismiss = requestDismiss + self.requestUpdate = requestUpdate + + self.navigationContainer = NavigationContainer() + + super.init() + + self.addSubnode(self.navigationContainer) + } + + func replace(item: ContextControllerActionsStackItem, animated: Bool) { + for itemContainer in self.itemContainers { + if animated { + self.dismissingItemContainers.append((itemContainer, false)) + } else { + itemContainer.removeFromSupernode() + } + } + self.itemContainers.removeAll() + + self.push(item: item, positionLock: nil, animated: animated) + } + + func push(item: ContextControllerActionsStackItem, positionLock: CGFloat?, animated: Bool) { + let itemContainer = ItemContainer( + getController: self.getController, + requestDismiss: self.requestDismiss, + requestUpdate: self.requestUpdate, + requestUpdateApparentHeight: { [weak self] transition in + guard let strongSelf = self else { + return + } + strongSelf.requestUpdate(transition) + }, + item: item, + reactionItems: item.reactionItems, + positionLock: positionLock + ) + self.itemContainers.append(itemContainer) + self.navigationContainer.addSubnode(itemContainer) + + let transition: ContainedViewLayoutTransition + if animated { + transition = .animated(duration: 0.45, curve: .spring) + } else { + transition = .immediate + } + self.requestUpdate(transition) + } + + func pop() { + if self.itemContainers.count == 1 { + //dismiss + } else { + let itemContainer = self.itemContainers[self.itemContainers.count - 1] + self.itemContainers.remove(at: self.itemContainers.count - 1) + self.dismissingItemContainers.append((itemContainer, true)) + } + + let transition: ContainedViewLayoutTransition = .animated(duration: 0.45, curve: .spring) + self.requestUpdate(transition) + } + + func update( + presentationData: PresentationData, + constrainedSize: CGSize, + transition: ContainedViewLayoutTransition + ) -> CGSize { + self.navigationContainer.backgroundColor = presentationData.theme.contextMenu.backgroundColor + + let animateAppearingContainers = transition.isAnimated && !self.dismissingItemContainers.isEmpty + + var topItemSize = CGSize() + var topItemApparentHeight: CGFloat = 0.0 + for i in 0 ..< self.itemContainers.count { + let itemContainer = self.itemContainers[i] + + var animateAppearingContainer = false + var itemContainerTransition = transition + if itemContainer.bounds.isEmpty { + itemContainerTransition = .immediate + animateAppearingContainer = i == self.itemContainers.count - 1 && animateAppearingContainers || self.itemContainers.count > 1 + } + + let itemConstrainedHeight: CGFloat = constrainedSize.height + + let itemSize = itemContainer.update( + presentationData: presentationData, + constrainedSize: CGSize(width: constrainedSize.width, height: itemConstrainedHeight), + standardWidth: 260.0, + transition: itemContainerTransition + ) + if i == self.itemContainers.count - 1 { + topItemSize = itemSize.size + topItemApparentHeight = itemSize.apparentHeight + } + + let itemFrame: CGRect + if i == self.itemContainers.count - 1 { + itemFrame = CGRect(origin: CGPoint(), size: itemSize.size) + } else { + itemFrame = CGRect(origin: CGPoint(x: -itemSize.size.width, y: 0.0), size: itemSize.size) + } + + itemContainerTransition.updateFrame(node: itemContainer, frame: itemFrame) + if animateAppearingContainer { + transition.animatePositionAdditive(node: itemContainer, offset: CGPoint(x: itemContainer.bounds.width, y: 0.0)) + } + } + + transition.updateFrame(node: self.navigationContainer, frame: CGRect(origin: CGPoint(), size: CGSize(width: topItemSize.width, height: max(44.0, topItemApparentHeight)))) + + for (itemContainer, isPopped) in self.dismissingItemContainers { + transition.updatePosition(node: itemContainer, position: CGPoint(x: isPopped ? itemContainer.bounds.width * 3.0 / 2.0 : -itemContainer.bounds.width / 2.0, y: itemContainer.position.y), completion: { [weak itemContainer] _ in + itemContainer?.removeFromSupernode() + }) + } + self.dismissingItemContainers.removeAll() + + return CGSize(width: topItemSize.width, height: topItemSize.height) + } +} diff --git a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift index 177810dce9..65dd811954 100644 --- a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift @@ -6,7 +6,568 @@ import TelegramPresentationData import TextSelectionNode import TelegramCore import SwiftSignalKit +import ReactionSelectionNode -final class ContextControllerExtractedPresentationNode: ASDisplayNode { +final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextControllerPresentationNode { + private final class ContentNode: ASDisplayNode { + let offsetContainerNode: ASDisplayNode + let containingNode: ContextExtractedContentContainingNode + + var animateClippingFromContentAreaInScreenSpace: CGRect? + var storedGlobalFrame: CGRect? + + init(containingNode: ContextExtractedContentContainingNode) { + self.offsetContainerNode = ASDisplayNode() + self.containingNode = containingNode + + super.init() + + self.addSubnode(self.offsetContainerNode) + self.offsetContainerNode.addSubnode(self.containingNode.contentNode) + } + + func update(presentationData: PresentationData, size: CGSize, transition: ContainedViewLayoutTransition) { + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.bounds.contains(point) { + return nil + } + if !self.containingNode.contentRect.contains(point) { + return nil + } + return self.view + } + } -} \ No newline at end of file + private final class AnimatingOutState { + var currentContentScreenFrame: CGRect + + init( + currentContentScreenFrame: CGRect + ) { + self.currentContentScreenFrame = currentContentScreenFrame + } + } + + private let getController: () -> ContextControllerProtocol? + private let requestUpdate: (ContainedViewLayoutTransition) -> Void + private let requestDismiss: (ContextMenuActionResult) -> Void + private let requestAnimateOut: (ContextMenuActionResult, @escaping () -> Void) -> Void + private let source: ContextExtractedContentSource + + private let backgroundNode: NavigationBackgroundNode + private let dismissTapNode: ASDisplayNode + private let clippingNode: ASDisplayNode + private let scrollNode: ASScrollNode + + private var reactionContextNode: ReactionContextNode? + private var reactionContextNodeIsAnimatingOut: Bool = false + + private var contentNode: ContentNode? + private let contentRectDebugNode: ASDisplayNode + private let actionsStackNode: ContextControllerActionsStackNode + + private var animatingOutState: AnimatingOutState? + + init( + getController: @escaping () -> ContextControllerProtocol?, + requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, + requestDismiss: @escaping (ContextMenuActionResult) -> Void, + requestAnimateOut: @escaping (ContextMenuActionResult, @escaping () -> Void) -> Void, + source: ContextExtractedContentSource + ) { + self.getController = getController + self.requestUpdate = requestUpdate + self.requestDismiss = requestDismiss + self.requestAnimateOut = requestAnimateOut + self.source = source + + self.backgroundNode = NavigationBackgroundNode(color: .clear, enableBlur: false) + + self.dismissTapNode = ASDisplayNode() + self.clippingNode = ASDisplayNode() + self.clippingNode.clipsToBounds = true + + self.scrollNode = ASScrollNode() + self.scrollNode.canCancelAllTouchesInViews = true + self.scrollNode.view.delaysContentTouches = false + self.scrollNode.view.showsVerticalScrollIndicator = false + if #available(iOS 11.0, *) { + self.scrollNode.view.contentInsetAdjustmentBehavior = .never + } + + self.contentRectDebugNode = ASDisplayNode() + self.contentRectDebugNode.isUserInteractionEnabled = false + self.contentRectDebugNode.backgroundColor = UIColor.red.withAlphaComponent(0.2) + + self.actionsStackNode = ContextControllerActionsStackNode( + getController: getController, + requestDismiss: { result in + requestDismiss(result) + }, + requestUpdate: requestUpdate + ) + + super.init() + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.clippingNode) + self.clippingNode.addSubnode(self.scrollNode) + self.scrollNode.addSubnode(self.dismissTapNode) + self.scrollNode.addSubnode(self.actionsStackNode) + + /*#if DEBUG + self.scrollNode.addSubnode(self.contentRectDebugNode) + #endif*/ + + self.dismissTapNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dismissTapGesture(_:)))) + } + + @objc func dismissTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.requestDismiss(.default) + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.bounds.contains(point) { + if let reactionContextNode = self.reactionContextNode { + if let result = reactionContextNode.hitTest(self.view.convert(point, to: reactionContextNode.view), with: event) { + return result + } + } + return self.scrollNode.hitTest(self.view.convert(point, to: self.scrollNode.view), with: event) + } else { + return nil + } + } + + func replaceItems(items: ContextController.Items, animated: Bool) { + self.actionsStackNode.replace(item: makeContextControllerActionsStackItem(items: items), animated: animated) + } + + func pushItems(items: ContextController.Items) { + let positionLock = self.getActionsStackPositionLock() + self.actionsStackNode.push(item: makeContextControllerActionsStackItem(items: items), positionLock: positionLock, animated: true) + } + + func popItems() { + self.actionsStackNode.pop() + } + + private func getActionsStackPositionLock() -> CGFloat? { + return self.actionsStackNode.frame.minY + } + + func update( + presentationData: PresentationData, + layout: ContainerViewLayout, + transition: ContainedViewLayoutTransition, + stateTransition: ContextControllerPresentationNodeStateTransition? + ) { + let contentActionsSpacing: CGFloat = 8.0 + let actionsSideInset: CGFloat = 6.0 + let topInset: CGFloat = layout.insets(options: .statusBar).top + 8.0 + let bottomInset: CGFloat = 10.0 + + let contentNode: ContentNode + var contentTransition = transition + + self.backgroundNode.updateColor( + color: presentationData.theme.contextMenu.dimColor, + enableBlur: true, + forceKeepBlur: true, + transition: .immediate + ) + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + self.backgroundNode.update(size: layout.size, transition: transition) + + transition.updateFrame(node: self.clippingNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + if self.scrollNode.frame != CGRect(origin: CGPoint(), size: layout.size) { + transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + } + + if let current = self.contentNode { + contentNode = current + } else { + guard let takeInfo = self.source.takeView() else { + return + } + contentNode = ContentNode(containingNode: takeInfo.contentContainingNode) + contentNode.animateClippingFromContentAreaInScreenSpace = takeInfo.contentAreaInScreenSpace + self.scrollNode.insertSubnode(contentNode, aboveSubnode: self.actionsStackNode) + self.contentNode = contentNode + contentTransition = .immediate + } + + var animateReactionsIn = false + var contentTopInset: CGFloat = topInset + var removedReactionContextNode: ReactionContextNode? + if let reactionItems = self.actionsStackNode.topReactionItems, !reactionItems.reactionItems.isEmpty { + if self.reactionContextNode == nil { + let reactionContextNode = ReactionContextNode(context: reactionItems.context, theme: presentationData.theme, items: reactionItems.reactionItems) + self.reactionContextNode = reactionContextNode + self.addSubnode(reactionContextNode) + + if transition.isAnimated { + animateReactionsIn = true + } + + reactionContextNode.reactionSelected = { [weak self] reaction in + guard let strongSelf = self, let controller = strongSelf.getController() as? ContextController else { + return + } + controller.reactionSelected?(reaction) + } + } + contentTopInset += 70.0 + } else if let reactionContextNode = self.reactionContextNode { + self.reactionContextNode = nil + removedReactionContextNode = reactionContextNode + } + + switch stateTransition { + case .animateIn, .animateOut: + contentNode.storedGlobalFrame = convertFrame(contentNode.containingNode.contentRect, from: contentNode.containingNode.view, to: self.view) + case .none: + if contentNode.storedGlobalFrame == nil { + contentNode.storedGlobalFrame = convertFrame(contentNode.containingNode.contentRect, from: contentNode.containingNode.view, to: self.view) + } + } + //let contentRectGlobalFrame = contentNode.storedGlobalFrame ?? convertFrame(contentNode.containingNode.contentRect, from: contentNode.containingNode.view, to: self.view) + let contentRectGlobalFrame = CGRect(origin: CGPoint(x: contentNode.containingNode.contentRect.minX, y: (contentNode.storedGlobalFrame?.maxY ?? 0.0) - contentNode.containingNode.contentRect.height), size: contentNode.containingNode.contentRect.size) + var contentRect = CGRect(origin: CGPoint(x: contentRectGlobalFrame.minX, y: contentRectGlobalFrame.maxY - contentNode.containingNode.contentRect.size.height), size: contentNode.containingNode.contentRect.size) + if case .animateOut = stateTransition { + contentRect.origin.y = self.contentRectDebugNode.frame.maxY - contentRect.size.height + } + + var defaultScrollY: CGFloat = 0.0 + if self.animatingOutState == nil { + contentNode.update( + presentationData: presentationData, + size: contentNode.containingNode.bounds.size, + transition: contentTransition + ) + + let actionsConstrainedHeight: CGFloat + if let actionsPositionLock = self.actionsStackNode.topPositionLock { + actionsConstrainedHeight = layout.size.height - bottomInset - layout.intrinsicInsets.bottom - actionsPositionLock + } else { + actionsConstrainedHeight = layout.size.height + } + + let actionsSize = self.actionsStackNode.update( + presentationData: presentationData, + constrainedSize: CGSize(width: layout.size.width, height: actionsConstrainedHeight), + transition: transition + ) + + if case .animateOut = stateTransition { + } else { + if contentRect.minY < contentTopInset { + contentRect.origin.y = contentTopInset + } + var combinedBounds = CGRect(origin: CGPoint(x: 0.0, y: contentRect.minY), size: CGSize(width: layout.size.width, height: contentRect.height + contentActionsSpacing + actionsSize.height)) + if combinedBounds.maxY > layout.size.height - bottomInset - layout.intrinsicInsets.bottom { + combinedBounds.origin.y = layout.size.height - bottomInset - layout.intrinsicInsets.bottom - combinedBounds.height + } + if combinedBounds.minY < contentTopInset { + combinedBounds.origin.y = contentTopInset + } + + contentRect.origin.y = combinedBounds.minY + } + + if let reactionContextNode = self.reactionContextNode { + var reactionContextNodeTransition = transition + if reactionContextNode.frame.isEmpty { + reactionContextNodeTransition = .immediate + } + reactionContextNodeTransition.updateFrame(node: reactionContextNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + reactionContextNode.updateLayout(size: layout.size, insets: UIEdgeInsets(top: topInset, left: 0.0, bottom: 0.0, right: 0.0), anchorRect: contentRect, transition: reactionContextNodeTransition) + } + if let removedReactionContextNode = removedReactionContextNode { + removedReactionContextNode.animateOut(to: contentRect, animatingOutToReaction: false) + transition.updateAlpha(node: removedReactionContextNode, alpha: 0.0, completion: { [weak removedReactionContextNode] _ in + removedReactionContextNode?.removeFromSupernode() + }) + } + + transition.updateFrame(node: self.contentRectDebugNode, frame: contentRect) + + var actionsFrame = CGRect(origin: CGPoint(x: 0.0, y: contentRect.maxY + contentActionsSpacing), size: actionsSize) + if contentRect.midX < layout.size.width / 2.0 { + actionsFrame.origin.x = contentRect.minX + actionsSideInset - 3.0 + } else { + actionsFrame.origin.x = contentRect.maxX - actionsSideInset - actionsSize.width + } + transition.updateFrame(node: self.actionsStackNode, frame: actionsFrame) + + contentTransition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: contentRect.minX - contentNode.containingNode.contentRect.minX, y: contentRect.minY - contentNode.containingNode.contentRect.minY), size: contentNode.containingNode.bounds.size)) + + let contentHeight = actionsFrame.maxY + bottomInset + layout.intrinsicInsets.bottom + let contentSize = CGSize(width: layout.size.width, height: contentHeight) + + if self.scrollNode.view.contentSize != contentSize { + let previousContentOffset = self.scrollNode.view.contentOffset + self.scrollNode.view.contentSize = contentSize + if case .none = stateTransition, transition.isAnimated { + let contentOffset = self.scrollNode.view.contentOffset + transition.animateOffsetAdditive(layer: self.scrollNode.layer, offset: previousContentOffset.y - contentOffset.y) + } + } + + defaultScrollY = contentSize.height - layout.size.height + if defaultScrollY < 0.0 { + defaultScrollY = 0.0 + } + + self.dismissTapNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: contentSize.width, height: max(contentSize.height, layout.size.height))) + } + + switch stateTransition { + case .animateIn: + let duration: Double = 0.42 + let springDamping: CGFloat = 104.0 + + self.scrollNode.view.contentOffset = CGPoint(x: 0.0, y: defaultScrollY) + + self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + + if let animateClippingFromContentAreaInScreenSpace = contentNode.animateClippingFromContentAreaInScreenSpace { + self.clippingNode.layer.animateFrame(from: animateClippingFromContentAreaInScreenSpace, to: CGRect(origin: CGPoint(), size: layout.size), duration: 0.2) + self.clippingNode.layer.animateBoundsOriginYAdditive(from: animateClippingFromContentAreaInScreenSpace.minY, to: 0.0, duration: 0.2) + } + + let currentContentScreenFrame = convertFrame(contentNode.containingNode.contentRect, from: contentNode.containingNode.view, to: self.view) + let currentContentLocalFrame = convertFrame(contentRect, from: self.scrollNode.view, to: self.view) + let animationInContentDistance = currentContentLocalFrame.maxY - currentContentScreenFrame.maxY + + contentNode.layer.animateSpring( + from: -animationInContentDistance as NSNumber, to: 0.0 as NSNumber, + keyPath: "position.y", + duration: duration, + delay: 0.0, + initialVelocity: 0.0, + damping: springDamping, + additive: true + ) + + self.actionsStackNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.05) + self.actionsStackNode.layer.animateSpring( + from: 0.01 as NSNumber, + to: 1.0 as NSNumber, + keyPath: "transform.scale", + duration: duration, + delay: 0.0, + initialVelocity: 0.0, + damping: springDamping, + additive: false + ) + + let actionsSize = self.actionsStackNode.bounds.size + + let actionsPositionDeltaXDistance: CGFloat = 0.0 + let actionsPositionDeltaYDistance = -animationInContentDistance - actionsSize.height / 2.0 - contentActionsSpacing + self.actionsStackNode.layer.animateSpring( + from: NSValue(cgPoint: CGPoint(x: actionsPositionDeltaXDistance, y: actionsPositionDeltaYDistance)), + to: NSValue(cgPoint: CGPoint()), + keyPath: "position", + duration: duration, + delay: 0.0, + initialVelocity: 0.0, + damping: springDamping, + additive: true + ) + + if let reactionContextNode = self.reactionContextNode { + reactionContextNode.animateIn(from: currentContentScreenFrame) + } + + contentNode.containingNode.isExtractedToContextPreview = true + contentNode.containingNode.isExtractedToContextPreviewUpdated?(true) + contentNode.containingNode.willUpdateIsExtractedToContextPreview?(true, transition) + + contentNode.containingNode.layoutUpdated = { [weak self] _, animation in + guard let strongSelf = self, let _ = strongSelf.contentNode else { + return + } + + if let _ = strongSelf.animatingOutState { + /*let updatedContentScreenFrame = convertFrame(contentNode.containingNode.contentRect, from: contentNode.containingNode.view, to: strongSelf.view) + if animatingOutState.currentContentScreenFrame != updatedContentScreenFrame { + let offset = CGPoint( + x: updatedContentScreenFrame.minX - animatingOutState.currentContentScreenFrame.minX, + y: updatedContentScreenFrame.minY - animatingOutState.currentContentScreenFrame.minY + ) + let _ = offset + + //animation.animator.updatePosition(layer: contentNode.layer, position: contentNode.position.offsetBy(dx: offset.x, dy: offset.y), completion: nil) + + animatingOutState.currentContentScreenFrame = updatedContentScreenFrame + }*/ + } else { + //strongSelf.requestUpdate(animation.transition) + + /*let updatedContentScreenFrame = convertFrame(contentNode.containingNode.contentRect, from: contentNode.containingNode.view, to: strongSelf.view) + if let storedGlobalFrame = contentNode.storedGlobalFrame { + let offset = CGPoint( + x: updatedContentScreenFrame.minX - storedGlobalFrame.minX, + y: updatedContentScreenFrame.maxY - storedGlobalFrame.maxY + ) + + if !offset.x.isZero || !offset.y.isZero { + //print("contentNode.frame = \(contentNode.frame)") + //animation.animator.updateBounds(layer: contentNode.layer, bounds: contentNode.layer.bounds.offsetBy(dx: -offset.x, dy: -offset.y), completion: nil) + } + + //animatingOutState.currentContentScreenFrame = updatedContentScreenFrame + }*/ + } + } + + /* + public var updateAbsoluteRect: ((CGRect, CGSize) -> Void)? + public var applyAbsoluteOffset: ((CGPoint, ContainedViewLayoutTransitionCurve, Double) -> Void)? + public var applyAbsoluteOffsetSpring: ((CGFloat, Double, CGFloat) -> Void)? + public var layoutUpdated: ((CGSize) -> Void)? + public var updateDistractionFreeMode: ((Bool) -> Void)? + public var requestDismiss: (() -> Void)*/ + case let .animateOut(result, completion): + let duration: Double = 0.25 + + let putBackInfo = self.source.putBack() + + if let putBackInfo = putBackInfo { + self.clippingNode.layer.animateFrame(from: CGRect(origin: CGPoint(), size: layout.size), to: putBackInfo.contentAreaInScreenSpace, duration: duration, removeOnCompletion: false) + self.clippingNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: putBackInfo.contentAreaInScreenSpace.minY, duration: duration, removeOnCompletion: false) + } + + let currentContentScreenFrame = convertFrame(contentNode.containingNode.contentRect, from: contentNode.containingNode.view, to: self.view) + + self.animatingOutState = AnimatingOutState( + currentContentScreenFrame: currentContentScreenFrame + ) + + let currentContentLocalFrame = convertFrame(contentRect, from: self.scrollNode.view, to: self.view) + + let animationInContentDistance: CGFloat + + switch result { + case .default, .custom: + animationInContentDistance = currentContentLocalFrame.minY - currentContentScreenFrame.minY + case .dismissWithoutContent: + animationInContentDistance = 0.0 + contentNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false) + } + + print("animationInContentDistance: \(animationInContentDistance)") + + contentNode.containingNode.willUpdateIsExtractedToContextPreview?(false, transition) + + contentNode.offsetContainerNode.position = contentNode.offsetContainerNode.position.offsetBy(dx: 0.0, dy: -animationInContentDistance) + contentNode.offsetContainerNode.layer.animate( + from: animationInContentDistance as NSNumber, + to: 0.0 as NSNumber, + keyPath: "position.y", + timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, + duration: duration, + delay: 0.0, + additive: true, + completion: { [weak self] _ in + Queue.mainQueue().after(0.2 * UIView.animationDurationFactor(), { + contentNode.containingNode.isExtractedToContextPreview = false + contentNode.containingNode.isExtractedToContextPreviewUpdated?(false) + + if let strongSelf = self, let contentNode = strongSelf.contentNode { + contentNode.containingNode.addSubnode(contentNode.containingNode.contentNode) + } + + completion() + }) + } + ) + /*Queue.mainQueue().after((duration + 0.2) * UIView.animationDurationFactor(), { [weak self] in + contentNode.containingNode.isExtractedToContextPreview = false + contentNode.containingNode.isExtractedToContextPreviewUpdated?(false) + + if let strongSelf = self, let contentNode = strongSelf.contentNode { + contentNode.containingNode.addSubnode(contentNode.containingNode.contentNode) + } + + completion() + })*/ + + self.actionsStackNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, removeOnCompletion: false) + self.actionsStackNode.layer.animate( + from: 1.0 as NSNumber, + to: 0.01 as NSNumber, + keyPath: "transform.scale", + timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, + duration: duration, + delay: 0.0, + removeOnCompletion: false + ) + + let actionsSize = self.actionsStackNode.bounds.size + + let actionsPositionDeltaXDistance: CGFloat = 0.0 + let actionsPositionDeltaYDistance = -animationInContentDistance - actionsSize.height / 2.0 - contentActionsSpacing + self.actionsStackNode.layer.animate( + from: NSValue(cgPoint: CGPoint()), + to: NSValue(cgPoint: CGPoint(x: actionsPositionDeltaXDistance, y: actionsPositionDeltaYDistance)), + keyPath: "position", + timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, + duration: duration, + delay: 0.0, + removeOnCompletion: false, + additive: true + ) + + self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, removeOnCompletion: false) + + if let reactionContextNode = self.reactionContextNode { + reactionContextNode.animateOut(to: currentContentScreenFrame, animatingOutToReaction: self.reactionContextNodeIsAnimatingOut) + } + case .none: + if animateReactionsIn, let reactionContextNode = self.reactionContextNode { + reactionContextNode.animateIn(from: contentRect) + } + } + } + + func animateOutToReaction(value: String, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) { + guard let reactionContextNode = self.reactionContextNode else { + self.requestAnimateOut(.default, completion) + return + } + + var contentCompleted = false + var reactionCompleted = false + let intermediateCompletion: () -> Void = { + if contentCompleted && reactionCompleted { + completion() + } + } + + self.reactionContextNodeIsAnimatingOut = true + reactionContextNode.willAnimateOutToReaction(value: value) + + self.requestAnimateOut(.default, { + contentCompleted = true + intermediateCompletion() + }) + + reactionContextNode.animateOutToReaction(value: value, targetView: targetView, hideNode: hideNode, completion: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.reactionContextNode?.removeFromSupernode() + strongSelf.reactionContextNode = nil + reactionCompleted = true + intermediateCompletion() + }) + } +} diff --git a/submodules/ContextUI/Sources/ContextControllerPresentationNode.swift b/submodules/ContextUI/Sources/ContextControllerPresentationNode.swift new file mode 100644 index 0000000000..a05fef8cb9 --- /dev/null +++ b/submodules/ContextUI/Sources/ContextControllerPresentationNode.swift @@ -0,0 +1,28 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramPresentationData +import TextSelectionNode +import TelegramCore +import SwiftSignalKit + +enum ContextControllerPresentationNodeStateTransition { + case animateIn + case animateOut(result: ContextMenuActionResult, completion: () -> Void) +} + +protocol ContextControllerPresentationNode: ASDisplayNode { + func replaceItems(items: ContextController.Items, animated: Bool) + func pushItems(items: ContextController.Items) + func popItems() + + func update( + presentationData: PresentationData, + layout: ContainerViewLayout, + transition: ContainedViewLayoutTransition, + stateTransition: ContextControllerPresentationNodeStateTransition? + ) + + func animateOutToReaction(value: String, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) +} diff --git a/submodules/ContextUI/Sources/PeekController.swift b/submodules/ContextUI/Sources/PeekController.swift index 9976d5bff9..312e8340b8 100644 --- a/submodules/ContextUI/Sources/PeekController.swift +++ b/submodules/ContextUI/Sources/PeekController.swift @@ -44,6 +44,12 @@ public final class PeekController: ViewController, ContextControllerProtocol { public func setItems(_ items: Signal, minHeight: ContextController.ActionsHeight?, previousActionsTransition: ContextController.PreviousActionsTransition) { } + public func pushItems(items: Signal) { + } + + public func popItems() { + } + private var controllerNode: PeekControllerNode { return self.displayNode as! PeekControllerNode } diff --git a/submodules/ContextUI/Sources/PeekControllerNode.swift b/submodules/ContextUI/Sources/PeekControllerNode.swift index f9b79671e2..e3b2c67031 100644 --- a/submodules/ContextUI/Sources/PeekControllerNode.swift +++ b/submodules/ContextUI/Sources/PeekControllerNode.swift @@ -158,7 +158,7 @@ final class PeekControllerNode: ViewControllerTracingNode { } let actionsSideInset: CGFloat = layout.safeInsets.left + 11.0 - let actionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: layout.size.width - actionsSideInset * 2.0, constrainedHeight: layout.size.height, bottomInset: 0.0, transition: .immediate) + let actionsSize = self.actionsContainerNode.updateLayout(widthClass: layout.metrics.widthClass, constrainedWidth: layout.size.width - actionsSideInset * 2.0, constrainedHeight: layout.size.height, transition: .immediate) let containerFrame: CGRect let actionsFrame: CGRect diff --git a/submodules/ContextUI/Sources/PinchController.swift b/submodules/ContextUI/Sources/PinchController.swift index e7db5ab007..41610a3a71 100644 --- a/submodules/ContextUI/Sources/PinchController.swift +++ b/submodules/ContextUI/Sources/PinchController.swift @@ -7,16 +7,6 @@ import TextSelectionNode import TelegramCore import SwiftSignalKit -private func convertFrame(_ frame: CGRect, from fromView: UIView, to toView: UIView) -> CGRect { - let sourceWindowFrame = fromView.convert(frame, to: nil) - var targetWindowFrame = toView.convert(sourceWindowFrame, from: nil) - - if let fromWindow = fromView.window, let toWindow = toView.window { - targetWindowFrame.origin.x += toWindow.bounds.width - fromWindow.bounds.width - } - return targetWindowFrame -} - final class PinchSourceGesture: UIPinchGestureRecognizer { private final class Target { var updated: (() -> Void)? diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index 8163f87fde..0dddbade98 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -1693,7 +1693,7 @@ public final class ControlledTransition { self.curve = curve } - func merge(with other: NativeAnimator) { + func merge(with other: NativeAnimator, forceRestart: Bool) { var removeAnimationIndices: [Int] = [] for i in 0 ..< self.animations.count { let animation = self.animations[i] @@ -1703,7 +1703,7 @@ public final class ControlledTransition { let otherAnimation = other.animations[j] if animation.layer === otherAnimation.layer && animation.path == otherAnimation.path { - if animation.toValue == otherAnimation.toValue { + if animation.toValue == otherAnimation.toValue && !forceRestart { removeAnimationIndices.append(i) } else { removeOtherAnimationIndices.append(j) @@ -1932,9 +1932,9 @@ public final class ControlledTransition { } } - public func merge(with other: ControlledTransition) { + public func merge(with other: ControlledTransition, forceRestart: Bool) { if let animator = self.animator as? NativeAnimator, let otherAnimator = other.animator as? NativeAnimator { - animator.merge(with: otherAnimator) + animator.merge(with: otherAnimator, forceRestart: forceRestart) } } } diff --git a/submodules/Display/Source/ContextContentSourceNode.swift b/submodules/Display/Source/ContextContentSourceNode.swift index 5eb8d82223..157573ada3 100644 --- a/submodules/Display/Source/ContextContentSourceNode.swift +++ b/submodules/Display/Source/ContextContentSourceNode.swift @@ -13,7 +13,7 @@ public final class ContextExtractedContentContainingNode: ASDisplayNode { public var updateAbsoluteRect: ((CGRect, CGSize) -> Void)? public var applyAbsoluteOffset: ((CGPoint, ContainedViewLayoutTransitionCurve, Double) -> Void)? public var applyAbsoluteOffsetSpring: ((CGFloat, Double, CGFloat) -> Void)? - public var layoutUpdated: ((CGSize) -> Void)? + public var layoutUpdated: ((CGSize, ListViewItemUpdateAnimation) -> Void)? public var updateDistractionFreeMode: ((Bool) -> Void)? public var requestDismiss: (() -> Void)? diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 303016ee23..04c7783c3a 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -2665,6 +2665,12 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture if !abs(updatedApparentHeight - previousApparentHeight).isZero { let currentAnimation = node.animationForKey("apparentHeight") if let currentAnimation = currentAnimation, let toFloat = currentAnimation.to as? CGFloat, toFloat.isEqual(to: updatedApparentHeight) { + /*node.addApparentHeightAnimation(updatedApparentHeight, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp, update: { [weak node] progress, currentValue in + if let node = node { + node.animateFrameTransition(progress, currentValue) + } + }) + node.addTransitionOffsetAnimation(0.0, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp)*/ } else { node.apparentHeight = previousApparentHeight node.animateFrameTransition(0.0, previousApparentHeight) @@ -2733,7 +2739,7 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture } for itemNode in self.itemNodes { - itemNode.beginPendingControlledTransitions(beginAt: timestamp) + itemNode.beginPendingControlledTransitions(beginAt: timestamp, forceRestart: false) } if hadInserts, let reorderNode = self.reorderNode, reorderNode.supernode != nil { diff --git a/submodules/Display/Source/ListViewItemNode.swift b/submodules/Display/Source/ListViewItemNode.swift index 9bc586ca2c..cdb0cc2c80 100644 --- a/submodules/Display/Source/ListViewItemNode.swift +++ b/submodules/Display/Source/ListViewItemNode.swift @@ -481,16 +481,16 @@ open class ListViewItemNode: ASDisplayNode, AccessibilityFocusableNode { self.pendingControlledTransitions.append(transition) } - func beginPendingControlledTransitions(beginAt: Double) { + func beginPendingControlledTransitions(beginAt: Double, forceRestart: Bool) { for transition in self.pendingControlledTransitions { - self.addControlledTransition(transition: transition, beginAt: beginAt) + self.addControlledTransition(transition: transition, beginAt: beginAt, forceRestart: forceRestart) } self.pendingControlledTransitions.removeAll() } - func addControlledTransition(transition: ControlledTransition, beginAt: Double) { + func addControlledTransition(transition: ControlledTransition, beginAt: Double, forceRestart: Bool) { for controlledTransition in self.controlledTransitions { - transition.merge(with: controlledTransition.transition) + transition.merge(with: controlledTransition.transition, forceRestart: forceRestart) } self.controlledTransitions.append(ControlledTransitionContext(transition: transition, beginAt: beginAt)) } diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index b5171707b8..1bcd8f363c 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -523,7 +523,7 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { let expandedFrame = CGRect(origin: CGPoint(x: floor(selfTargetRect.midX - expandedSize.width / 2.0), y: floor(selfTargetRect.midY - expandedSize.height / 2.0)), size: expandedSize) - let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .linear) + let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .linear) self.addSubnode(itemNode) itemNode.frame = selfSourceRect @@ -562,7 +562,7 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { additionalAnimationNode.visibility = true }) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0, execute: { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + min(5.0, 2.0 * UIView.animationDurationFactor()), execute: { self.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: hideNode, completion: { mainAnimationCompleted = true intermediateCompletion() diff --git a/submodules/TelegramCore/Sources/State/MessageReactions.swift b/submodules/TelegramCore/Sources/State/MessageReactions.swift index 81c5c47e0a..37bf1907ee 100644 --- a/submodules/TelegramCore/Sources/State/MessageReactions.swift +++ b/submodules/TelegramCore/Sources/State/MessageReactions.swift @@ -4,7 +4,6 @@ import SwiftSignalKit import TelegramApi import MtProtoKit - public func updateMessageReactionsInteractively(account: Account, messageId: MessageId, reaction: String?) -> Signal { return account.postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .updateReaction, id: messageId, action: UpdateMessageReactionsAction()) @@ -59,7 +58,7 @@ private func requestUpdateMessageReaction(postbox: Postbox, network: Network, st if messageId.namespace != Namespaces.Message.Cloud { return .fail(.generic) } - return network.request(Api.functions.messages.sendReaction(flags: value == nil ? 0 : 1, peer: inputPeer, msgId: messageId.id, reaction: value)) + let signal: Signal = network.request(Api.functions.messages.sendReaction(flags: value == nil ? 0 : 1, peer: inputPeer, msgId: messageId.id, reaction: value)) |> mapError { _ -> RequestUpdateMessageReactionError in return .generic } @@ -88,6 +87,11 @@ private func requestUpdateMessageReaction(postbox: Postbox, network: Network, st |> castError(RequestUpdateMessageReactionError.self) |> ignoreValues } + #if DEBUG + return signal |> delay(0.1, queue: .mainQueue()) + #else + return signal + #endif } } @@ -225,3 +229,234 @@ private func synchronizeMessageReactions(transaction: Transaction, postbox: Post } } +public extension EngineMessageReactionListContext.State { + init(message: EngineMessage, reaction: String?) { + var totalCount: Int = 0 + if let reactionsAttribute = message._asMessage().reactionsAttribute { + for messageReaction in reactionsAttribute.reactions { + if reaction == nil || messageReaction.value == reaction { + totalCount += Int(messageReaction.count) + } + } + } + self.init( + totalCount: totalCount, + items: [], + canLoadMore: true + ) + } +} + +public final class EngineMessageReactionListContext { + public final class Item: Equatable { + public let peer: EnginePeer + public let reaction: String + + init( + peer: EnginePeer, + reaction: String + ) { + self.peer = peer + self.reaction = reaction + } + + public static func ==(lhs: Item, rhs: Item) -> Bool { + if lhs.peer != rhs.peer { + return false + } + if lhs.reaction != rhs.reaction { + return false + } + return true + } + } + + public struct State: Equatable { + public var totalCount: Int + public var items: [Item] + public var canLoadMore: Bool + + public init( + totalCount: Int, + items: [Item], + canLoadMore: Bool + ) { + self.totalCount = totalCount + self.items = items + self.canLoadMore = canLoadMore + } + } + + private final class Impl { + struct InternalState: Equatable { + var totalCount: Int + var items: [Item] + var canLoadMore: Bool + var nextOffset: String? + } + + let queue: Queue + + let account: Account + let message: EngineMessage + let reaction: String? + + let disposable = MetaDisposable() + + var state: InternalState + let statePromise = Promise() + + var isLoadingMore: Bool = false + + init(queue: Queue, account: Account, message: EngineMessage, reaction: String?) { + self.queue = queue + self.account = account + self.message = message + self.reaction = reaction + + let initialState = EngineMessageReactionListContext.State(message: message, reaction: reaction) + self.state = InternalState(totalCount: initialState.totalCount, items: initialState.items, canLoadMore: true, nextOffset: nil) + + self.loadMore() + } + + deinit { + assert(self.queue.isCurrent()) + + self.disposable.dispose() + } + + func loadMore() { + if self.isLoadingMore { + return + } + self.isLoadingMore = true + + let account = self.account + let message = self.message + let reaction = self.reaction + let currentOffset = self.state.nextOffset + let limit = self.state.items.isEmpty ? 50 : 100 + let signal: Signal = self.account.postbox.transaction { transaction -> Api.InputPeer? in + return transaction.getPeer(message.id.peerId).flatMap(apiInputPeer) + } + |> mapToSignal { inputPeer -> Signal in + if message.id.namespace != Namespaces.Message.Cloud { + return .single(InternalState(totalCount: 0, items: [], canLoadMore: false, nextOffset: nil)) + } + guard let inputPeer = inputPeer else { + return .single(InternalState(totalCount: 0, items: [], canLoadMore: false, nextOffset: nil)) + } + var flags: Int32 = 0 + if reaction != nil { + flags |= 1 << 0 + } + if currentOffset != nil { + flags |= 1 << 1 + } + return account.network.request(Api.functions.messages.getMessageReactionsList(flags: flags, peer: inputPeer, id: message.id.id, reaction: reaction, offset: currentOffset, limit: Int32(limit))) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { result -> Signal in + return account.postbox.transaction { transaction -> InternalState in + switch result { + case let .messageReactionsList(_, count, reactions, users, nextOffset): + var peers: [Peer] = [] + var peerPresences: [PeerId: PeerPresence] = [:] + + for user in users { + let telegramUser = TelegramUser(user: user) + peers.append(telegramUser) + if let presence = TelegramUserPresence(apiUser: user) { + peerPresences[telegramUser.id] = presence + } + } + + updatePeers(transaction: transaction, peers: peers, update: { _, updated -> Peer in + return updated + }) + updatePeerPresences(transaction: transaction, accountPeerId: account.peerId, peerPresences: peerPresences) + + var items: [EngineMessageReactionListContext.Item] = [] + for reaction in reactions { + switch reaction { + case let .messageUserReaction(userId, reaction): + if let peer = transaction.getPeer(PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId))) { + items.append(EngineMessageReactionListContext.Item(peer: EnginePeer(peer), reaction: reaction)) + } + } + } + + return InternalState(totalCount: Int(count), items: items, canLoadMore: nextOffset != nil, nextOffset: nextOffset) + case .none: + return InternalState(totalCount: 0, items: [], canLoadMore: false, nextOffset: nil) + } + } + } + } + self.disposable.set((signal + |> deliverOn(self.queue)).start(next: { [weak self] state in + guard let strongSelf = self else { + return + } + var existingPeerIds = Set() + for item in strongSelf.state.items { + existingPeerIds.insert(item.peer.id) + } + + for item in state.items { + if existingPeerIds.contains(item.peer.id) { + continue + } + existingPeerIds.insert(item.peer.id) + strongSelf.state.items.append(item) + } + if state.canLoadMore { + strongSelf.state.totalCount = max(state.totalCount, strongSelf.state.items.count) + } else { + strongSelf.state.totalCount = strongSelf.state.items.count + } + strongSelf.state.canLoadMore = state.canLoadMore + strongSelf.state.nextOffset = state.nextOffset + + strongSelf.isLoadingMore = false + strongSelf.statePromise.set(.single(strongSelf.state)) + })) + } + } + + private let queue: Queue + private let impl: QueueLocalObject + + public var state: Signal { + return Signal { subscriber in + let disposable = MetaDisposable() + self.impl.with { impl in + disposable.set(impl.statePromise.get().start(next: { state in + subscriber.putNext(State( + totalCount: state.totalCount, + items: state.items, + canLoadMore: state.canLoadMore + )) + })) + } + return disposable + } + } + + init(account: Account, message: EngineMessage, reaction: String?) { + let queue = Queue() + self.queue = queue + self.impl = QueueLocalObject(queue: queue, generate: { + return Impl(queue: queue, account: account, message: message, reaction: reaction) + }) + } + + public func loadMore() { + self.impl.with { impl in + impl.loadMore() + } + } +} diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUser.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUser.swift index 06bd57ad56..766e3b9385 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUser.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramUser.swift @@ -205,6 +205,9 @@ public final class TelegramUser: Peer, Equatable { } public static func ==(lhs: TelegramUser, rhs: TelegramUser) -> Bool { + if lhs === rhs { + return true + } if lhs.id != rhs.id { return false } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index ae3f5417e1..2d9120b97f 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -301,5 +301,9 @@ public extension TelegramEngine { } } } + + public func messageReactionList(message: EngineMessage, reaction: String?) -> EngineMessageReactionListContext { + return EngineMessageReactionListContext(account: self.account, message: message, reaction: reaction) + } } } diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Reactions.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Reactions.imageset/Contents.json new file mode 100644 index 0000000000..d7a8d326ec --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Reactions.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ContextReactionsIcon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Reactions.imageset/ContextReactionsIcon.svg b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Reactions.imageset/ContextReactionsIcon.svg new file mode 100644 index 0000000000..60c69ab8ab --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Context Menu/Reactions.imageset/ContextReactionsIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 386e282359..f78c5927af 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1064,8 +1064,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } else if updatedReaction == nil { itemNode.awaitingAppliedReaction = (nil, { + controller?.dismiss() }) - controller?.dismiss() } } } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 4cbecfdf81..3a494304d4 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -543,7 +543,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState return transaction.getCombinedPeerReadState(messages[0].id.peerId) } - let dataSignal: Signal<(MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?, AppConfiguration, Bool, Int32), NoError> = combineLatest( + let dataSignal: Signal<(MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?, AppConfiguration, Bool, Int32, AvailableReactions?), NoError> = combineLatest( loadLimits, loadStickerSaveStatusSignal, loadResourceStatusSignal, @@ -552,9 +552,10 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState |> take(1), cachedData, readState, - ApplicationSpecificNotice.getMessageViewsPrivacyTips(accountManager: context.sharedContext.accountManager) + ApplicationSpecificNotice.getMessageViewsPrivacyTips(accountManager: context.sharedContext.accountManager), + context.engine.stickers.availableReactions() ) - |> map { limitsAndAppConfig, stickerSaveStatus, resourceStatus, messageActions, updatingMessageMedia, cachedData, readState, messageViewsPrivacyTips -> (MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?, AppConfiguration, Bool, Int32) in + |> map { limitsAndAppConfig, stickerSaveStatus, resourceStatus, messageActions, updatingMessageMedia, cachedData, readState, messageViewsPrivacyTips, availableReactions -> (MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?, AppConfiguration, Bool, Int32, AvailableReactions?) in let (limitsConfiguration, appConfig) = limitsAndAppConfig var canEdit = false if !isAction { @@ -567,12 +568,12 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState isMessageRead = readState.isOutgoingMessageIndexRead(message.index) } - return (MessageContextMenuData(starStatus: stickerSaveStatus, canReply: canReply, canPin: canPin, canEdit: canEdit, canSelect: canSelect, resourceStatus: resourceStatus, messageActions: messageActions), updatingMessageMedia, cachedData, appConfig, isMessageRead, messageViewsPrivacyTips) + return (MessageContextMenuData(starStatus: stickerSaveStatus, canReply: canReply, canPin: canPin, canEdit: canEdit, canSelect: canSelect, resourceStatus: resourceStatus, messageActions: messageActions), updatingMessageMedia, cachedData, appConfig, isMessageRead, messageViewsPrivacyTips, availableReactions) } return dataSignal |> deliverOnMainQueue - |> map { data, updatingMessageMedia, cachedData, appConfig, isMessageRead, messageViewsPrivacyTips -> ContextController.Items in + |> map { data, updatingMessageMedia, cachedData, appConfig, isMessageRead, messageViewsPrivacyTips, availableReactions -> ContextController.Items in var actions: [ContextMenuItem] = [] var isPinnedMessages = false @@ -1209,9 +1210,14 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState controllerInteraction.openPeer(stats.peers[0].id, .default, nil) }) } else if !stats.peers.isEmpty || reactionCount != 0 { - if reactionCount != 0, !"".isEmpty { - let minHeight = c.getActionsMinHeight() - c.setItems(.single(ContextController.Items(content: .custom(ReactionListContextMenuContent()), tip: nil)), minHeight: minHeight, previousActionsTransition: .slide(forward: true)) + if reactionCount != 0 { + c.pushItems(items: .single(ContextController.Items(content: .custom(ReactionListContextMenuContent(context: context, availableReactions: availableReactions, message: EngineMessage(message), back: { [weak c] in + c?.popItems() + }, openPeer: { [weak c] id in + c?.dismiss(completion: { + controllerInteraction.openPeer(id, .default, nil) + }) + })), tip: nil))) } else { var subActions: [ContextMenuItem] = [] @@ -1837,7 +1843,11 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus self.buttonNode.accessibilityLabel = presentationData.strings.VoiceChat_StopRecording self.iconNode = ASImageNode() - self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Read"), color: presentationData.theme.actionSheet.primaryTextColor) + if let reactionsAttribute = item.message.reactionsAttribute, !reactionsAttribute.reactions.isEmpty { + self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reactions"), color: presentationData.theme.actionSheet.primaryTextColor) + } else { + self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Read"), color: presentationData.theme.actionSheet.primaryTextColor) + } self.avatarsNode = AnimatedAvatarSetNode() self.avatarsContext = AnimatedAvatarSetContext() @@ -2053,7 +2063,16 @@ private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCus let placeholderAvatarsContent: AnimatedAvatarSetContext.Content var avatarsPeers: [EnginePeer] = [] - if let peers = self.currentStats?.peers { + if let recentPeers = self.item.message.reactionsAttribute?.recentPeers, !recentPeers.isEmpty { + for recentPeer in recentPeers { + if let peer = self.item.message.peers[recentPeer.peerId] { + avatarsPeers.append(EnginePeer(peer)) + if avatarsPeers.count == 3 { + break + } + } + } + } else if let peers = self.currentStats?.peers { for i in 0 ..< min(3, peers.count) { avatarsPeers.append(peers[i]) } diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 82b407c15e..26bbb5a99a 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -2209,6 +2209,11 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.appliedForwardInfo = (forwardSource, forwardAuthorSignature) strongSelf.updateAccessibilityData(accessibilityData) + var animation = animation + if strongSelf.mainContextSourceNode.isExtractedToContextPreview { + animation = .System(duration: 0.25, transition: ControlledTransition(duration: 0.25, curve: .easeInOut, interactive: false)) + } + var legacyTransition: ContainedViewLayoutTransition = .immediate var useDisplayLinkAnimations = false if case let .System(duration, _) = animation { @@ -2534,7 +2539,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode contentContainer?.containerNode.targetNodeForActivationProgressContentRect = CGRect(origin: CGPoint(x: backgroundFrame.minX + incomingOffset, y: 0.0), size: relativeFrame.size) if previousContextFrame?.size != contentContainer?.containerNode.bounds.size || previousContextContentFrame != contentContainer?.sourceNode.contentRect { - contentContainer?.sourceNode.layoutUpdated?(relativeFrame.size) + contentContainer?.sourceNode.layoutUpdated?(relativeFrame.size, animation) } var selectionInsets = UIEdgeInsets() @@ -2724,16 +2729,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.shadowNode.updateLayout(backgroundFrame: backgroundFrame, transition: animation.transition) strongSelf.backgroundWallpaperNode.updateFrame(backgroundFrame, transition: animation.transition) - if let type = strongSelf.backgroundNode.type { - var incomingOffset: CGFloat = 0.0 + if let _ = strongSelf.backgroundNode.type { + /*var incomingOffset: CGFloat = 0.0 switch type { case .incoming: incomingOffset = 5.0 default: break - } - strongSelf.mainContextSourceNode.contentRect = backgroundFrame.offsetBy(dx: incomingOffset, dy: 0.0) - strongSelf.mainContainerNode.targetNodeForActivationProgressContentRect = strongSelf.mainContextSourceNode.contentRect + }*/ + //strongSelf.mainContextSourceNode.contentRect = backgroundFrame.offsetBy(dx: incomingOffset, dy: 0.0) + //strongSelf.mainContainerNode.targetNodeForActivationProgressContentRect = strongSelf.mainContextSourceNode.contentRect if !strongSelf.mainContextSourceNode.isExtractedToContextPreview { if let (rect, size) = strongSelf.absoluteRect { strongSelf.updateAbsoluteRect(rect, within: size) @@ -2870,7 +2875,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.mainContainerNode.targetNodeForActivationProgressContentRect = strongSelf.mainContextSourceNode.contentRect if previousContextFrame.size != strongSelf.mainContextSourceNode.bounds.size || previousContextContentFrame != strongSelf.mainContextSourceNode.contentRect { - strongSelf.mainContextSourceNode.layoutUpdated?(strongSelf.mainContextSourceNode.bounds.size) + strongSelf.mainContextSourceNode.layoutUpdated?(strongSelf.mainContextSourceNode.bounds.size, animation) } strongSelf.updateSearchTextHighlightState() @@ -2924,11 +2929,12 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } override func shouldAnimateHorizontalFrameTransition() -> Bool { - if let _ = self.backgroundFrameTransition { + return false + /*if let _ = self.backgroundFrameTransition { return true } else { return false - } + }*/ } override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { diff --git a/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift b/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift index 7e90385b1e..cf4d579b2c 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift @@ -285,7 +285,7 @@ public final class ChatMessageTransitionNode: ASDisplayNode { var currentContentRect = self.contextSourceNode.contentRect let contextSourceNode = self.contextSourceNode - self.contextSourceNode.layoutUpdated = { [weak self, weak contextSourceNode] size in + self.contextSourceNode.layoutUpdated = { [weak self, weak contextSourceNode] size, _ in guard let strongSelf = self, let contextSourceNode = contextSourceNode, strongSelf.contextSourceNode === contextSourceNode else { return }