From d7e8737a9227a9dfe6914ba141e6bc882825b347 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Tue, 16 Nov 2021 20:21:38 +0400 Subject: [PATCH] Reactions --- submodules/ContextUI/BUILD | 2 + .../ContextUI/Sources/ContextController.swift | 135 ++++- submodules/LottieMeshSwift/BUILD | 3 +- .../LottieMeshSwift/Sources/Buffer.swift | 171 ++---- .../Sources/MeshAnimation.swift | 119 ++-- .../Sources/MeshAnimationCache.swift | 44 +- .../NotificationsPresentationData.swift | 4 +- submodules/Postbox/Sources/MediaBox.swift | 8 + submodules/ReactionSelectionNode/BUILD | 25 + .../Sources/ReactionContextNode.swift | 529 ++++++++++++++++++ .../Sources/ReactionSelectionNode.swift | 488 ++++++++++++++++ .../Sources/ReactionSelectionParentNode.swift | 87 +++ .../Sources/Account/Account.swift | 1 + .../Account/AccountIntermediateState.swift | 4 + .../ApiUtils/ReactionsMessageAttribute.swift | 102 ++++ .../ApiUtils/StoreMessage_Telegram.swift | 4 + .../Sources/State/AccountViewTracker.swift | 83 +++ .../Sources/State/MessageReactions.swift | 228 ++++++++ .../SyncCore_ReactionsMessageAttribute.swift | 35 +- .../Peers/TelegramEnginePeers.swift | 8 +- .../TelegramUI/Sources/ChatController.swift | 96 ++++ .../ChatMessageAnimatedStickerItemNode.swift | 3 +- .../ChatMessageAttachedContentNode.swift | 4 +- .../ChatMessageBubbleContentNode.swift | 4 + .../Sources/ChatMessageBubbleItemNode.swift | 54 +- .../ChatMessageContactBubbleContentNode.swift | 3 +- .../ChatMessageDateAndStatusNode.swift | 231 +++++++- .../ChatMessageInteractiveFileNode.swift | 3 +- ...atMessageInteractiveInstantVideoNode.swift | 3 +- .../ChatMessageInteractiveMediaNode.swift | 3 +- .../ChatMessageMapBubbleContentNode.swift | 3 +- .../ChatMessageMediaBubbleContentNode.swift | 2 + .../ChatMessagePollBubbleContentNode.swift | 3 +- ...atMessageRestrictedBubbleContentNode.swift | 3 +- .../Sources/ChatMessageStickerItemNode.swift | 3 +- .../ChatMessageTextBubbleContentNode.swift | 15 +- 36 files changed, 2296 insertions(+), 217 deletions(-) create mode 100644 submodules/ReactionSelectionNode/BUILD create mode 100644 submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift create mode 100644 submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift create mode 100644 submodules/ReactionSelectionNode/Sources/ReactionSelectionParentNode.swift create mode 100644 submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift create mode 100644 submodules/TelegramCore/Sources/State/MessageReactions.swift diff --git a/submodules/ContextUI/BUILD b/submodules/ContextUI/BUILD index df3cad27f7..54f8c87581 100644 --- a/submodules/ContextUI/BUILD +++ b/submodules/ContextUI/BUILD @@ -16,6 +16,8 @@ swift_library( "//submodules/TelegramPresentationData:TelegramPresentationData", "//submodules/TextSelectionNode:TextSelectionNode", "//submodules/AppBundle:AppBundle", + "//submodules/AccountContext:AccountContext", + "//submodules/ReactionSelectionNode:ReactionSelectionNode", ], visibility = [ "//visibility:public", diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index f8dadf79d1..8b3625aef7 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -4,8 +4,10 @@ import AsyncDisplayKit import Display import TelegramPresentationData import TextSelectionNode +import ReactionSelectionNode import TelegramCore import SwiftSignalKit +import AccountContext private let animationDurationFactor: Double = 1.0 @@ -201,11 +203,14 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi private var contentAreaInScreenSpace: CGRect? private let contentContainerNode: ContextContentContainerNode private var actionsContainerNode: ContextActionsContainerNode + private var reactionContextNode: ReactionContextNode? + private var reactionContextNodeIsAnimatingOut = false private var didCompleteAnimationIn = false private var initialContinueGesturePoint: CGPoint? private var didMoveFromInitialGesturePoint = false private var highlightedActionNode: ContextActionNodeProtocol? + private var highlightedReaction: ReactionContextItem.Reaction? private let hapticFeedback = HapticFeedback() @@ -216,7 +221,18 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi private let blurBackground: Bool - init(account: Account, controller: ContextController, presentationData: PresentationData, source: ContextContentSource, items: Signal, beginDismiss: @escaping (ContextMenuActionResult) -> Void, recognizer: TapLongTapOrDoubleTapGestureRecognizer?, gesture: ContextGesture?, beganAnimatingOut: @escaping () -> Void, attemptTransitionControllerIntoNavigation: @escaping () -> Void) { + init( + account: Account, + controller: ContextController, + presentationData: PresentationData, + source: ContextContentSource, + items: Signal, + beginDismiss: @escaping (ContextMenuActionResult) -> Void, + recognizer: TapLongTapOrDoubleTapGestureRecognizer?, + gesture: ContextGesture?, + beganAnimatingOut: @escaping () -> Void, + attemptTransitionControllerIntoNavigation: @escaping () -> Void + ) { self.presentationData = presentationData self.source = source self.items = items @@ -653,6 +669,10 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi localContentSourceFrame = localSourceFrame } + if let reactionContextNode = self.reactionContextNode { + reactionContextNode.animateIn(from: CGRect(origin: CGPoint(x: originalProjectedContentViewFrame.1.minX, y: originalProjectedContentViewFrame.1.minY), size: contentParentNode.contentRect.size)) + } + self.actionsContainerNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: localSourceFrame.center.x - self.actionsContainerNode.position.x, y: localSourceFrame.center.y - self.actionsContainerNode.position.y + actionsOffset)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: actionsDuration, initialVelocity: 0.0, damping: springDamping, additive: true) let contentContainerOffset = CGPoint(x: localContentSourceFrame.center.x - self.contentContainerNode.frame.center.x - contentParentNode.contentRect.minX, y: localContentSourceFrame.center.y - self.contentContainerNode.frame.center.y - contentParentNode.contentRect.minY) self.contentContainerNode.layer.animateSpring(from: NSValue(cgPoint: contentContainerOffset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: contentDuration, initialVelocity: 0.0, damping: springDamping, additive: true, completion: { [weak self] _ in @@ -922,6 +942,10 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi contentParentNode.updateAbsoluteRect?(self.contentContainerNode.frame.offsetBy(dx: 0.0, dy: -self.scrollNode.view.contentOffset.y + contentContainerOffset.y), self.bounds.size) contentParentNode.applyAbsoluteOffset?(CGPoint(x: 0.0, y: -contentContainerOffset.y), transitionCurve, transitionDuration) + if let reactionContextNode = self.reactionContextNode { + reactionContextNode.animateOut(to: CGRect(origin: CGPoint(x: originalProjectedContentViewFrame.1.minX, y: originalProjectedContentViewFrame.1.minY), size: contentParentNode.contentRect.size), animatingOutToReaction: self.reactionContextNodeIsAnimatingOut) + } + contentParentNode.willUpdateIsExtractedToContextPreview?(false, .animated(duration: 0.2, curve: .easeInOut)) } else { if let snapshotView = contentParentNode.contentNode.view.snapshotContentTree(keepTransform: true) { @@ -940,6 +964,10 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi contentParentNode.contentNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) contentParentNode.willUpdateIsExtractedToContextPreview?(false, .animated(duration: 0.2, curve: .easeInOut)) + + if let reactionContextNode = self.reactionContextNode { + reactionContextNode.animateOut(to: nil, animatingOutToReaction: self.reactionContextNodeIsAnimatingOut) + } } case let .controller(source): guard let maybeContentNode = self.contentContainerNode.contentNode, case let .controller(controller) = maybeContentNode else { @@ -1078,9 +1106,43 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi completedContentNode = true intermediateCompletion() }) + + if let reactionContextNode = self.reactionContextNode { + reactionContextNode.animateOut(to: nil, animatingOutToReaction: self.reactionContextNodeIsAnimatingOut) + } } } } + + func animateOutToReaction(value: String, targetEmptyNode: ASDisplayNode, targetFilledNode: ASDisplayNode, hideNode: Bool, completion: @escaping () -> Void) { + guard let reactionContextNode = self.reactionContextNode else { + self.animateOut(result: .default, completion: completion) + return + } + var contentCompleted = false + var reactionCompleted = false + let intermediateCompletion: () -> Void = { + if contentCompleted && reactionCompleted { + completion() + } + } + + self.reactionContextNodeIsAnimatingOut = true + self.animateOut(result: .default, completion: { + contentCompleted = true + intermediateCompletion() + }) + reactionContextNode.animateOutToReaction(value: value, targetEmptyNode: targetEmptyNode, targetFilledNode: targetFilledNode, hideNode: hideNode, completion: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.reactionContextNode?.removeFromSupernode() + strongSelf.reactionContextNode = nil + reactionCompleted = true + intermediateCompletion() + }) + } + func getActionsMinHeight() -> ContextController.ActionsHeight? { if !self.actionsContainerNode.bounds.height.isZero { @@ -1111,6 +1173,29 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.currentItems = items self.currentActionsMinHeight = minHeight + + if let reactionContextNode = self.reactionContextNode { + self.reactionContextNode = nil + reactionContextNode.removeFromSupernode() + } + + if !items.reactionItems.isEmpty, let context = items.context { + let reactionContextNode = ReactionContextNode(account: context.account, theme: self.presentationData.theme, items: items.reactionItems) + self.reactionContextNode = reactionContextNode + self.addSubnode(reactionContextNode) + + reactionContextNode.reactionSelected = { [weak self] reaction in + guard let strongSelf = self, let controller = strongSelf.getController() as? ContextController else { + return + } + switch reaction { + case .like: + controller.reactionSelected?(.like) + case .unlike: + controller.reactionSelected?(.unlike) + } + } + } let previousActionsContainerNode = self.actionsContainerNode let previousActionsContainerFrame = previousActionsContainerNode.view.convert(previousActionsContainerNode.bounds, to: self.view) @@ -1202,7 +1287,11 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) let actionsSideInset: CGFloat = layout.safeInsets.left + 11.0 - let contentTopInset: CGFloat = max(11.0, layout.statusBarHeight ?? 0.0) + var contentTopInset: CGFloat = max(11.0, layout.statusBarHeight ?? 0.0) + + if let _ = self.reactionContextNode { + contentTopInset += 34.0 + } let actionsBottomInset: CGFloat = 11.0 @@ -1432,6 +1521,12 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi let absoluteContentRect = contentContainerFrame.offsetBy(dx: 0.0, dy: -self.scrollNode.view.contentOffset.y) contentParentNode.updateAbsoluteRect?(absoluteContentRect, layout.size) + + if let reactionContextNode = self.reactionContextNode { + let insets = layout.insets(options: [.statusBar]) + transition.updateFrame(node: reactionContextNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + reactionContextNode.updateLayout(size: layout.size, insets: insets, anchorRect: CGRect(origin: CGPoint(x: absoluteContentRect.minX + contentParentNode.contentRect.minX, y: absoluteContentRect.minY + contentParentNode.contentRect.minY), size: contentParentNode.contentRect.size), transition: transition) + } } case let .controller(contentParentNode): var projectedFrame: CGRect = convertFrame(contentParentNode.sourceNode.bounds, from: contentParentNode.sourceNode.view, to: self.view) @@ -1562,6 +1657,14 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi transition.animateOffsetAdditive(node: self.scrollNode, offset: currentContainerFrame.minY - previousContainerFrame.minY) } } + + let absoluteContentRect = contentContainerFrame.offsetBy(dx: 0.0, dy: -self.scrollNode.view.contentOffset.y) + + if let reactionContextNode = self.reactionContextNode { + let insets = layout.insets(options: [.statusBar]) + transition.updateFrame(node: reactionContextNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + reactionContextNode.updateLayout(size: layout.size, insets: insets, anchorRect: CGRect(origin: CGPoint(x: absoluteContentRect.minX, y: absoluteContentRect.minY), size: contentSize), transition: transition) + } } } } @@ -1646,6 +1749,13 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi if !self.bounds.contains(point) { return nil } + + if let reactionContextNode = self.reactionContextNode { + if let result = reactionContextNode.hitTest(self.view.convert(point, to: reactionContextNode.view), with: event) { + return result + } + } + let mappedPoint = self.view.convert(point, to: self.scrollNode.view) var maybePassthrough: ContextController.HandledTouchEvent? if let maybeContentNode = self.contentContainerNode.contentNode { @@ -1807,15 +1917,21 @@ public enum ContextContentSource { public final class ContextController: ViewController, StandalonePresentableController, ContextControllerProtocol { public struct Items { public var items: [ContextMenuItem] + public var context: AccountContext? + public var reactionItems: [ReactionContextItem] public var tip: Tip? - public init(items: [ContextMenuItem], tip: Tip? = nil) { + public init(items: [ContextMenuItem], context: AccountContext? = nil, reactionItems: [ReactionContextItem] = [], tip: Tip? = nil) { self.items = items + self.context = context + self.reactionItems = reactionItems self.tip = tip } public init() { self.items = [] + self.context = nil + self.reactionItems = [] self.tip = nil } } @@ -1880,6 +1996,8 @@ public final class ContextController: ViewController, StandalonePresentableContr private var shouldBeDismissedDisposable: Disposable? + public var reactionSelected: ((ReactionContextItem.Reaction) -> Void)? + public init(account: Account, presentationData: PresentationData, source: ContextContentSource, items: Signal, recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil, gesture: ContextGesture? = nil) { self.account = account self.presentationData = presentationData @@ -2022,4 +2140,15 @@ public final class ContextController: ViewController, StandalonePresentableContr override public func dismiss(completion: (() -> Void)? = nil) { self.dismiss(result: .default, completion: completion) } + + public func dismissWithReaction(value: String, targetEmptyNode: ASDisplayNode, targetFilledNode: ASDisplayNode, hideNode: Bool, completion: (() -> Void)?) { + if !self.wasDismissed { + self.wasDismissed = true + self.controllerNode.animateOutToReaction(value: value, targetEmptyNode: targetEmptyNode, targetFilledNode: targetFilledNode, hideNode: hideNode, completion: { [weak self] in + self?.presentingViewController?.dismiss(animated: false, completion: nil) + completion?() + }) + self.dismissed?() + } + } } diff --git a/submodules/LottieMeshSwift/BUILD b/submodules/LottieMeshSwift/BUILD index 3782d528d0..f83cda9829 100644 --- a/submodules/LottieMeshSwift/BUILD +++ b/submodules/LottieMeshSwift/BUILD @@ -56,7 +56,7 @@ optimization_flags = select({ swift_optimization_flags = select({ ":debug_build": [ - "-O", + #"-O", ], "//conditions:default": [], }) @@ -75,6 +75,7 @@ swift_library( ], deps = [ ":LottieMeshBinding", + "//submodules/Postbox:Postbox", ], visibility = [ "//visibility:public", diff --git a/submodules/LottieMeshSwift/Sources/Buffer.swift b/submodules/LottieMeshSwift/Sources/Buffer.swift index 81a7a21030..a620fbab3d 100644 --- a/submodules/LottieMeshSwift/Sources/Buffer.swift +++ b/submodules/LottieMeshSwift/Sources/Buffer.swift @@ -1,122 +1,49 @@ import Foundation +import Postbox private let emptyMemory = malloc(1)! -public class MeshMemoryBuffer: Equatable, CustomStringConvertible { - public internal(set) var memory: UnsafeMutableRawPointer - var capacity: Int +public class MeshMemoryBuffer { + public internal(set) var data: Data public internal(set) var length: Int - var freeWhenDone: Bool - - public init(copyOf buffer: MeshMemoryBuffer) { - self.memory = malloc(buffer.length) - memcpy(self.memory, buffer.memory, buffer.length) - self.capacity = buffer.length - self.length = buffer.length - self.freeWhenDone = true - } - - public init(memory: UnsafeMutableRawPointer, capacity: Int, length: Int, freeWhenDone: Bool) { - self.memory = memory - self.capacity = capacity - self.length = length - self.freeWhenDone = freeWhenDone - } public init(data: Data) { - if data.count == 0 { - self.memory = emptyMemory - self.capacity = 0 - self.length = 0 - self.freeWhenDone = false - } else { - self.memory = malloc(data.count)! - data.copyBytes(to: self.memory.assumingMemoryBound(to: UInt8.self), count: data.count) - self.capacity = data.count - self.length = data.count - self.freeWhenDone = true - } - } - - public init() { - self.memory = emptyMemory - self.capacity = 0 - self.length = 0 - self.freeWhenDone = false - } - - deinit { - if self.freeWhenDone { - free(self.memory) - } - } - - public var description: String { - let hexString = NSMutableString() - let bytes = self.memory.assumingMemoryBound(to: UInt8.self) - for i in 0 ..< self.length { - hexString.appendFormat("%02x", UInt(bytes[i])) - } - - return hexString as String + self.data = data + self.length = data.count } public func makeData() -> Data { - if self.length == 0 { - return Data() + if self.data.count == self.length { + return self.data } else { - return Data(bytes: self.memory, count: self.length) + return self.data.subdata(in: 0 ..< self.length) } } - - public func withDataNoCopy(_ f: (Data) -> Void) { - f(Data(bytesNoCopy: self.memory, count: self.length, deallocator: .none)) - } - - public static func ==(lhs: MeshMemoryBuffer, rhs: MeshMemoryBuffer) -> Bool { - return lhs.length == rhs.length && memcmp(lhs.memory, rhs.memory, lhs.length) == 0 - } } -public final class MeshWriteBuffer: MeshMemoryBuffer { - public var offset = 0 - - public override init() { - super.init(memory: malloc(32), capacity: 32, length: 0, freeWhenDone: true) +extension WriteBuffer { + func writeInt32(_ value: Int32) { + var value = value + self.write(&value, length: 4) } - - public func makeReadBufferAndReset() -> MeshReadBuffer { - let buffer = MeshReadBuffer(memory: self.memory, length: self.offset, freeWhenDone: true) - self.memory = malloc(32) - self.capacity = 32 - self.offset = 0 - return buffer + + func writeFloat(_ value: Float) { + var value: Float32 = value + self.write(&value, length: 4) } +} - public func readBufferNoCopy() -> MeshReadBuffer { - return MeshReadBuffer(memory: self.memory, length: self.offset, freeWhenDone: false) - } +public final class MeshWriteBuffer { + let file: ManagedFile + private(set) var offset: Int = 0 - override public func makeData() -> Data { - return Data(bytes: self.memory.assumingMemoryBound(to: UInt8.self), count: self.offset) - } - - public func reset() { - self.offset = 0 + public init(file: ManagedFile) { + self.file = file } public func write(_ data: UnsafeRawPointer, length: Int) { - if self.offset + length > self.capacity { - self.capacity = self.offset + length + 256 - if self.length == 0 { - self.memory = malloc(self.capacity)! - } else { - self.memory = realloc(self.memory, self.capacity) - } - } - memcpy(self.memory + self.offset, data, length) + let _ = self.file.write(data, count: length) self.offset += length - self.length = self.offset } public func writeInt8(_ value: Int8) { @@ -135,18 +62,20 @@ public final class MeshWriteBuffer: MeshMemoryBuffer { } public func write(_ data: Data) { - let length = data.count - if self.offset + length > self.capacity { - self.capacity = self.offset + length + 256 - if self.length == 0 { - self.memory = malloc(self.capacity)! - } else { - self.memory = realloc(self.memory, self.capacity) - } + data.withUnsafeBytes { bytes in + self.write(bytes.baseAddress!, length: bytes.count) } - data.copyBytes(to: self.memory.advanced(by: offset).assumingMemoryBound(to: UInt8.self), count: length) - self.offset += length - self.length = self.offset + } + + func write(_ data: DataRange) { + data.data.withUnsafeBytes { bytes in + self.write(bytes.baseAddress!.advanced(by: data.range.lowerBound), length: data.count) + } + } + + public func seek(offset: Int) { + self.file.seek(position: Int64(offset)) + self.offset = offset } } @@ -157,22 +86,16 @@ public final class MeshReadBuffer: MeshMemoryBuffer { super.init(data: data) } - public init(memory: UnsafeMutableRawPointer, length: Int, freeWhenDone: Bool) { - super.init(memory: memory, capacity: length, length: length, freeWhenDone: freeWhenDone) - } - - public init(memoryBufferNoCopy: MeshMemoryBuffer) { - super.init(memory: memoryBufferNoCopy.memory, capacity: memoryBufferNoCopy.length, length: memoryBufferNoCopy.length, freeWhenDone: false) - } - - public func dataNoCopy() -> Data { - return Data(bytesNoCopy: self.memory.assumingMemoryBound(to: UInt8.self), count: self.length, deallocator: .none) - } - public func read(_ data: UnsafeMutableRawPointer, length: Int) { - memcpy(data, self.memory.advanced(by: self.offset), length) + self.data.copyBytes(to: data.assumingMemoryBound(to: UInt8.self), from: self.offset ..< (self.offset + length)) self.offset += length } + + func readDataRange(count: Int) -> DataRange { + let result = DataRange(data: self.data, range: self.offset ..< (self.offset + count)) + self.offset += count + return result + } public func readInt8() -> Int8 { var result: Int8 = 0 @@ -195,12 +118,4 @@ public final class MeshReadBuffer: MeshMemoryBuffer { public func skip(_ length: Int) { self.offset += length } - - public func reset() { - self.offset = 0 - } - - public func sharedBufferNoCopy() -> MeshReadBuffer { - return MeshReadBuffer(memory: memory, length: length, freeWhenDone: false) - } } diff --git a/submodules/LottieMeshSwift/Sources/MeshAnimation.swift b/submodules/LottieMeshSwift/Sources/MeshAnimation.swift index 0de377be5a..a7710444df 100644 --- a/submodules/LottieMeshSwift/Sources/MeshAnimation.swift +++ b/submodules/LottieMeshSwift/Sources/MeshAnimation.swift @@ -2,6 +2,7 @@ import Foundation import Metal import MetalKit import LottieMeshBinding +import Postbox enum TriangleFill { struct Color { @@ -109,15 +110,29 @@ enum MeshOption { case stroke(lineWidth: CGFloat, miterLimit: CGFloat, lineJoin: CGLineJoin, lineCap: CGLineCap) } +final class DataRange { + let data: Data + let range: Range + + init(data: Data, range: Range) { + self.data = data + self.range = range + } + + var count: Int { + return self.range.upperBound - self.range.lowerBound + } +} + public final class MeshAnimation { final class Frame { final class Segment { - let vertices: Data - let triangles: Data + let vertices: DataRange + let triangles: DataRange let fill: TriangleFill let transform: CGAffineTransform - init(vertices: Data, triangles: Data, fill: TriangleFill, transform: CGAffineTransform) { + init(vertices: DataRange, triangles: DataRange, fill: TriangleFill, transform: CGAffineTransform) { self.vertices = vertices self.triangles = triangles self.fill = fill @@ -126,16 +141,10 @@ public final class MeshAnimation { static func read(buffer: MeshReadBuffer) -> Segment { let vertCount = Int(buffer.readInt32()) - var vertices = Data(count: vertCount) - vertices.withUnsafeMutableBytes { bytes in - buffer.read(bytes.baseAddress!, length: bytes.count) - } + let vertices = buffer.readDataRange(count: vertCount) let triCount = Int(buffer.readInt32()) - var triangles = Data(count: triCount) - triangles.withUnsafeMutableBytes { bytes in - buffer.read(bytes.baseAddress!, length: bytes.count) - } + let triangles = buffer.readDataRange(count: triCount) return Segment(vertices: vertices, triangles: triangles, fill: TriangleFill.read(buffer: buffer), transform: CGAffineTransform(a: CGFloat(buffer.readFloat()), b: CGFloat(buffer.readFloat()), c: CGFloat(buffer.readFloat()), d: CGFloat(buffer.readFloat()), tx: CGFloat(buffer.readFloat()), ty: CGFloat(buffer.readFloat()))) } @@ -171,12 +180,12 @@ public final class MeshAnimation { return Frame(segments: segments) } - func write(buffer: MeshWriteBuffer) { + /*func write(buffer: MeshWriteBuffer) { buffer.writeInt32(Int32(self.segments.count)) for segment in self.segments { segment.write(buffer: buffer) } - } + }*/ } let frames: [Frame] @@ -194,12 +203,12 @@ public final class MeshAnimation { return MeshAnimation(frames: frames) } - public func write(buffer: MeshWriteBuffer) { + /*public func write(buffer: MeshWriteBuffer) { buffer.writeInt32(Int32(self.frames.count)) for frame in self.frames { frame.write(buffer: buffer) } - } + }*/ } @available(iOS 13.0, *) @@ -456,15 +465,15 @@ public final class MeshRenderer: MTKView { let startVertexIndex = nextVertexIndex let startIndexIndex = nextIndexIndex - segment.vertices.withUnsafeBytes { vertices in - let _ = memcpy(vertexData.advanced(by: nextVertexIndex * 2), vertices.baseAddress!, vertices.count) + segment.vertices.data.withUnsafeBytes { vertices in + let _ = memcpy(vertexData.advanced(by: nextVertexIndex * 2), vertices.baseAddress!.advanced(by: segment.vertices.range.lowerBound), segment.vertices.count) } nextVertexIndex += segment.vertices.count / (4 * 2) let baseVertexIndex = Int32(startVertexIndex) - segment.triangles.withUnsafeBytes { triangles in - let _ = memcpy(indexData.advanced(by: nextIndexIndex), triangles.baseAddress!, triangles.count) + segment.triangles.data.withUnsafeBytes { triangles in + let _ = memcpy(indexData.advanced(by: nextIndexIndex), triangles.baseAddress!.advanced(by: segment.triangles.range.lowerBound), segment.triangles.count) } nextIndexIndex += segment.triangles.count / 4 @@ -534,8 +543,6 @@ public final class MeshRenderer: MTKView { renderEncoder.setVertexBuffer(mesh.vertexBuffer, offset: 0, index: 0) renderEncoder.drawIndexedPrimitives(type: .triangle, indexCount: iCount, indexType: .uint32, indexBuffer: mesh.indexBuffer, indexBufferOffset: iStart * 4) - //renderEncoder.drawIndexedPrimitives(type: .triangle, indexCount: iCount, indexType: .uint32, indexBuffer: mesh.indexBuffer, indexBufferOffset: iStart, instanceCount: 1) - //renderEncoder.drawPrimitives(type: .triangle, vertexStart: startIndex, vertexCount: count, instanceCount: 1) } let nextFrame = mesh.currentFrame + 1 @@ -566,18 +573,13 @@ public final class MeshRenderer: MTKView { } } -private func generateSegments(geometry: CapturedGeometryNode, superAlpha: CGFloat = 1.0, path: [Int] = []) -> [MeshAnimation.Frame.Segment] { +private func generateSegments(geometry: CapturedGeometryNode, superAlpha: CGFloat, superTransform: CGAffineTransform, writeSegment: (MeshAnimation.Frame.Segment) -> Void) { if geometry.isHidden || geometry.alpha.isZero { - return [] + return } - var result: [MeshAnimation.Frame.Segment] = [] - for i in 0 ..< geometry.subnodes.count { - let subResult = generateSegments(geometry: geometry.subnodes[i], superAlpha: superAlpha * geometry.alpha, path: path + [i]).map { segment in - return MeshAnimation.Frame.Segment(vertices: segment.vertices, triangles: segment.triangles, fill: segment.fill, transform: segment.transform.concatenating(CATransform3DGetAffineTransform(geometry.transform))) - } - result.append(contentsOf: subResult) + generateSegments(geometry: geometry.subnodes[i], superAlpha: superAlpha * geometry.alpha, superTransform: CATransform3DGetAffineTransform(geometry.transform).concatenating(superTransform), writeSegment: writeSegment) } if let displayItem = geometry.displayItem { @@ -615,7 +617,7 @@ private func generateSegments(geometry: CapturedGeometryNode, superAlpha: CGFloa meshData = LottieMeshData.generate(with: UIBezierPath(cgPath: displayItem.path), fill: nil, stroke: LottieMeshStroke(lineWidth: stroke.lineWidth, lineJoin: stroke.lineJoin, lineCap: stroke.lineCap, miterLimit: stroke.miterLimit)) } if let meshData = meshData, meshData.triangleCount() != 0 { - let mappedTriangles = MeshWriteBuffer() + let mappedTriangles = WriteBuffer() for i in 0 ..< meshData.triangleCount() { var v0: Int = 0 var v1: Int = 0 @@ -626,7 +628,7 @@ private func generateSegments(geometry: CapturedGeometryNode, superAlpha: CGFloa mappedTriangles.writeInt32(Int32(v2)) } - let mappedVertices = MeshWriteBuffer() + let mappedVertices = WriteBuffer() for i in 0 ..< meshData.vertexCount() { var x: Float = 0.0 var y: Float = 0.0 @@ -635,32 +637,69 @@ private func generateSegments(geometry: CapturedGeometryNode, superAlpha: CGFloa mappedVertices.writeFloat(y) } - result.append(MeshAnimation.Frame.Segment(vertices: mappedVertices.makeData(), triangles: mappedTriangles.makeData(), fill: triangleFill, transform: CATransform3DGetAffineTransform(geometry.transform))) + let verticesData = mappedVertices.makeData() + let trianglesData = mappedTriangles.makeData() + + let segment = MeshAnimation.Frame.Segment(vertices: DataRange(data: verticesData, range: 0 ..< verticesData.count), triangles: DataRange(data: trianglesData, range: 0 ..< trianglesData.count), fill: triangleFill, transform: CATransform3DGetAffineTransform(geometry.transform).concatenating(superTransform)) + + writeSegment(segment) } } - - return result } -public func generateMeshAnimation(data: Data) -> MeshAnimation? { +public func generateMeshAnimation(data: Data) -> TempBoxFile? { guard let animation = try? JSONDecoder().decode(Animation.self, from: data) else { return nil } let container = MyAnimationContainer(animation: animation) - - var frames: [MeshAnimation.Frame] = [] + + let tempFile = TempBox.shared.tempFile(fileName: "data") + guard let file = ManagedFile(queue: nil, path: tempFile.path, mode: .readwrite) else { + return nil + } + let writeBuffer = MeshWriteBuffer(file: file) + + let frameCountOffset = writeBuffer.offset + writeBuffer.writeInt32(0) + + var frameCount: Int = 0 for i in 0 ..< Int(animation.endFrame) { container.setFrame(frame: CGFloat(i)) #if DEBUG print("Frame \(i) / \(Int(animation.endFrame))") #endif + + let segmentCountOffset = writeBuffer.offset + writeBuffer.writeInt32(0) + var segmentCount: Int = 0 let geometry = container.captureGeometry() geometry.transform = CATransform3DMakeTranslation(256.0, 256.0, 0.0) - let segments = generateSegments(geometry: geometry) - frames.append(MeshAnimation.Frame(segments: segments)) + + generateSegments(geometry: geometry, superAlpha: 1.0, superTransform: .identity, writeSegment: { segment in + segment.write(buffer: writeBuffer) + + segmentCount += 1 + }) + + let currentOffset = writeBuffer.offset + writeBuffer.seek(offset: segmentCountOffset) + writeBuffer.writeInt32(Int32(segmentCount)) + + writeBuffer.seek(offset: currentOffset) + + frameCount += 1 } + + let currentOffset = writeBuffer.offset + writeBuffer.seek(offset: frameCountOffset) + writeBuffer.writeInt32(Int32(frameCount)) + writeBuffer.seek(offset: currentOffset) - return MeshAnimation(frames: frames) + return tempFile +} + +public final class MeshRenderingContext { + } diff --git a/submodules/MeshAnimationCache/Sources/MeshAnimationCache.swift b/submodules/MeshAnimationCache/Sources/MeshAnimationCache.swift index 4a17830150..403bf31727 100644 --- a/submodules/MeshAnimationCache/Sources/MeshAnimationCache.swift +++ b/submodules/MeshAnimationCache/Sources/MeshAnimationCache.swift @@ -25,7 +25,7 @@ public final class MeshAnimationCache { if let item = self.items[resource.id.stringRepresentation] { if let animation = item.animation { return animation - } else if let readyPath = item.readyPath, let data = try? Data(contentsOf: URL(fileURLWithPath: readyPath)) { + } else if let readyPath = item.readyPath, let data = try? Data(contentsOf: URL(fileURLWithPath: readyPath), options: [.alwaysMapped]) { let buffer = MeshReadBuffer(data: data) let animation = MeshAnimation.read(buffer: buffer) item.animation = animation @@ -38,7 +38,7 @@ public final class MeshAnimationCache { self.items[resource.id.stringRepresentation] = item let path = self.mediaBox.cachedRepresentationPathForId(resource.id.stringRepresentation, representationId: "mesh-animation", keepDuration: .general) - if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { + if let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.alwaysMapped]) { let animation = MeshAnimation.read(buffer: MeshReadBuffer(data: data)) item.readyPath = path item.animation = animation @@ -54,7 +54,7 @@ public final class MeshAnimationCache { if let item = self.items[bundleName] { if let animation = item.animation { return animation - } else if let readyPath = item.readyPath, let data = try? Data(contentsOf: URL(fileURLWithPath: readyPath)) { + } else if let readyPath = item.readyPath, let data = try? Data(contentsOf: URL(fileURLWithPath: readyPath), options: [.alwaysMapped]) { let buffer = MeshReadBuffer(data: data) let animation = MeshAnimation.read(buffer: buffer) item.animation = animation @@ -67,7 +67,7 @@ public final class MeshAnimationCache { self.items[bundleName] = item let path = self.mediaBox.cachedRepresentationPathForId(bundleName, representationId: "mesh-animation", keepDuration: .general) - if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { + if let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.alwaysMapped]) { let animation = MeshAnimation.read(buffer: MeshReadBuffer(data: data)) item.readyPath = path item.animation = animation @@ -89,18 +89,22 @@ public final class MeshAnimationCache { |> take(1) |> mapToSignal { data -> Signal<(MeshAnimation, String)?, NoError> in return Signal { subscriber in - guard let zippedData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) else { + guard let zippedData = try? Data(contentsOf: URL(fileURLWithPath: data.path), options: [.alwaysMapped]) else { subscriber.putNext(nil) subscriber.putCompletion() return EmptyDisposable } let jsonData = TGGUnzipData(zippedData, 1 * 1024 * 1024) ?? zippedData - if let animation = generateMeshAnimation(data: jsonData) { - let buffer = MeshWriteBuffer() - animation.write(buffer: buffer) - mediaBox.storeCachedResourceRepresentation(resource, representationId: "mesh-animation", keepDuration: .general, data: buffer.makeData(), completion: { path in - subscriber.putNext((animation, path)) - subscriber.putCompletion() + if let tempFile = generateMeshAnimation(data: jsonData) { + mediaBox.storeCachedResourceRepresentation(resource.id.stringRepresentation, representationId: "mesh-animation", keepDuration: .general, tempFile: tempFile, completion: { path in + if let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.alwaysMapped]) { + let animation = MeshAnimation.read(buffer: MeshReadBuffer(data: data)) + subscriber.putNext((animation, path)) + subscriber.putCompletion() + } else { + subscriber.putNext(nil) + subscriber.putCompletion() + } }) } else { subscriber.putNext(nil) @@ -136,18 +140,22 @@ public final class MeshAnimationCache { return EmptyDisposable } - guard let zippedData = try? Data(contentsOf: URL(fileURLWithPath: path)) else { + guard let zippedData = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.alwaysMapped]) else { subscriber.putNext(nil) subscriber.putCompletion() return EmptyDisposable } let jsonData = TGGUnzipData(zippedData, 1 * 1024 * 1024) ?? zippedData - if let animation = generateMeshAnimation(data: jsonData) { - let buffer = MeshWriteBuffer() - animation.write(buffer: buffer) - mediaBox.storeCachedResourceRepresentation(bundleName, representationId: "mesh-animation", keepDuration: .general, data: buffer.makeData(), completion: { path in - subscriber.putNext((animation, path)) - subscriber.putCompletion() + if let tempFile = generateMeshAnimation(data: jsonData) { + mediaBox.storeCachedResourceRepresentation(bundleName, representationId: "mesh-animation", keepDuration: .general, tempFile: tempFile, completion: { path in + if let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.alwaysMapped]) { + let animation = MeshAnimation.read(buffer: MeshReadBuffer(data: data)) + subscriber.putNext((animation, path)) + subscriber.putCompletion() + } else { + subscriber.putNext(nil) + subscriber.putCompletion() + } }) } else { subscriber.putNext(nil) diff --git a/submodules/NotificationsPresentationData/Sources/NotificationsPresentationData.swift b/submodules/NotificationsPresentationData/Sources/NotificationsPresentationData.swift index cc1352f022..d34b39971f 100644 --- a/submodules/NotificationsPresentationData/Sources/NotificationsPresentationData.swift +++ b/submodules/NotificationsPresentationData/Sources/NotificationsPresentationData.swift @@ -2,9 +2,11 @@ import Foundation public struct NotificationsPresentationData: Codable, Equatable { public var applicationLockedMessageString: String + public var incomingCallString: String - public init(applicationLockedMessageString: String) { + public init(applicationLockedMessageString: String, incomingCallString: String) { self.applicationLockedMessageString = applicationLockedMessageString + self.incomingCallString = incomingCallString } } diff --git a/submodules/Postbox/Sources/MediaBox.swift b/submodules/Postbox/Sources/MediaBox.swift index eb6839a5c2..ca4b2a57de 100644 --- a/submodules/Postbox/Sources/MediaBox.swift +++ b/submodules/Postbox/Sources/MediaBox.swift @@ -805,6 +805,14 @@ public final class MediaBox { } } + public func storeCachedResourceRepresentation(_ resourceId: String, representationId: String, keepDuration: CachedMediaRepresentationKeepDuration, tempFile: TempBoxFile, completion: @escaping (String) -> Void = { _ in }) { + self.dataQueue.async { + let path = self.cachedRepresentationPathsForId(resourceId, representationId: representationId, keepDuration: keepDuration).complete + let _ = try? FileManager.default.moveItem(atPath: tempFile.path, toPath: path) + completion(path) + } + } + public func cachedResourceRepresentation(_ resource: MediaResource, representation: CachedMediaResourceRepresentation, pathExtension: String? = nil, complete: Bool, fetch: Bool = true, attemptSynchronously: Bool = false) -> Signal { return Signal { subscriber in let disposable = MetaDisposable() diff --git a/submodules/ReactionSelectionNode/BUILD b/submodules/ReactionSelectionNode/BUILD new file mode 100644 index 0000000000..46c127075d --- /dev/null +++ b/submodules/ReactionSelectionNode/BUILD @@ -0,0 +1,25 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ReactionSelectionNode", + module_name = "ReactionSelectionNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Postbox:Postbox", + "//submodules/TelegramCore:TelegramCore", + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/AnimatedStickerNode:AnimatedStickerNode", + "//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode", + "//submodules/TelegramPresentationData:TelegramPresentationData", + ], + visibility = [ + "//visibility:public", + ], +) + diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift new file mode 100644 index 0000000000..1b4a764ac2 --- /dev/null +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -0,0 +1,529 @@ +import Foundation +import AsyncDisplayKit +import Display +import AnimatedStickerNode +import TelegramCore +import TelegramPresentationData + +public enum ReactionGestureItem { + case like + case unlike +} + +public final class ReactionContextItem { + public enum Reaction { + case like + case unlike + } + + public let reaction: ReactionContextItem.Reaction + + public init(reaction: ReactionContextItem.Reaction) { + self.reaction = reaction + } +} + +private let largeCircleSize: CGFloat = 16.0 +private let smallCircleSize: CGFloat = 8.0 + +private func generateBackgroundImage(foreground: UIColor, diameter: CGFloat, shadowBlur: CGFloat) -> UIImage? { + return generateImage(CGSize(width: diameter + shadowBlur * 2.0, height: diameter + shadowBlur * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(foreground.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + })?.stretchableImage(withLeftCapWidth: Int(shadowBlur + diameter / 2.0), topCapHeight: Int(shadowBlur + diameter / 2.0)) +} + +private func generateBackgroundShadowImage(shadow: UIColor, diameter: CGFloat, shadowBlur: CGFloat) -> UIImage? { + return generateImage(CGSize(width: diameter * 2.0 + shadowBlur * 2.0, height: diameter + shadowBlur * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(shadow.cgColor) + context.setShadow(offset: CGSize(), blur: shadowBlur, color: shadow.cgColor) + + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur + diameter, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.fill(CGRect(origin: CGPoint(x: shadowBlur + diameter / 2.0, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur + diameter, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.fill(CGRect(origin: CGPoint(x: shadowBlur + diameter / 2.0, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + })?.stretchableImage(withLeftCapWidth: Int(diameter + shadowBlur / 2.0), topCapHeight: Int(diameter / 2.0 + shadowBlur / 2.0)) +} + +private func generateBubbleImage(foreground: UIColor, diameter: CGFloat, shadowBlur: CGFloat) -> UIImage? { + return generateImage(CGSize(width: diameter + shadowBlur * 2.0, height: diameter + shadowBlur * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(foreground.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + })?.stretchableImage(withLeftCapWidth: Int(diameter / 2.0 + shadowBlur / 2.0), topCapHeight: Int(diameter / 2.0 + shadowBlur / 2.0)) +} + +private func generateBubbleShadowImage(shadow: UIColor, diameter: CGFloat, shadowBlur: CGFloat) -> UIImage? { + return generateImage(CGSize(width: diameter + shadowBlur * 2.0, height: diameter + shadowBlur * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(shadow.cgColor) + context.setShadow(offset: CGSize(), blur: shadowBlur, color: shadow.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.setShadow(offset: CGSize(), blur: 1.0, color: shadow.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + })?.stretchableImage(withLeftCapWidth: Int(diameter / 2.0 + shadowBlur / 2.0), topCapHeight: Int(diameter / 2.0 + shadowBlur / 2.0)) +} + +public final class ReactionContextNode: ASDisplayNode { + private let theme: PresentationTheme + private let items: [ReactionContextItem] + + private let backgroundNode: ASImageNode + private let backgroundShadowNode: ASImageNode + private let backgroundContainerNode: ASDisplayNode + + private let largeCircleNode: ASImageNode + private let largeCircleShadowNode: ASImageNode + + private let smallCircleNode: ASImageNode + private let smallCircleShadowNode: ASImageNode + + private let contentContainer: ASDisplayNode + private var itemNodes: [ReactionNode] = [] + private let disclosureButton: HighlightTrackingButtonNode + + private var isExpanded: Bool = true + private var highlightedReaction: ReactionContextItem.Reaction? + private var validLayout: (CGSize, UIEdgeInsets, CGRect)? + + public var reactionSelected: ((ReactionGestureItem) -> Void)? + + private let hapticFeedback = HapticFeedback() + + public init(account: Account, theme: PresentationTheme, items: [ReactionContextItem]) { + self.theme = theme + self.items = items + + let shadowBlur: CGFloat = 5.0 + + self.backgroundNode = ASImageNode() + self.backgroundNode.displayWithoutProcessing = true + self.backgroundNode.displaysAsynchronously = false + + self.backgroundShadowNode = ASImageNode() + self.backgroundShadowNode.displayWithoutProcessing = true + self.backgroundShadowNode.displaysAsynchronously = false + + self.backgroundContainerNode = ASDisplayNode() + self.backgroundContainerNode.allowsGroupOpacity = true + + self.largeCircleNode = ASImageNode() + self.largeCircleNode.displayWithoutProcessing = true + self.largeCircleNode.displaysAsynchronously = false + + self.largeCircleShadowNode = ASImageNode() + self.largeCircleShadowNode.displayWithoutProcessing = true + self.largeCircleShadowNode.displaysAsynchronously = false + + self.smallCircleNode = ASImageNode() + self.smallCircleNode.displayWithoutProcessing = true + self.smallCircleNode.displaysAsynchronously = false + + self.smallCircleShadowNode = ASImageNode() + self.smallCircleShadowNode.displayWithoutProcessing = true + self.smallCircleShadowNode.displaysAsynchronously = false + + self.backgroundNode.image = generateBackgroundImage(foreground: theme.contextMenu.backgroundColor.withAlphaComponent(1.0), diameter: 52.0, shadowBlur: shadowBlur) + + self.backgroundShadowNode.image = generateBackgroundShadowImage(shadow: UIColor(white: 0.0, alpha: 0.2), diameter: 52.0, shadowBlur: shadowBlur) + + self.largeCircleNode.image = generateBubbleImage(foreground: theme.contextMenu.backgroundColor.withAlphaComponent(1.0), diameter: largeCircleSize, shadowBlur: shadowBlur) + self.smallCircleNode.image = generateBubbleImage(foreground: theme.contextMenu.backgroundColor.withAlphaComponent(1.0), diameter: smallCircleSize, shadowBlur: shadowBlur) + + self.largeCircleShadowNode.image = generateBubbleShadowImage(shadow: UIColor(white: 0.0, alpha: 0.2), diameter: largeCircleSize, shadowBlur: shadowBlur) + self.smallCircleShadowNode.image = generateBubbleShadowImage(shadow: UIColor(white: 0.0, alpha: 0.2), diameter: smallCircleSize, shadowBlur: shadowBlur) + + self.contentContainer = ASDisplayNode() + self.contentContainer.clipsToBounds = true + + self.disclosureButton = HighlightTrackingButtonNode() + self.disclosureButton.hitTestSlop = UIEdgeInsets(top: -6.0, left: -6.0, bottom: -6.0, right: -6.0) + let buttonImage = generateImage(CGSize(width: 30.0, height: 30.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(theme.contextMenu.dimColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + context.setBlendMode(.copy) + context.setStrokeColor(UIColor.clear.cgColor) + context.setLineWidth(2.0) + context.setLineCap(.round) + context.setLineJoin(.round) + context.beginPath() + context.move(to: CGPoint(x: 8.0, y: size.height / 2.0 + 3.0)) + context.addLine(to: CGPoint(x: size.width / 2.0, y: 11.0)) + context.addLine(to: CGPoint(x: size.width - 8.0, y: size.height / 2.0 + 3.0)) + context.strokePath() + }) + self.disclosureButton.setImage(buttonImage, for: []) + + super.init() + + self.addSubnode(self.smallCircleShadowNode) + self.addSubnode(self.largeCircleShadowNode) + self.addSubnode(self.backgroundShadowNode) + + self.backgroundContainerNode.addSubnode(self.smallCircleNode) + self.backgroundContainerNode.addSubnode(self.largeCircleNode) + self.backgroundContainerNode.addSubnode(self.backgroundNode) + self.addSubnode(self.backgroundContainerNode) + + self.contentContainer.addSubnode(self.disclosureButton) + + self.itemNodes = self.items.map { item in + let reactionItem: ReactionGestureItem + switch item.reaction { + case .like: + reactionItem = .like + case .unlike: + reactionItem = .unlike + } + return ReactionNode(account: account, theme: theme, reaction: reactionItem, maximizedReactionSize: 30.0, loadFirstFrame: true) + } + self.itemNodes.forEach(self.contentContainer.addSubnode) + + self.addSubnode(self.contentContainer) + + self.disclosureButton.addTarget(self, action: #selector(self.disclosurePressed), forControlEvents: .touchUpInside) + self.disclosureButton.highligthedChanged = { [weak self] highlighted in + if highlighted { + self?.disclosureButton.layer.animateScale(from: 1.0, to: 0.8, duration: 0.15, removeOnCompletion: false) + } else { + self?.disclosureButton.layer.animateScale(from: 0.8, to: 1.0, duration: 0.25) + } + } + } + + override public func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + public func updateLayout(size: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, transition: ContainedViewLayoutTransition) { + self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: transition, animateInFromAnchorRect: nil, animateOutToAnchorRect: nil) + } + + private func calculateBackgroundFrame(containerSize: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, contentSize: CGSize) -> (CGRect, Bool) { + var contentSize = contentSize + contentSize.width = max(52.0, contentSize.width) + contentSize.height = 52.0 + + let sideInset: CGFloat = 12.0 + let backgroundOffset: CGPoint = CGPoint(x: 22.0, y: -7.0) + + var rect: CGRect + let isLeftAligned: Bool + if anchorRect.maxX < containerSize.width - backgroundOffset.x - sideInset { + rect = CGRect(origin: CGPoint(x: anchorRect.maxX - contentSize.width + backgroundOffset.x, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize) + isLeftAligned = true + } else { + rect = CGRect(origin: CGPoint(x: anchorRect.minX - backgroundOffset.x - 4.0, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize) + isLeftAligned = false + } + rect.origin.x = max(sideInset, rect.origin.x) + rect.origin.y = max(insets.top + sideInset, rect.origin.y) + rect.origin.x = min(containerSize.width - contentSize.width - sideInset, rect.origin.x) + return (rect, isLeftAligned) + } + + private func updateLayout(size: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, transition: ContainedViewLayoutTransition, animateInFromAnchorRect: CGRect?, animateOutToAnchorRect: CGRect?, animateReactionHighlight: Bool = false) { + self.validLayout = (size, insets, anchorRect) + + let sideInset: CGFloat = 12.0 + let itemSpacing: CGFloat = 6.0 + let minimizedItemSize: CGFloat = 30.0 + let maximizedItemSize: CGFloat = 30.0 - 18.0 + let shadowBlur: CGFloat = 5.0 + let verticalInset: CGFloat = 13.0 + let rowHeight: CGFloat = 30.0 + let rowSpacing: CGFloat = itemSpacing + + let columnCount = min(6, self.items.count) + let contentWidth = CGFloat(columnCount) * minimizedItemSize + (CGFloat(columnCount) - 1.0) * itemSpacing + sideInset * 2.0 + let rowCount = self.items.count / columnCount + (self.items.count % columnCount == 0 ? 0 : 1) + + let expandedRowCount = self.isExpanded ? rowCount : 1 + + let contentHeight = verticalInset * 2.0 + rowHeight * CGFloat(expandedRowCount) + CGFloat(expandedRowCount - 1) * rowSpacing + + let (backgroundFrame, isLeftAligned) = self.calculateBackgroundFrame(containerSize: size, insets: insets, anchorRect: anchorRect, contentSize: CGSize(width: contentWidth, height: contentHeight)) + + transition.updateFrame(node: self.contentContainer, frame: backgroundFrame) + + for i in 0 ..< self.items.count { + let rowIndex = i / columnCount + let columnIndex = i % columnCount + let row = CGFloat(rowIndex) + let column = CGFloat(columnIndex) + + let itemSize: CGFloat = minimizedItemSize + let itemOffset: CGFloat = 0.0 + + let itemFrame = CGRect(origin: CGPoint(x: sideInset + column * (minimizedItemSize + itemSpacing) - itemOffset, y: verticalInset + row * (rowHeight + rowSpacing) + floor((rowHeight - minimizedItemSize) / 2.0) - itemOffset), size: CGSize(width: itemSize, height: itemSize)) + transition.updateFrame(node: self.itemNodes[i], frame: itemFrame, beginWithCurrentState: true) + self.itemNodes[i].updateLayout(size: CGSize(width: itemSize, height: itemSize), scale: itemSize / (maximizedItemSize + 18.0), transition: transition, displayText: false) + self.itemNodes[i].updateIsAnimating(false, animated: false) + if rowIndex != 0 || columnIndex == columnCount - 1 { + if self.isExpanded { + if self.itemNodes[i].alpha.isZero { + self.itemNodes[i].alpha = 1.0 + if transition.isAnimated { + let delayOffset: Double = 1.0 - Double(columnIndex) / Double(columnCount - 1) + self.itemNodes[i].layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4 + delayOffset * 0.32, initialVelocity: 0.0, damping: 95.0) + self.itemNodes[i].layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.05) + } + } + } else { + self.itemNodes[i].alpha = 0.0 + } + } else { + self.itemNodes[i].alpha = 1.0 + } + + if rowIndex == 0 && columnIndex == columnCount - 1 { + transition.updateFrame(node: self.disclosureButton, frame: itemFrame) + if self.isExpanded { + if self.disclosureButton.alpha.isEqual(to: 1.0) { + self.disclosureButton.alpha = 0.0 + if transition.isAnimated { + self.disclosureButton.layer.animateScale(from: 0.8, to: 0.1, duration: 0.2, removeOnCompletion: false) + self.disclosureButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in + self?.disclosureButton.layer.removeAnimation(forKey: "scale") + }) + } + } + } else { + self.disclosureButton.alpha = 1.0 + } + } + } + + let isInOverflow = backgroundFrame.maxY > anchorRect.minY + let backgroundAlpha: CGFloat = isInOverflow ? 1.0 : 0.8 + let shadowAlpha: CGFloat = isInOverflow ? 1.0 : 0.0 + transition.updateAlpha(node: self.backgroundContainerNode, alpha: backgroundAlpha) + transition.updateAlpha(node: self.backgroundShadowNode, alpha: shadowAlpha) + transition.updateAlpha(node: self.largeCircleShadowNode, alpha: shadowAlpha) + transition.updateAlpha(node: self.smallCircleShadowNode, alpha: shadowAlpha) + + transition.updateFrame(node: self.backgroundContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + + transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame.insetBy(dx: -shadowBlur, dy: -shadowBlur)) + transition.updateFrame(node: self.backgroundShadowNode, frame: backgroundFrame.insetBy(dx: -shadowBlur, dy: -shadowBlur)) + + let largeCircleFrame: CGRect + let smallCircleFrame: CGRect + if isLeftAligned { + largeCircleFrame = CGRect(origin: CGPoint(x: backgroundFrame.midX - floor(largeCircleSize / 2.0), y: backgroundFrame.maxY - largeCircleSize / 2.0), size: CGSize(width: largeCircleSize, height: largeCircleSize)) + smallCircleFrame = CGRect(origin: CGPoint(x: largeCircleFrame.maxX - 3.0, y: largeCircleFrame.maxY + 2.0), size: CGSize(width: smallCircleSize, height: smallCircleSize)) + } else { + largeCircleFrame = CGRect(origin: CGPoint(x: backgroundFrame.midX - floor(largeCircleSize / 2.0), y: backgroundFrame.maxY - largeCircleSize / 2.0), size: CGSize(width: largeCircleSize, height: largeCircleSize)) + smallCircleFrame = CGRect(origin: CGPoint(x: largeCircleFrame.minX + 3.0 - smallCircleSize, y: largeCircleFrame.maxY + 2.0), size: CGSize(width: smallCircleSize, height: smallCircleSize)) + } + + transition.updateFrame(node: self.largeCircleNode, frame: largeCircleFrame.insetBy(dx: -shadowBlur, dy: -shadowBlur)) + transition.updateFrame(node: self.largeCircleShadowNode, frame: largeCircleFrame.insetBy(dx: -shadowBlur, dy: -shadowBlur)) + transition.updateFrame(node: self.smallCircleNode, frame: smallCircleFrame.insetBy(dx: -shadowBlur, dy: -shadowBlur)) + transition.updateFrame(node: self.smallCircleShadowNode, frame: smallCircleFrame.insetBy(dx: -shadowBlur, dy: -shadowBlur)) + + if let animateInFromAnchorRect = animateInFromAnchorRect { + let springDuration: Double = 0.42 + let springDamping: CGFloat = 104.0 + + let sourceBackgroundFrame = self.calculateBackgroundFrame(containerSize: size, insets: insets, anchorRect: animateInFromAnchorRect, contentSize: CGSize(width: contentWidth, height: contentHeight)).0 + + self.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: sourceBackgroundFrame.minX - backgroundFrame.minX, y: sourceBackgroundFrame.minY - backgroundFrame.minY)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, additive: true) + } else if let animateOutToAnchorRect = animateOutToAnchorRect { + let targetBackgroundFrame = self.calculateBackgroundFrame(containerSize: size, insets: insets, anchorRect: animateOutToAnchorRect, contentSize: CGSize(width: contentWidth, height: contentHeight)).0 + + self.layer.animatePosition(from: CGPoint(), to: CGPoint(x: targetBackgroundFrame.minX - backgroundFrame.minX, y: targetBackgroundFrame.minY - backgroundFrame.minY), duration: 0.2, removeOnCompletion: false, additive: true) + } + } + + public func animateIn(from sourceAnchorRect: CGRect) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + + if let (size, insets, anchorRect) = self.validLayout { + self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: .immediate, animateInFromAnchorRect: sourceAnchorRect, animateOutToAnchorRect: nil) + } + + let smallCircleDuration: Double = 0.5 + let largeCircleDuration: Double = 0.5 + let largeCircleDelay: Double = 0.08 + let mainCircleDuration: Double = 0.5 + let mainCircleDelay: Double = 0.1 + + self.smallCircleNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: smallCircleDuration) + self.smallCircleShadowNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: smallCircleDuration) + + self.largeCircleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, delay: largeCircleDelay) + self.largeCircleNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: largeCircleDuration, delay: largeCircleDelay) + self.largeCircleShadowNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: largeCircleDuration, delay: largeCircleDelay) + + self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, delay: mainCircleDelay) + self.backgroundNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: mainCircleDuration, delay: mainCircleDelay) + self.backgroundShadowNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: mainCircleDuration, delay: mainCircleDelay) + + if let itemNode = self.itemNodes.first { + itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, delay: mainCircleDelay) + itemNode.didAppear() + itemNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: mainCircleDuration, delay: mainCircleDelay, completion: { _ in + }) + } + } + + public func animateOut(to targetAnchorRect: CGRect?, animatingOutToReaction: Bool) { + self.backgroundNode.layer.animateAlpha(from: self.backgroundNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.backgroundShadowNode.layer.animateAlpha(from: self.backgroundShadowNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.largeCircleNode.layer.animateAlpha(from: self.largeCircleNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.largeCircleShadowNode.layer.animateAlpha(from: self.largeCircleShadowNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.smallCircleNode.layer.animateAlpha(from: self.smallCircleNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.smallCircleShadowNode.layer.animateAlpha(from: self.smallCircleShadowNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + for itemNode in self.itemNodes { + itemNode.layer.animateAlpha(from: itemNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + self.disclosureButton.layer.animateAlpha(from: self.disclosureButton.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + + if let targetAnchorRect = targetAnchorRect, let (size, insets, anchorRect) = self.validLayout { + self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: .immediate, animateInFromAnchorRect: nil, animateOutToAnchorRect: targetAnchorRect) + } + } + + public func animateOutToReaction(value: String, targetEmptyNode: ASDisplayNode, targetFilledNode: ASDisplayNode, hideNode: Bool, completion: @escaping () -> Void) { + for itemNode in self.itemNodes { + switch itemNode.reaction { + case .like: + if let snapshotView = itemNode.view.snapshotContentTree(keepTransform: true), let targetSnapshotView = targetFilledNode.view.snapshotContentTree() { + targetSnapshotView.frame = self.view.convert(targetFilledNode.bounds, from: targetFilledNode.view) + itemNode.isHidden = true + self.view.addSubview(targetSnapshotView) + self.view.addSubview(snapshotView) + snapshotView.frame = itemNode.view.convert(itemNode.view.bounds, to: self.view).offsetBy(dx: 25.0, dy: 1.0) + + var completedTarget = false + let intermediateCompletion: () -> Void = { + if completedTarget { + completion() + } + } + + let targetPosition = self.view.convert(targetFilledNode.bounds.center, from: targetFilledNode.view) + let duration: Double = 0.3 + if hideNode { + targetFilledNode.isHidden = true + } + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) + targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + targetSnapshotView.layer.animateScale(from: snapshotView.bounds.width / targetSnapshotView.bounds.width, to: 0.5, duration: 0.3, removeOnCompletion: false) + + let sourcePoint = snapshotView.center + let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - 30.0) + + let x1 = sourcePoint.x + let y1 = sourcePoint.y + let x2 = midPoint.x + let y2 = midPoint.y + let x3 = targetPosition.x + let y3 = targetPosition.y + + let a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + let b = (x1 * x1 * (y2 - y3) + x3 * x3 * (y1 - y2) + x2 * x2 * (y3 - y1)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + let c = (x2 * x2 * (x3 * y1 - x1 * y3) + x2 * (x1 * x1 * y3 - x3 * x3 * y1) + x1 * x3 * (x3 - x1) * y2) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + + var keyframes: [AnyObject] = [] + for i in 0 ..< 10 { + let k = CGFloat(i) / CGFloat(10 - 1) + let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k + let y = a * x * x + b * x + c + keyframes.append(NSValue(cgPoint: CGPoint(x: x, y: y))) + } + + snapshotView.layer.animateKeyframes(values: keyframes, duration: 0.3, keyPath: "position", removeOnCompletion: false, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.hapticFeedback.tap() + } + completedTarget = true + if hideNode { + targetFilledNode.isHidden = false + targetFilledNode.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration, initialVelocity: 0.0, damping: 90.0) + targetEmptyNode.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration, initialVelocity: 0.0, damping: 90.0) + } + intermediateCompletion() + }) + targetSnapshotView.layer.animateKeyframes(values: keyframes, duration: 0.3, keyPath: "position", removeOnCompletion: false) + + snapshotView.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 0.5) / snapshotView.bounds.width, duration: 0.3, removeOnCompletion: false) + return + } + default: + break + } + } + completion() + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let contentPoint = self.contentContainer.view.convert(point, from: self.view) + if !self.disclosureButton.alpha.isZero { + if let result = self.disclosureButton.hitTest(self.disclosureButton.view.convert(point, from: self.view), with: event) { + return result + } + } + for itemNode in self.itemNodes { + if !itemNode.alpha.isZero && itemNode.frame.contains(contentPoint) { + return self.view + } + } + return nil + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + let point = recognizer.location(in: self.view) + if let reaction = self.reaction(at: point) { + self.reactionSelected?(reaction) + } + } + } + + public func reaction(at point: CGPoint) -> ReactionGestureItem? { + let contentPoint = self.contentContainer.view.convert(point, from: self.view) + for itemNode in self.itemNodes { + if !itemNode.alpha.isZero && itemNode.frame.contains(contentPoint) { + return itemNode.reaction + } + } + for itemNode in self.itemNodes { + if !itemNode.alpha.isZero && itemNode.frame.insetBy(dx: -8.0, dy: -8.0).contains(contentPoint) { + return itemNode.reaction + } + } + return nil + } + + public func setHighlightedReaction(_ value: ReactionContextItem.Reaction?) { + self.highlightedReaction = value + if let (size, insets, anchorRect) = self.validLayout { + self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) + } + } + + @objc private func disclosurePressed() { + self.isExpanded = true + if let (size, insets, anchorRect) = self.validLayout { + self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: .animated(duration: 0.3, curve: .spring), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) + } + } +} + diff --git a/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift new file mode 100644 index 0000000000..63aad1cc39 --- /dev/null +++ b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift @@ -0,0 +1,488 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import TelegramPresentationData +import AppBundle +import AnimatedStickerNode +import TelegramAnimatedStickerNode + +private func generateBubbleImage(foreground: UIColor, diameter: CGFloat, shadowBlur: CGFloat) -> UIImage? { + return generateImage(CGSize(width: diameter + shadowBlur * 2.0, height: diameter + shadowBlur * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(foreground.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + })?.stretchableImage(withLeftCapWidth: Int(diameter / 2.0 + shadowBlur / 2.0), topCapHeight: Int(diameter / 2.0 + shadowBlur / 2.0)) +} + +private func generateBubbleShadowImage(shadow: UIColor, diameter: CGFloat, shadowBlur: CGFloat) -> UIImage? { + return generateImage(CGSize(width: diameter + shadowBlur * 2.0, height: diameter + shadowBlur * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.setShadow(offset: CGSize(), blur: shadowBlur, color: shadow.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.setShadow(offset: CGSize(), blur: 1.0, color: shadow.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowBlur, y: shadowBlur), size: CGSize(width: diameter, height: diameter))) + })?.stretchableImage(withLeftCapWidth: Int(diameter / 2.0 + shadowBlur / 2.0), topCapHeight: Int(diameter / 2.0 + shadowBlur / 2.0)) +} + +private let font = Font.medium(13.0) + +final class ReactionNode: ASDisplayNode { + let reaction: ReactionGestureItem + private let textBackgroundNode: ASImageNode + private let textNode: ImmediateTextNode + private let animationNode: AnimatedStickerNode + private let imageNode: ASImageNode + private let additionalImageNode: ASImageNode + var isMaximized: Bool? + private let intrinsicSize: CGSize + private let intrinsicOffset: CGPoint + + init(account: Account, theme: PresentationTheme, reaction: ReactionGestureItem, maximizedReactionSize: CGFloat, loadFirstFrame: Bool) { + self.reaction = reaction + + self.textBackgroundNode = ASImageNode() + self.textBackgroundNode.displaysAsynchronously = false + self.textBackgroundNode.displayWithoutProcessing = true + self.textBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 20.0, color: theme.chat.serviceMessage.components.withDefaultWallpaper.dateFillFloating.withAlphaComponent(0.8)) + self.textBackgroundNode.alpha = 0.0 + + self.textNode = ImmediateTextNode() + self.textNode.displaysAsynchronously = false + self.textNode.isUserInteractionEnabled = false + + let reactionText: String = "" + + self.textNode.attributedText = NSAttributedString(string: reactionText, font: font, textColor: theme.chat.serviceMessage.dateTextColor.withWallpaper) + let textSize = self.textNode.updateLayout(CGSize(width: 200.0, height: 100.0)) + let textBackgroundSize = CGSize(width: textSize.width + 12.0, height: 20.0) + let textBackgroundFrame = CGRect(origin: CGPoint(), size: textBackgroundSize) + let textFrame = CGRect(origin: CGPoint(x: floor((textBackgroundFrame.width - textSize.width) / 2.0), y: floor((textBackgroundFrame.height - textSize.height) / 2.0)), size: textSize) + self.textBackgroundNode.frame = textBackgroundFrame + self.textNode.frame = textFrame + self.textNode.alpha = 0.0 + + self.animationNode = AnimatedStickerNode() + self.animationNode.automaticallyLoadFirstFrame = loadFirstFrame + self.animationNode.playToCompletionOnStop = true + + var intrinsicSize = CGSize(width: maximizedReactionSize + 14.0, height: maximizedReactionSize + 14.0) + + self.imageNode = ASImageNode() + self.additionalImageNode = ASImageNode() + switch reaction { + case .like: + self.intrinsicOffset = CGPoint(x: 0.0, y: 0.0) + self.imageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Reactions/ContextHeartFilled"), color: UIColor(rgb: 0xfe1512)) + case .unlike: + self.intrinsicOffset = CGPoint(x: 0.0, y: 0.0) + self.imageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Reactions/ContextHeartBrokenL"), color: UIColor(rgb: 0xfe1512)) + self.additionalImageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Reactions/ContextHeartBrokenR"), color: UIColor(rgb: 0xfe1512)) + } + + if let image = self.imageNode.image { + intrinsicSize = image.size + } + + self.intrinsicSize = intrinsicSize + + super.init() + + //self.backgroundColor = .gray + + //self.textBackgroundNode.addSubnode(self.textNode) + //self.addSubnode(self.textBackgroundNode) + + //self.addSubnode(self.animationNode) + self.addSubnode(self.imageNode) + self.addSubnode(self.additionalImageNode) + self.animationNode.updateLayout(size: self.intrinsicSize) + self.animationNode.frame = CGRect(origin: CGPoint(), size: self.intrinsicSize) + + switch reaction { + case .like: + self.imageNode.frame = CGRect(origin: CGPoint(x: -5.0, y: -5.0), size: self.intrinsicSize) + case .unlike: + self.imageNode.frame = CGRect(origin: CGPoint(x: -6.0, y: -5.0), size: self.intrinsicSize) + self.additionalImageNode.frame = CGRect(origin: CGPoint(x: -3.0, y: -5.0), size: self.intrinsicSize) + } + } + + func updateLayout(size: CGSize, scale: CGFloat, transition: ContainedViewLayoutTransition, displayText: Bool) { + /*transition.updatePosition(node: self.animationNode, position: CGPoint(x: size.width / 2.0 + self.intrinsicOffset.x * scale, y: size.height / 2.0 + self.intrinsicOffset.y * scale), beginWithCurrentState: true) + transition.updateTransformScale(node: self.animationNode, scale: scale, beginWithCurrentState: true) + transition.updatePosition(node: self.imageNode, position: CGPoint(x: size.width / 2.0 + self.intrinsicOffset.x * scale, y: size.height / 2.0 + self.intrinsicOffset.y * scale), beginWithCurrentState: true) + transition.updateTransformScale(node: self.imageNode, scale: scale, beginWithCurrentState: true) + + transition.updatePosition(node: self.textBackgroundNode, position: CGPoint(x: size.width / 2.0, y: displayText ? -24.0 : (size.height / 2.0)), beginWithCurrentState: true) + transition.updateTransformScale(node: self.textBackgroundNode, scale: displayText ? 1.0 : 0.1, beginWithCurrentState: true) + + transition.updateAlpha(node: self.textBackgroundNode, alpha: displayText ? 1.0 : 0.0, beginWithCurrentState: true) + transition.updateAlpha(node: self.textNode, alpha: displayText ? 1.0 : 0.0, beginWithCurrentState: true)*/ + } + + func updateIsAnimating(_ isAnimating: Bool, animated: Bool) { + if isAnimating { + self.animationNode.visibility = true + } else { + self.animationNode.visibility = false + } + } + + func didAppear() { + switch self.reaction { + case .like: + self.imageNode.layer.animateScale(from: 1.0, to: 1.08, duration: 0.12, delay: 0.22, removeOnCompletion: false, completion: { [weak self] _ in + guard let strongSelf = self else { + return + } + strongSelf.imageNode.layer.animateScale(from: 1.08, to: 1.0, duration: 0.12) + }) + case .unlike: + self.imageNode.layer.animatePosition(from: CGPoint(x: -2.5, y: 0.0), to: CGPoint(), duration: 0.2, delay: 0.15, additive: true) + self.additionalImageNode.layer.animatePosition(from: CGPoint(x: 2.5, y: 0.0), to: CGPoint(), duration: 0.2, delay: 0.15, additive: true) + } + } +} + +final class ReactionSelectionNode: ASDisplayNode { + private let account: Account + private let theme: PresentationTheme + private let reactions: [ReactionGestureItem] + + private let backgroundNode: ASImageNode + private let backgroundShadowNode: ASImageNode + private let bubbleNodes: [(ASImageNode, ASImageNode)] + private var reactionNodes: [ReactionNode] = [] + private var hasSelectedNode = false + + private let hapticFeedback = HapticFeedback() + + private var shadowBlur: CGFloat = 8.0 + private var minimizedReactionSize: CGFloat = 28.0 + private var smallCircleSize: CGFloat = 14.0 + + private var isRightAligned: Bool = false + + public init(account: Account, theme: PresentationTheme, reactions: [ReactionGestureItem]) { + self.account = account + self.theme = theme + self.reactions = reactions + + self.backgroundNode = ASImageNode() + self.backgroundNode.displaysAsynchronously = false + self.backgroundNode.displayWithoutProcessing = true + + self.backgroundShadowNode = ASImageNode() + self.backgroundShadowNode.displaysAsynchronously = false + self.backgroundShadowNode.displayWithoutProcessing = true + + self.bubbleNodes = (0 ..< 2).map { i -> (ASImageNode, ASImageNode) in + let imageNode = ASImageNode() + imageNode.displaysAsynchronously = false + imageNode.displayWithoutProcessing = true + + let shadowNode = ASImageNode() + shadowNode.displaysAsynchronously = false + shadowNode.displayWithoutProcessing = true + + return (imageNode, shadowNode) + } + + super.init() + + self.bubbleNodes.forEach { _, shadow in + //self.addSubnode(shadow) + } + self.addSubnode(self.backgroundShadowNode) + self.bubbleNodes.forEach { foreground, _ in + //self.addSubnode(foreground) + } + self.addSubnode(self.backgroundNode) + } + + func updateLayout(constrainedSize: CGSize, startingPoint: CGPoint, offsetFromStart: CGFloat, isInitial: Bool, touchPoint: CGPoint) { + let initialAnchorX = startingPoint.x + + var isRightAligned = false + if initialAnchorX > constrainedSize.width / 2.0 { + isRightAligned = true + } + + let reactionSideInset: CGFloat = 10.0 + let reactionSpacing: CGFloat = 6.0 + let minReactionSpacing: CGFloat = 2.0 + let minimizedReactionSize = self.minimizedReactionSize + let contentWidth: CGFloat = CGFloat(self.reactions.count) * (minimizedReactionSize) + CGFloat(self.reactions.count - 1) * reactionSpacing + reactionSideInset * 2.0 + let spaceForMaximizedReaction = CGFloat(self.reactions.count - 1) * reactionSpacing - CGFloat(self.reactions.count - 1) * minReactionSpacing + let maximizedReactionSize: CGFloat = minimizedReactionSize + spaceForMaximizedReaction + let backgroundHeight: CGFloat = floor(self.minimizedReactionSize * 1.8) + + var backgroundFrame = CGRect(origin: CGPoint(x: -shadowBlur, y: -shadowBlur), size: CGSize(width: contentWidth + shadowBlur * 2.0, height: backgroundHeight + shadowBlur * 2.0)) + if constrainedSize.width > 500.0 { + backgroundFrame = backgroundFrame.offsetBy(dx: constrainedSize.width - contentWidth - 44.0, dy: startingPoint.y - backgroundHeight - 12.0) + } else { + backgroundFrame = backgroundFrame.offsetBy(dx: floor((constrainedSize.width - contentWidth) / 2.0), dy: startingPoint.y - backgroundHeight - 12.0) + } + backgroundFrame.origin.x = max(0.0, backgroundFrame.minX) + backgroundFrame.origin.x = min(constrainedSize.width - backgroundFrame.width, backgroundFrame.minX) + + let anchorMinX = backgroundFrame.minX + shadowBlur + backgroundHeight / 2.0 + let anchorMaxX = backgroundFrame.maxX - shadowBlur - backgroundHeight / 2.0 + let anchorX = max(anchorMinX, min(anchorMaxX, offsetFromStart)) + + var maximizedIndex = -1 + /*if let reaction = self.reactions.last, case .reply = reaction { + maximizedIndex = self.reactions.count - 1 + }*/ + if backgroundFrame.insetBy(dx: -10.0, dy: -10.0).offsetBy(dx: 0.0, dy: 10.0).contains(touchPoint) { + maximizedIndex = Int(((touchPoint.x - anchorMinX) / (anchorMaxX - anchorMinX)) * CGFloat(self.reactionNodes.count)) + maximizedIndex = max(0, min(self.reactionNodes.count - 1, maximizedIndex)) + } + + let interReactionSpacing: CGFloat + if maximizedIndex != -1 { + interReactionSpacing = minReactionSpacing + } else { + interReactionSpacing = reactionSpacing + } + + if isInitial && self.reactionNodes.isEmpty { + self.shadowBlur = floor(minimizedReactionSize * 0.26) + self.smallCircleSize = 14.0 + + self.backgroundNode.image = generateBubbleImage(foreground: .white, diameter: backgroundHeight, shadowBlur: self.shadowBlur) + self.backgroundShadowNode.image = generateBubbleShadowImage(shadow: UIColor(white: 0.0, alpha: 0.2), diameter: backgroundHeight, shadowBlur: self.shadowBlur) + for i in 0 ..< self.bubbleNodes.count { + self.bubbleNodes[i].0.image = generateBubbleImage(foreground: .white, diameter: CGFloat(i + 1) * self.smallCircleSize, shadowBlur: self.shadowBlur) + self.bubbleNodes[i].1.image = generateBubbleShadowImage(shadow: UIColor(white: 0.0, alpha: 0.2), diameter: CGFloat(i + 1) * self.smallCircleSize, shadowBlur: self.shadowBlur) + } + + self.reactionNodes = self.reactions.map { reaction -> ReactionNode in + return ReactionNode(account: self.account, theme: self.theme, reaction: reaction, maximizedReactionSize: maximizedReactionSize - 12.0, loadFirstFrame: true) + } + self.reactionNodes.forEach(self.addSubnode(_:)) + } + + let minimizedReactionVerticalInset: CGFloat = floor((backgroundHeight - minimizedReactionSize) / 2.0) + + + /*if maximizedIndex == -1 { + backgroundFrame.size.width -= maximizedReactionSize - minimizedReactionSize + backgroundFrame.origin.x += maximizedReactionSize - minimizedReactionSize + }*/ + + self.isRightAligned = isRightAligned + + let backgroundTransition: ContainedViewLayoutTransition + if isInitial { + backgroundTransition = .immediate + } else { + backgroundTransition = .animated(duration: 0.18, curve: .easeInOut) + } + backgroundTransition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) + backgroundTransition.updateFrame(node: self.backgroundShadowNode, frame: backgroundFrame) + + var reactionX: CGFloat = backgroundFrame.minX + shadowBlur + reactionSideInset + if maximizedIndex != -1 { + self.hasSelectedNode = false + } else { + self.hasSelectedNode = true + } + + for iterationIndex in 0 ..< self.reactionNodes.count { + var i = iterationIndex + let isMaximized = i == maximizedIndex + if !isRightAligned { + i = self.reactionNodes.count - 1 - i + } + + let reactionSize: CGFloat + if isMaximized { + reactionSize = maximizedReactionSize + } else { + reactionSize = minimizedReactionSize + } + + let transition: ContainedViewLayoutTransition + if isInitial { + transition = .immediate + } else { + transition = .animated(duration: 0.18, curve: .easeInOut) + } + + if self.reactionNodes[i].isMaximized != isMaximized { + self.reactionNodes[i].isMaximized = isMaximized + self.reactionNodes[i].updateIsAnimating(isMaximized, animated: !isInitial) + if isMaximized && !isInitial { + self.hapticFeedback.tap() + } + } + + var reactionFrame = CGRect(origin: CGPoint(x: reactionX, y: backgroundFrame.maxY - shadowBlur - minimizedReactionVerticalInset - reactionSize), size: CGSize(width: reactionSize, height: reactionSize)) + if isMaximized { + reactionFrame.origin.x -= 7.0 + reactionFrame.size.width += 14.0 + } + self.reactionNodes[i].updateLayout(size: reactionFrame.size, scale: reactionFrame.size.width / (maximizedReactionSize + 14.0), transition: transition, displayText: isMaximized) + + transition.updateFrame(node: self.reactionNodes[i], frame: reactionFrame, beginWithCurrentState: true) + + reactionX += reactionSize + interReactionSpacing + } + + let mainBubbleFrame = CGRect(origin: CGPoint(x: anchorX - self.smallCircleSize - shadowBlur, y: backgroundFrame.maxY - shadowBlur - self.smallCircleSize - shadowBlur), size: CGSize(width: self.smallCircleSize * 2.0 + shadowBlur * 2.0, height: self.smallCircleSize * 2.0 + shadowBlur * 2.0)) + self.bubbleNodes[1].0.frame = mainBubbleFrame + self.bubbleNodes[1].1.frame = mainBubbleFrame + + let secondaryBubbleFrame = CGRect(origin: CGPoint(x: mainBubbleFrame.midX - 10.0 - (self.smallCircleSize + shadowBlur * 2.0) / 2.0, y: mainBubbleFrame.midY + 10.0 - (self.smallCircleSize + shadowBlur * 2.0) / 2.0), size: CGSize(width: self.smallCircleSize + shadowBlur * 2.0, height: self.smallCircleSize + shadowBlur * 2.0)) + self.bubbleNodes[0].0.frame = secondaryBubbleFrame + self.bubbleNodes[0].1.frame = secondaryBubbleFrame + } + + func animateIn() { + self.bubbleNodes[1].0.layer.animateScale(from: 0.01, to: 1.0, duration: 0.11, delay: 0.0, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) + self.bubbleNodes[1].1.layer.animateScale(from: 0.01, to: 1.0, duration: 0.11, delay: 0.0, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) + + self.bubbleNodes[0].0.layer.animateScale(from: 0.01, to: 1.0, duration: 0.11, delay: 0.05, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) + self.bubbleNodes[0].1.layer.animateScale(from: 0.01, to: 1.0, duration: 0.11, delay: 0.05, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) + + let backgroundOffset: CGPoint + if self.isRightAligned { + backgroundOffset = CGPoint(x: (self.backgroundNode.frame.width - shadowBlur) / 2.0 - 42.0, y: 10.0) + } else { + backgroundOffset = CGPoint(x: -(self.backgroundNode.frame.width - shadowBlur) / 2.0 + 42.0, y: 10.0) + } + let damping: CGFloat = 100.0 + + for i in 0 ..< self.reactionNodes.count { + let animationOffset: Double = 1.0 - Double(i) / Double(self.reactionNodes.count - 1) + var nodeOffset: CGPoint + if self.isRightAligned { + nodeOffset = CGPoint(x: self.reactionNodes[i].frame.minX - (self.backgroundNode.frame.maxX - shadowBlur) / 2.0 - 42.0, y: 10.0) + } else { + nodeOffset = CGPoint(x: self.reactionNodes[i].frame.minX - (self.backgroundNode.frame.minX + shadowBlur) / 2.0 - 42.0, y: 10.0) + } + nodeOffset.x = 0.0 + nodeOffset.y = 30.0 + self.reactionNodes[i].layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.04, delay: animationOffset * 0.1) + self.reactionNodes[i].layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, delay: animationOffset * 0.1, initialVelocity: 0.0, damping: damping) + //self.reactionNodes[i].layer.animateSpring(from: NSValue(cgPoint: nodeOffset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.5, delay: animationOffset * 0.1, initialVelocity: 0.0, damping: damping, additive: true) + } + + self.backgroundNode.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 0.0, damping: damping) + self.backgroundNode.layer.animateSpring(from: NSValue(cgPoint: backgroundOffset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.5, initialVelocity: 0.0, damping: damping, additive: true) + self.backgroundShadowNode.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 0.0, damping: damping) + self.backgroundShadowNode.layer.animateSpring(from: NSValue(cgPoint: backgroundOffset), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.5, initialVelocity: 0.0, damping: damping, additive: true) + } + + func animateOut(into targetNode: ASDisplayNode?, hideTarget: Bool, completion: @escaping () -> Void) { + self.hapticFeedback.prepareTap() + + var completedContainer = false + var completedTarget = true + + let intermediateCompletion: () -> Void = { + if completedContainer && completedTarget { + completion() + } + } + + if let targetNode = targetNode { + for i in 0 ..< self.reactionNodes.count { + if let isMaximized = self.reactionNodes[i].isMaximized, isMaximized { + targetNode.recursivelyEnsureDisplaySynchronously(true) + if let snapshotView = self.reactionNodes[i].view.snapshotContentTree(), let targetSnapshotView = targetNode.view.snapshotContentTree() { + targetSnapshotView.frame = self.view.convert(targetNode.bounds, from: targetNode.view) + self.reactionNodes[i].isHidden = true + self.view.addSubview(targetSnapshotView) + self.view.addSubview(snapshotView) + completedTarget = false + let targetPosition = self.view.convert(targetNode.bounds.center, from: targetNode.view) + let duration: Double = 0.3 + if hideTarget { + targetNode.isHidden = true + } + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) + targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + targetSnapshotView.layer.animateScale(from: snapshotView.bounds.width / targetSnapshotView.bounds.width, to: 0.5, duration: 0.3, removeOnCompletion: false) + + + let sourcePoint = snapshotView.center + let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - 30.0) + + let x1 = sourcePoint.x + let y1 = sourcePoint.y + let x2 = midPoint.x + let y2 = midPoint.y + let x3 = targetPosition.x + let y3 = targetPosition.y + + let a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + let b = (x1 * x1 * (y2 - y3) + x3 * x3 * (y1 - y2) + x2 * x2 * (y3 - y1)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + let c = (x2 * x2 * (x3 * y1 - x1 * y3) + x2 * (x1 * x1 * y3 - x3 * x3 * y1) + x1 * x3 * (x3 - x1) * y2) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) + + var keyframes: [AnyObject] = [] + for i in 0 ..< 10 { + let k = CGFloat(i) / CGFloat(10 - 1) + let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k + let y = a * x * x + b * x + c + keyframes.append(NSValue(cgPoint: CGPoint(x: x, y: y))) + } + + snapshotView.layer.animateKeyframes(values: keyframes, duration: 0.3, keyPath: "position", removeOnCompletion: false, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.hapticFeedback.tap() + } + completedTarget = true + if hideTarget { + targetNode.isHidden = false + targetNode.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration, initialVelocity: 0.0, damping: 90.0) + } + intermediateCompletion() + }) + targetSnapshotView.layer.animateKeyframes(values: keyframes, duration: 0.3, keyPath: "position", removeOnCompletion: false) + + snapshotView.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 0.5) / snapshotView.bounds.width, duration: 0.3, removeOnCompletion: false) + } + break + } + } + } + + //self.backgroundNode.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + self.backgroundShadowNode.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.backgroundShadowNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in + completedContainer = true + intermediateCompletion() + }) + for (node, shadow) in self.bubbleNodes { + node.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + node.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + shadow.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + shadow.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + for i in 0 ..< self.reactionNodes.count { + self.reactionNodes[i].layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + self.reactionNodes[i].layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + } + + func selectedReaction() -> ReactionGestureItem? { + for i in 0 ..< self.reactionNodes.count { + if let isMaximized = self.reactionNodes[i].isMaximized, isMaximized { + return self.reactionNodes[i].reaction + } + } + return nil + } +} + + diff --git a/submodules/ReactionSelectionNode/Sources/ReactionSelectionParentNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionSelectionParentNode.swift new file mode 100644 index 0000000000..7a1a0c9498 --- /dev/null +++ b/submodules/ReactionSelectionNode/Sources/ReactionSelectionParentNode.swift @@ -0,0 +1,87 @@ +/*import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import TelegramPresentationData + +public final class ReactionSelectionParentNode: ASDisplayNode { + private let account: Account + private let theme: PresentationTheme + + private var currentNode: ReactionSelectionNode? + private var currentLocation: (CGPoint, CGFloat, CGPoint)? + + private var validLayout: (size: CGSize, insets: UIEdgeInsets)? + + public init(account: Account, theme: PresentationTheme) { + self.account = account + self.theme = theme + + super.init() + } + + func displayReactions(_ reactions: [ReactionGestureItem], at point: CGPoint, touchPoint: CGPoint) { + if let currentNode = self.currentNode { + currentNode.removeFromSupernode() + self.currentNode = nil + } + + let reactionNode = ReactionSelectionNode(account: self.account, theme: self.theme, reactions: reactions) + self.addSubnode(reactionNode) + self.currentNode = reactionNode + self.currentLocation = (point, point.x, touchPoint) + + if let (size, insets) = self.validLayout { + self.update(size: size, insets: insets, isInitial: true) + + reactionNode.animateIn() + } + } + + func selectedReaction() -> ReactionGestureItem? { + if let currentNode = self.currentNode { + return currentNode.selectedReaction() + } + return nil + } + + func dismissReactions(into targetNode: ASDisplayNode?, hideTarget: Bool) { + if let currentNode = self.currentNode { + currentNode.animateOut(into: targetNode, hideTarget: hideTarget, completion: { [weak currentNode] in + currentNode?.removeFromSupernode() + }) + self.currentNode = nil + } + } + + func updateReactionsAnchor(point: CGPoint, touchPoint: CGPoint) { + if let (currentPoint, _, _) = self.currentLocation { + self.currentLocation = (currentPoint, point.x, touchPoint) + + if let (size, insets) = self.validLayout { + self.update(size: size, insets: insets, isInitial: false) + } + } + } + + public func updateLayout(size: CGSize, insets: UIEdgeInsets, transition: ContainedViewLayoutTransition) { + self.validLayout = (size, insets) + + self.update(size: size, insets: insets, isInitial: false) + } + + private func update(size: CGSize, insets: UIEdgeInsets, isInitial: Bool) { + if let currentNode = self.currentNode, let (point, offset, touchPoint) = self.currentLocation { + currentNode.updateLayout(constrainedSize: size, startingPoint: CGPoint(x: size.width - 32.0, y: point.y), offsetFromStart: offset, isInitial: isInitial, touchPoint: touchPoint) + currentNode.frame = CGRect(origin: CGPoint(), size: size) + } + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return nil + } +} + + +*/ diff --git a/submodules/TelegramCore/Sources/Account/Account.swift b/submodules/TelegramCore/Sources/Account/Account.swift index 159651cf71..b9b4ec40c2 100644 --- a/submodules/TelegramCore/Sources/Account/Account.swift +++ b/submodules/TelegramCore/Sources/Account/Account.swift @@ -1045,6 +1045,7 @@ public class Account { self.managedOperationsDisposable.add(managedSynchronizeConsumeMessageContentOperations(postbox: self.postbox, network: self.network, stateManager: self.stateManager).start()) self.managedOperationsDisposable.add(managedConsumePersonalMessagesActions(postbox: self.postbox, network: self.network, stateManager: self.stateManager).start()) self.managedOperationsDisposable.add(managedSynchronizeMarkAllUnseenPersonalMessagesOperations(postbox: self.postbox, network: self.network, stateManager: self.stateManager).start()) + self.managedOperationsDisposable.add(managedApplyPendingMessageReactionsActions(postbox: self.postbox, network: self.network, stateManager: self.stateManager).start()) self.managedOperationsDisposable.add(managedSynchronizeEmojiKeywordsOperations(postbox: self.postbox, network: self.network).start()) self.managedOperationsDisposable.add(managedApplyPendingScheduledMessagesActions(postbox: self.postbox, network: self.network, stateManager: self.stateManager).start()) diff --git a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift index b610a5dd6b..99c1264e63 100644 --- a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift +++ b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift @@ -258,6 +258,10 @@ struct AccountMutableState { self.addOperation(.UpdateMessagePoll(id, poll, results)) } + /*mutating func updateMessageReactions(_ messageId: MessageId, reactions: Api.MessageReactions) { + self.addOperation(.UpdateMessageReactions(messageId, reactions)) + }*/ + mutating func updateMedia(_ id: MediaId, media: Media?) { self.addOperation(.UpdateMedia(id, media)) } diff --git a/submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift b/submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift new file mode 100644 index 0000000000..0fee33b4cc --- /dev/null +++ b/submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift @@ -0,0 +1,102 @@ +import Foundation +import Postbox +import TelegramApi + + +/*extension ReactionsMessageAttribute { + func withUpdatedResults(_ reactions: Api.MessageReactions) -> ReactionsMessageAttribute { + switch reactions { + case let .messageReactions(flags, results): + let min = (flags & (1 << 0)) != 0 + var reactions = results.map { result -> MessageReaction in + switch result { + case let .reactionCount(flags, reaction, count): + return MessageReaction(value: reaction, count: count, isSelected: (flags & (1 << 0)) != 0) + } + } + if min { + var currentSelectedReaction: String? + for reaction in self.reactions { + if reaction.isSelected { + currentSelectedReaction = reaction.value + break + } + } + if let currentSelectedReaction = currentSelectedReaction { + for i in 0 ..< reactions.count { + if reactions[i].value == currentSelectedReaction { + reactions[i].isSelected = true + } + } + } + } + return ReactionsMessageAttribute(reactions: reactions) + } + } +}*/ + +public func mergedMessageReactions(attributes: [MessageAttribute]) -> ReactionsMessageAttribute? { + var current: ReactionsMessageAttribute? + var pending: PendingReactionsMessageAttribute? + for attribute in attributes { + if let attribute = attribute as? ReactionsMessageAttribute { + current = attribute + } else if let attribute = attribute as? PendingReactionsMessageAttribute { + pending = attribute + } + } + + if let pending = pending { + var reactions = current?.reactions ?? [] + if let value = pending.value { + var found = false + for i in 0 ..< reactions.count { + if reactions[i].value == value { + found = true + if !reactions[i].isSelected { + reactions[i].isSelected = true + reactions[i].count += 1 + } + } + } + if !found { + reactions.append(MessageReaction(value: value, count: 1, isSelected: true)) + } + } + for i in (0 ..< reactions.count).reversed() { + if reactions[i].isSelected, pending.value != reactions[i].value { + if reactions[i].count == 1 { + reactions.remove(at: i) + } else { + reactions[i].isSelected = false + reactions[i].count -= 1 + } + } + } + if !reactions.isEmpty { + return ReactionsMessageAttribute(reactions: reactions) + } else { + return nil + } + } else if let current = current { + return current + } else { + return nil + } +} + +/*extension ReactionsMessageAttribute { + convenience init(apiReactions: Api.MessageReactions) { + switch apiReactions { + case let .messageReactions(_, results): + self.init(reactions: results.map { result in + switch result { + case let .reactionCount(flags, reaction, count): + return MessageReaction(value: reaction, count: count, isSelected: (flags & (1 << 0)) != 0) + } + }) + } + } +} +*/ + diff --git a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift index df871f29b4..97d1b6a70d 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift @@ -545,6 +545,10 @@ extension StoreMessage { if (flags & (1 << 17)) != 0 { attributes.append(ContentRequiresValidationMessageAttribute()) } + + /*if let reactions = reactions { + attributes.append(ReactionsMessageAttribute(apiReactions: reactions)) + }*/ if let replies = replies { let recentRepliersPeerIds: [PeerId]? diff --git a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift index 81667079b8..b2b667e250 100644 --- a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift @@ -812,6 +812,89 @@ public final class AccountViewTracker { } } + public func updateReactionsForMessageIds(messageIds: Set) { + /*self.queue.async { + var addedMessageIds: [MessageId] = [] + let timestamp = Int32(CFAbsoluteTimeGetCurrent()) + for messageId in messageIds { + let messageTimestamp = self.updatedReactionsMessageIdsAndTimestamps[messageId] + if messageTimestamp == nil || messageTimestamp! < timestamp - 5 * 60 { + self.updatedReactionsMessageIdsAndTimestamps[messageId] = timestamp + addedMessageIds.append(messageId) + } + } + if !addedMessageIds.isEmpty { + for (peerId, messageIds) in messagesIdsGroupedByPeerId(Set(addedMessageIds)) { + let disposableId = self.nextUpdatedReactionsDisposableId + self.nextUpdatedReactionsDisposableId += 1 + + if let account = self.account { + let signal = (account.postbox.transaction { transaction -> Signal in + if let peer = transaction.getPeer(peerId), let inputPeer = apiInputPeer(peer) { + return account.network.request(Api.functions.messages.getMessagesReactions(peer: inputPeer, id: messageIds.map { $0.id })) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { updates -> Signal in + guard let updates = updates else { + return .complete() + } + return account.postbox.transaction { transaction -> Void in + let updateList: [Api.Update] + switch updates { + case let .updates(updates, _, _, _, _): + updateList = updates + case let .updatesCombined(updates, _, _, _, _, _): + updateList = updates + case let .updateShort(update, _): + updateList = [update] + default: + updateList = [] + } + for update in updateList { + switch update { + case let .updateMessageReactions(peer, msgId, reactions): + transaction.updateMessage(MessageId(peerId: peer.peerId, namespace: Namespaces.Message.Cloud, id: msgId), update: { currentMessage in + + let updatedReactions = ReactionsMessageAttribute(apiReactions: reactions) + + let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) + var attributes = currentMessage.attributes + loop: for j in 0 ..< attributes.count { + if let attribute = attributes[j] as? ReactionsMessageAttribute { + if updatedReactions.reactions == attribute.reactions { + return .skip + } + attributes[j] = updatedReactions + break loop + } + } + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + default: + break + } + } + } + } + } else { + return .complete() + } + } + |> switchToLatest) + |> afterDisposed { [weak self] in + self?.queue.async { + self?.updatedReactionsDisposables.set(nil, forKey: disposableId) + } + } + self.updatedReactionsDisposables.set(signal.start(), forKey: disposableId) + } + } + } + }*/ + } + public func updateSeenLiveLocationForMessageIds(messageIds: Set) { self.queue.async { var addedMessageIds: [MessageId] = [] diff --git a/submodules/TelegramCore/Sources/State/MessageReactions.swift b/submodules/TelegramCore/Sources/State/MessageReactions.swift new file mode 100644 index 0000000000..4f0ab70948 --- /dev/null +++ b/submodules/TelegramCore/Sources/State/MessageReactions.swift @@ -0,0 +1,228 @@ +import Foundation +import Postbox +import SwiftSignalKit +import TelegramApi +import MtProtoKit + + +public func updateMessageReactionsInteractively(postbox: Postbox, messageId: MessageId, reaction: String?) -> Signal { + return postbox.transaction { transaction -> Void in + transaction.setPendingMessageAction(type: .updateReaction, id: messageId, action: UpdateMessageReactionsAction()) + transaction.updateMessage(messageId, update: { currentMessage in + var storeForwardInfo: StoreMessageForwardInfo? + if let forwardInfo = currentMessage.forwardInfo { + storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) + } + var attributes = currentMessage.attributes + loop: for j in 0 ..< attributes.count { + if let _ = attributes[j] as? PendingReactionsMessageAttribute { + attributes.remove(at: j) + break loop + } + } + attributes.append(PendingReactionsMessageAttribute(value: reaction)) + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + } + |> ignoreValues +} + +private enum RequestUpdateMessageReactionError { + case generic +} + +private func requestUpdateMessageReaction(postbox: Postbox, network: Network, stateManager: AccountStateManager, messageId: MessageId) -> Signal { + return .complete() + /*return postbox.transaction { transaction -> (Peer, String?)? in + guard let peer = transaction.getPeer(messageId.peerId) else { + return nil + } + guard let message = transaction.getMessage(messageId) else { + return nil + } + var value: String? + for attribute in message.attributes { + if let attribute = attribute as? PendingReactionsMessageAttribute { + value = attribute.value + break + } + } + return (peer, value) + } + |> castError(RequestUpdateMessageReactionError.self) + |> mapToSignal { peerAndValue in + guard let (peer, value) = peerAndValue else { + return .fail(.generic) + } + guard let inputPeer = apiInputPeer(peer) else { + return .fail(.generic) + } + 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)) + |> mapError { _ -> RequestUpdateMessageReactionError in + return .generic + } + |> mapToSignal { result -> Signal in + return postbox.transaction { transaction -> Void in + transaction.setPendingMessageAction(type: .updateReaction, id: messageId, action: UpdateMessageReactionsAction()) + transaction.updateMessage(messageId, update: { currentMessage in + var storeForwardInfo: StoreMessageForwardInfo? + if let forwardInfo = currentMessage.forwardInfo { + storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) + } + let reactions = mergedMessageReactions(attributes: currentMessage.attributes) + var attributes = currentMessage.attributes + for j in (0 ..< attributes.count).reversed() { + if attributes[j] is PendingReactionsMessageAttribute || attributes[j] is ReactionsMessageAttribute { + attributes.remove(at: j) + } + } + if let reactions = reactions { + attributes.append(reactions) + } + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + stateManager.addUpdates(result) + } + |> castError(RequestUpdateMessageReactionError.self) + |> ignoreValues + } + }*/ +} + +private final class ManagedApplyPendingMessageReactionsActionsHelper { + var operationDisposables: [MessageId: Disposable] = [:] + + func update(entries: [PendingMessageActionsEntry]) -> (disposeOperations: [Disposable], beginOperations: [(PendingMessageActionsEntry, MetaDisposable)]) { + var disposeOperations: [Disposable] = [] + var beginOperations: [(PendingMessageActionsEntry, MetaDisposable)] = [] + + var hasRunningOperationForPeerId = Set() + var validIds = Set() + for entry in entries { + if !hasRunningOperationForPeerId.contains(entry.id.peerId) { + hasRunningOperationForPeerId.insert(entry.id.peerId) + validIds.insert(entry.id) + + if self.operationDisposables[entry.id] == nil { + let disposable = MetaDisposable() + beginOperations.append((entry, disposable)) + self.operationDisposables[entry.id] = disposable + } + } + } + + var removeMergedIds: [MessageId] = [] + for (id, disposable) in self.operationDisposables { + if !validIds.contains(id) { + removeMergedIds.append(id) + disposeOperations.append(disposable) + } + } + + for id in removeMergedIds { + self.operationDisposables.removeValue(forKey: id) + } + + return (disposeOperations, beginOperations) + } + + func reset() -> [Disposable] { + let disposables = Array(self.operationDisposables.values) + self.operationDisposables.removeAll() + return disposables + } +} + +private func withTakenAction(postbox: Postbox, type: PendingMessageActionType, id: MessageId, _ f: @escaping (Transaction, PendingMessageActionsEntry?) -> Signal) -> Signal { + return postbox.transaction { transaction -> Signal in + var result: PendingMessageActionsEntry? + + if let action = transaction.getPendingMessageAction(type: type, id: id) as? UpdateMessageReactionsAction { + result = PendingMessageActionsEntry(id: id, action: action) + } + + return f(transaction, result) + } + |> switchToLatest +} + +func managedApplyPendingMessageReactionsActions(postbox: Postbox, network: Network, stateManager: AccountStateManager) -> Signal { + return Signal { _ in + let helper = Atomic(value: ManagedApplyPendingMessageReactionsActionsHelper()) + + let actionsKey = PostboxViewKey.pendingMessageActions(type: .updateReaction) + let disposable = postbox.combinedView(keys: [actionsKey]).start(next: { view in + var entries: [PendingMessageActionsEntry] = [] + if let v = view.views[actionsKey] as? PendingMessageActionsView { + entries = v.entries + } + + let (disposeOperations, beginOperations) = helper.with { helper -> (disposeOperations: [Disposable], beginOperations: [(PendingMessageActionsEntry, MetaDisposable)]) in + return helper.update(entries: entries) + } + + for disposable in disposeOperations { + disposable.dispose() + } + + for (entry, disposable) in beginOperations { + let signal = withTakenAction(postbox: postbox, type: .updateReaction, id: entry.id, { transaction, entry -> Signal in + if let entry = entry { + if let _ = entry.action as? UpdateMessageReactionsAction { + return synchronizeMessageReactions(transaction: transaction, postbox: postbox, network: network, stateManager: stateManager, id: entry.id) + } else { + assertionFailure() + } + } + return .complete() + }) + |> then( + postbox.transaction { transaction -> Void in + transaction.setPendingMessageAction(type: .updateReaction, id: entry.id, action: nil) + } + |> ignoreValues + ) + + disposable.set(signal.start()) + } + }) + + return ActionDisposable { + let disposables = helper.with { helper -> [Disposable] in + return helper.reset() + } + for disposable in disposables { + disposable.dispose() + } + disposable.dispose() + } + } +} + +private func synchronizeMessageReactions(transaction: Transaction, postbox: Postbox, network: Network, stateManager: AccountStateManager, id: MessageId) -> Signal { + return requestUpdateMessageReaction(postbox: postbox, network: network, stateManager: stateManager, messageId: id) + |> `catch` { _ -> Signal in + return postbox.transaction { transaction -> Void in + transaction.setPendingMessageAction(type: .updateReaction, id: id, action: nil) + transaction.updateMessage(id, update: { currentMessage in + var storeForwardInfo: StoreMessageForwardInfo? + if let forwardInfo = currentMessage.forwardInfo { + storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) + } + var attributes = currentMessage.attributes + loop: for j in 0 ..< attributes.count { + if let _ = attributes[j] as? PendingReactionsMessageAttribute { + attributes.remove(at: j) + break loop + } + } + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + } + |> ignoreValues + } +} + diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift index 4a1898a5c5..e281a8614f 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift @@ -1,13 +1,42 @@ import Postbox -public final class ReactionsMessageAttribute: MessageAttribute { - public init() { +public struct MessageReaction: Equatable, PostboxCoding { + public var value: String + public var count: Int32 + public var isSelected: Bool + + public init(value: String, count: Int32, isSelected: Bool) { + self.value = value + self.count = count + self.isSelected = isSelected } - required public init(decoder: PostboxDecoder) { + public init(decoder: PostboxDecoder) { + self.value = decoder.decodeStringForKey("v", orElse: "") + self.count = decoder.decodeInt32ForKey("c", orElse: 0) + self.isSelected = decoder.decodeInt32ForKey("s", orElse: 0) != 0 } public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(self.value, forKey: "v") + encoder.encodeInt32(self.count, forKey: "c") + encoder.encodeInt32(self.isSelected ? 1 : 0, forKey: "s") + } +} + +public final class ReactionsMessageAttribute: MessageAttribute { + public let reactions: [MessageReaction] + + public init(reactions: [MessageReaction]) { + self.reactions = reactions + } + + required public init(decoder: PostboxDecoder) { + self.reactions = decoder.decodeObjectArrayWithDecoderForKey("r") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeObjectArray(self.reactions, forKey: "r") } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift index d8dd414845..d62a62be31 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift @@ -517,7 +517,7 @@ public extension TelegramEngine { } } - var results: [(EnginePeer, Int32)] = [] + var results: [(EnginePeer, PeerGroupId, ChatListIndex)] = [] for listId in peerIds { guard let peer = transaction.getPeer(listId) else { @@ -532,14 +532,14 @@ public extension TelegramEngine { guard let readState = transaction.getCombinedPeerReadState(channel.id), readState.count != 0 else { continue } - guard let topMessageIndex = transaction.getTopPeerMessageIndex(peerId: channel.id) else { + guard let (groupId, index) = transaction.getPeerChatListIndex(channel.id) else { continue } - results.append((EnginePeer(channel), topMessageIndex.timestamp)) + results.append((EnginePeer(channel), groupId, index)) } - results.sort(by: { $0.1 > $1.1 }) + results.sort(by: { $0.2 > $1.2 }) if let peer = results.first?.0 { let unreadCount: Int32 = transaction.getCombinedPeerReadState(peer.id)?.count ?? 0 diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 266ee16fe8..e4b7064e6f 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -64,6 +64,8 @@ import UniversalMediaPlayer import WallpaperBackgroundNode import ChatListUI import CalendarMessageScreen +import ReactionSelectionNode +import LottieMeshSwift #if DEBUG import os.signpost @@ -964,8 +966,102 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G actions.tip = tip } + actions.context = strongSelf.context + + var hasLike = false + let hearts: [String] = ["❤", "❤️"] + for attribute in messages[0].attributes { + if let attribute = attribute as? ReactionsMessageAttribute { + for reaction in attribute.reactions { + if hearts.contains(reaction.value) { + if reaction.isSelected { + hasLike = true + } + } + } + } else if let attribute = attribute as? PendingReactionsMessageAttribute { + if let value = attribute.value, hearts.contains(value) { + hasLike = true + } + } + } + + actions.reactionItems = [ReactionContextItem(reaction: hasLike ? .unlike : .like)] + let controller = ContextController(account: strongSelf.context.account, presentationData: strongSelf.presentationData, source: .extracted(ChatMessageContextExtractedContentSource(chatNode: strongSelf.chatDisplayNode, postbox: strongSelf.context.account.postbox, message: message, selectAll: selectAll)), items: .single(actions), recognizer: recognizer, gesture: gesture) strongSelf.currentContextController = controller + + let _ = strongSelf.context.meshAnimationCache.get(bundleName: "Hearts") + + controller.reactionSelected = { [weak controller] value in + guard let strongSelf = self, let message = updatedMessages.first else { + return + } + let hearts: [String] = ["❤", "❤️"] + strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item { + if item.message.id == message.id { + switch value { + case .like: + itemNode.awaitingAppliedReaction = (hearts[0], { [weak itemNode] in + guard let controller = controller else { + return + } + if let itemNode = itemNode, let (targetEmptyNode, targetFilledNode) = itemNode.targetReactionNode(value: hearts[0]) { + controller.dismissWithReaction(value: hearts[0], targetEmptyNode: targetEmptyNode, targetFilledNode: targetFilledNode, hideNode: true, completion: { [weak itemNode, weak targetFilledNode] in + guard let strongSelf = self, let itemNode = itemNode, let targetFilledNode = targetFilledNode else { + return + } + + let targetFrame = targetFilledNode.view.convert(targetFilledNode.bounds, to: itemNode.view).offsetBy(dx: 0.0, dy: itemNode.insets.top) + + if #available(iOS 13.0, *), let meshAnimation = strongSelf.context.meshAnimationCache.get(bundleName: "Hearts") { + if let animationView = MeshRenderer() { + let animationFrame = CGRect(origin: CGPoint(x: targetFrame.midX - 200.0 / 2.0, y: targetFrame.midY - 200.0 / 2.0), size: CGSize(width: 200.0, height: 200.0)).offsetBy(dx: -50.0, dy: 0.0) + animationView.frame = animationFrame + + var removeNode: (() -> Void)? + + animationView.allAnimationsCompleted = { + removeNode?() + } + + let overlayMeshAnimationNode = strongSelf.chatDisplayNode.messageTransitionNode.add(decorationView: animationView, itemNode: itemNode) + + removeNode = { [weak overlayMeshAnimationNode] in + guard let strongSelf = self, let overlayMeshAnimationNode = overlayMeshAnimationNode else { + return + } + strongSelf.chatDisplayNode.messageTransitionNode.remove(decorationNode: overlayMeshAnimationNode) + } + + animationView.add(mesh: meshAnimation, offset: CGPoint()) + } + } + }) + } else { + controller.dismiss() + } + }) + case .unlike: + itemNode.awaitingAppliedReaction = (nil, { + guard let controller = controller else { + return + } + controller.dismiss() + }) + } + } + } + } + switch value { + case .like: + let _ = updateMessageReactionsInteractively(postbox: strongSelf.context.account.postbox, messageId: message.id, reaction: hearts[0]).start() + case .unlike: + let _ = updateMessageReactionsInteractively(postbox: strongSelf.context.account.postbox, messageId: message.id, reaction: nil).start() + } + } + strongSelf.forEachController({ controller in if let controller = controller as? TooltipScreen { diff --git a/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift index a18486ad8e..fc486483f1 100644 --- a/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -865,6 +865,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { var edited = false var viewCount: Int? = nil var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let _ = attribute as? EditedMessageAttribute, isEmoji { edited = true @@ -884,7 +885,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { isReplyThread = true } - let (dateAndStatusSize, dateAndStatusApply) = makeDateAndStatusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) + let (dateAndStatusSize, dateAndStatusApply) = makeDateAndStatusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) var viaBotApply: (TextNodeLayout, () -> TextNode)? var replyInfoApply: (CGSize, () -> ChatMessageReplyInfoNode)? diff --git a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift index 134f4977f5..d3751d173f 100644 --- a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift @@ -322,6 +322,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { } var viewCount: Int? var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -497,6 +498,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { type: statusType, edited: edited, viewCount: viewCount, + dateReactions: dateReactions, dateReplies: dateReplies, isPinned: message.tags.contains(.pinned) && !associatedData.isInPinnedListMode && !isReplyThread, dateText: dateText @@ -617,7 +619,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { let textConstrainedSize = CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height - insets.top - insets.bottom) if let textStatusType = textStatusType { - statusSizeAndApply = statusLayout(context, presentationData, edited, viewCount, dateText, textStatusType, textConstrainedSize, dateReplies, message.tags.contains(.pinned) && !associatedData.isInPinnedListMode && !isReplyThread, message.isSelfExpiring) + statusSizeAndApply = statusLayout(context, presentationData, edited, viewCount, dateText, textStatusType, textConstrainedSize, dateReactions, dateReplies, message.tags.contains(.pinned) && !associatedData.isInPinnedListMode && !isReplyThread, message.isSelfExpiring) } var updatedAdditionalImageBadge: ChatMessageInteractiveMediaBadge? diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift index db3775ce12..01a062a262 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift @@ -210,6 +210,10 @@ class ChatMessageBubbleContentNode: ASDisplayNode { func applyAbsoluteOffsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { } + func reactionTargetNode(value: String) -> (ASDisplayNode, ASDisplayNode)? { + return nil + } + func getStatusNode() -> ASDisplayNode? { return nil } diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 3778f0d084..540cc3d71c 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -909,7 +909,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode forwardInfoLayout: (ChatPresentationData, PresentationStrings, ChatMessageForwardInfoType, Peer?, String?, String?, CGSize) -> (CGSize, (CGFloat) -> ChatMessageForwardInfoNode), replyInfoLayout: (ChatPresentationData, PresentationStrings, AccountContext, ChatMessageReplyInfoType, Message, CGSize) -> (CGSize, () -> ChatMessageReplyInfoNode), actionButtonsLayout: (AccountContext, ChatPresentationThemeData, PresentationChatBubbleCorners, PresentationStrings, ReplyMarkupMessageAttribute, Message, CGFloat) -> (minWidth: CGFloat, layout: (CGFloat) -> (CGSize, (Bool) -> ChatMessageActionButtonsNode)), - mosaicStatusLayout: (AccountContext, ChatPresentationData, Bool, Int?, String, ChatMessageDateAndStatusType, CGSize, Int, Bool, Bool) -> (CGSize, (Bool) -> ChatMessageDateAndStatusNode), + mosaicStatusLayout: (AccountContext, ChatPresentationData, Bool, Int?, String, ChatMessageDateAndStatusType, CGSize, [MessageReaction], Int, Bool, Bool) -> (CGSize, (Bool) -> ChatMessageDateAndStatusNode), layoutConstants: ChatMessageItemLayoutConstants, currentItem: ChatMessageItem?, currentForwardInfo: (Peer?, String?)?, @@ -1470,6 +1470,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } var viewCount: Int? var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -1508,7 +1509,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode isReplyThread = true } - mosaicStatusSizeAndApply = mosaicStatusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: 200.0, height: CGFloat.greatestFiniteMagnitude), dateReplies, message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, message.isSelfExpiring) + mosaicStatusSizeAndApply = mosaicStatusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: 200.0, height: CGFloat.greatestFiniteMagnitude), dateReactions, dateReplies, message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, message.isSelfExpiring) } } @@ -2682,6 +2683,46 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } strongSelf.updateSearchTextHighlightState() + + if let (_, f) = strongSelf.awaitingAppliedReaction { + /*var bounds = strongSelf.bounds + let offset = bounds.origin.x + bounds.origin.x = 0.0 + strongSelf.bounds = bounds + var shadowBounds = strongSelf.shadowNode.bounds + let shadowOffset = shadowBounds.origin.x + shadowBounds.origin.x = 0.0 + strongSelf.shadowNode.bounds = shadowBounds + if !offset.isZero { + strongSelf.layer.animateBoundsOriginXAdditive(from: offset, to: 0.0, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring) + } + if !shadowOffset.isZero { + strongSelf.shadowNode.layer.animateBoundsOriginXAdditive(from: shadowOffset, to: 0.0, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring) + } + if let swipeToReplyNode = strongSelf.swipeToReplyNode { + strongSelf.swipeToReplyNode = nil + swipeToReplyNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak swipeToReplyNode] _ in + swipeToReplyNode?.removeFromSupernode() + }) + swipeToReplyNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + } + */ + strongSelf.awaitingAppliedReaction = nil + /*var targetNode: ASDisplayNode? + var hideTarget = false + if let awaitingAppliedReaction = awaitingAppliedReaction { + for contentNode in strongSelf.contentNodes { + if let (reactionNode, count) = contentNode.reactionTargetNode(value: awaitingAppliedReaction) { + targetNode = reactionNode + hideTarget = count == 1 + break + } + } + } + strongSelf.reactionRecognizer?.complete(into: targetNode, hideTarget: hideTarget)*/ + f() + } + } override func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { @@ -3613,6 +3654,15 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode return self.mainContextSourceNode.isExtractedToContextPreview || hasWallpaper || isPreview } + override func targetReactionNode(value: String) -> (ASDisplayNode, ASDisplayNode)? { + for contentNode in self.contentNodes { + if let (emptyNode, filledNode) = contentNode.reactionTargetNode(value: value) { + return (emptyNode, filledNode) + } + } + return nil + } + func animateQuizInvalidOptionSelected() { if let supernode = self.supernode, let subnodes = supernode.subnodes { for i in 0 ..< subnodes.count { diff --git a/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift index 01024dd50e..ef68229f22 100644 --- a/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift @@ -154,6 +154,7 @@ class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { } var viewCount: Int? var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -195,7 +196,7 @@ class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { isReplyThread = true } - let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: constrainedSize.width, height: CGFloat.greatestFiniteMagnitude), dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) + let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: constrainedSize.width, height: CGFloat.greatestFiniteMagnitude), dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) statusSize = size statusApply = apply } diff --git a/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift b/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift index c341a56221..782e54c397 100644 --- a/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift @@ -40,6 +40,114 @@ enum ChatMessageDateAndStatusType: Equatable { case FreeOutgoing(ChatMessageDateAndStatusOutgoingType) } +private let reactionCountFont = Font.semibold(11.0) +private let reactionFont = Font.regular(12.0) + +private final class StatusReactionNode: ASDisplayNode { + let emptyImageNode: ASImageNode + let selectedImageNode: ASImageNode + + private var theme: PresentationTheme? + private var isSelected: Bool? + + override init() { + self.emptyImageNode = ASImageNode() + self.emptyImageNode.displaysAsynchronously = false + self.selectedImageNode = ASImageNode() + self.selectedImageNode.displaysAsynchronously = false + + super.init() + + self.addSubnode(self.emptyImageNode) + self.addSubnode(self.selectedImageNode) + } + + func update(type: ChatMessageDateAndStatusType, isSelected: Bool, count: Int, theme: PresentationTheme, wallpaper: TelegramWallpaper, animated: Bool) { + if self.theme !== theme { + self.theme = theme + + let emptyImage: UIImage? + let selectedImage: UIImage? + switch type { + case .BubbleIncoming: + emptyImage = PresentationResourcesChat.chatMessageLike(theme, incoming: true, isSelected: false) + selectedImage = PresentationResourcesChat.chatMessageLike(theme, incoming: true, isSelected: true) + case .BubbleOutgoing: + emptyImage = PresentationResourcesChat.chatMessageLike(theme, incoming: false, isSelected: false) + selectedImage = PresentationResourcesChat.chatMessageLike(theme, incoming: false, isSelected: true) + case .ImageIncoming, .ImageOutgoing: + emptyImage = PresentationResourcesChat.chatMessageMediaLike(theme, isSelected: false) + selectedImage = PresentationResourcesChat.chatMessageMediaLike(theme, isSelected: true) + case .FreeIncoming, .FreeOutgoing: + emptyImage = PresentationResourcesChat.chatMessageFreeLike(theme, wallpaper: wallpaper, isSelected: false) + selectedImage = PresentationResourcesChat.chatMessageFreeLike(theme, wallpaper: wallpaper, isSelected: true) + } + + if let emptyImage = emptyImage, let selectedImage = selectedImage { + self.emptyImageNode.image = emptyImage + self.emptyImageNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: emptyImage.size) + + self.selectedImageNode.image = selectedImage + self.selectedImageNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: selectedImage.size) + } + } + + if self.isSelected != isSelected { + let wasSelected = self.isSelected + self.isSelected = isSelected + + self.emptyImageNode.isHidden = isSelected && count <= 1 + self.selectedImageNode.isHidden = !isSelected + + if let wasSelected = wasSelected, wasSelected, !isSelected { + if let image = self.selectedImageNode.image { + let leftImage = generateImage(image.size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + image.draw(in: CGRect(origin: CGPoint(), size: size)) + UIGraphicsPopContext() + context.clear(CGRect(origin: CGPoint(x: size.width / 2.0, y: 0.0), size: CGSize(width: size.width / 2.0, height: size.height))) + }) + let rightImage = generateImage(image.size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + image.draw(in: CGRect(origin: CGPoint(), size: size)) + UIGraphicsPopContext() + context.clear(CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width / 2.0, height: size.height))) + }) + if let leftImage = leftImage, let rightImage = rightImage { + let leftView = UIImageView() + leftView.image = leftImage + leftView.frame = self.selectedImageNode.frame + let rightView = UIImageView() + rightView.image = rightImage + rightView.frame = self.selectedImageNode.frame + self.view.addSubview(leftView) + self.view.addSubview(rightView) + + let duration: Double = 0.3 + + leftView.layer.animateRotation(from: 0.0, to: -CGFloat.pi * 0.7, duration: duration, removeOnCompletion: false) + rightView.layer.animateRotation(from: 0.0, to: CGFloat.pi * 0.7, duration: duration, removeOnCompletion: false) + + leftView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: -6.0, y: 8.0), duration: duration, removeOnCompletion: false, additive: true) + rightView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 6.0, y: 8.0), duration: duration, removeOnCompletion: false, additive: true) + + leftView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, removeOnCompletion: false, completion: { [weak leftView] _ in + leftView?.removeFromSuperview() + }) + + rightView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, removeOnCompletion: false, completion: { [weak rightView] _ in + rightView?.removeFromSuperview() + }) + } + } + } + } + } +} + + class ChatMessageDateAndStatusNode: ASDisplayNode { private var backgroundNode: ASImageNode? private var blurredBackgroundNode: NavigationBackgroundNode? @@ -49,6 +157,9 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { private var clockMinNode: ASImageNode? private let dateNode: TextNode private var impressionIcon: ASImageNode? + private var reactionNodes: [StatusReactionNode] = [] + private var reactionCountNode: TextNode? + private var reactionButtonNode: HighlightTrackingButtonNode? private var repliesIcon: ASImageNode? private var selfExpiringIcon: ASImageNode? private var replyCountNode: TextNode? @@ -91,7 +202,7 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } } - func asyncLayout() -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ edited: Bool, _ impressionCount: Int?, _ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize, _ replies: Int, _ isPinned: Bool, _ hasAutoremove: Bool) -> (CGSize, (Bool) -> Void) { + func asyncLayout() -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ edited: Bool, _ impressionCount: Int?, _ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize, _ reactions: [MessageReaction], _ replies: Int, _ isPinned: Bool, _ hasAutoremove: Bool) -> (CGSize, (Bool) -> Void) { let dateLayout = TextNode.asyncLayout(self.dateNode) var checkReadNode = self.checkReadNode @@ -107,10 +218,11 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { let currentTheme = self.theme let makeReplyCountLayout = TextNode.asyncLayout(self.replyCountNode) + let makeReactionCountLayout = TextNode.asyncLayout(self.reactionCountNode) let previousLayoutSize = self.layoutSize - return { context, presentationData, edited, impressionCount, dateText, type, constrainedSize, replyCount, isPinned, hasAutoremove in + return { context, presentationData, edited, impressionCount, dateText, type, constrainedSize, reactions, replyCount, isPinned, hasAutoremove in let dateColor: UIColor var backgroundImage: UIImage? var blurredBackgroundColor: (UIColor, Bool)? @@ -415,8 +527,34 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } var replyCountLayoutAndApply: (TextNodeLayout, () -> TextNode)? + + let reactionSize: CGFloat = 14.0 + var reactionCountLayoutAndApply: (TextNodeLayout, () -> TextNode)? + let reactionSpacing: CGFloat = -4.0 + let reactionTrailingSpacing: CGFloat = 4.0 var reactionInset: CGFloat = 0.0 + if !reactions.isEmpty { + reactionInset = -1.0 + CGFloat(reactions.count) * reactionSize + CGFloat(reactions.count - 1) * reactionSpacing + reactionTrailingSpacing + + var count = 0 + for reaction in reactions { + count += Int(reaction.count) + } + + let countString: String + if count > 1000000 { + countString = "\(count / 1000000)M" + } else if count > 1000 { + countString = "\(count / 1000)K" + } else { + countString = "\(count)" + } + + let layoutAndApply = makeReactionCountLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: countString, font: dateFont, textColor: dateColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: 100.0))) + reactionInset += max(10.0, layoutAndApply.0.size.width) + 2.0 + reactionCountLayoutAndApply = layoutAndApply + } if replyCount > 0 { let countString: String @@ -599,6 +737,80 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } var reactionOffset: CGFloat = leftInset - reactionInset + backgroundInsets.left + for i in 0 ..< reactions.count { + let node: StatusReactionNode + if strongSelf.reactionNodes.count > i { + node = strongSelf.reactionNodes[i] + } else { + node = StatusReactionNode() + if strongSelf.reactionNodes.count > i { + let previousNode = strongSelf.reactionNodes[i] + if animated { + previousNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak previousNode] _ in + previousNode?.removeFromSupernode() + }) + } else { + previousNode.removeFromSupernode() + } + strongSelf.reactionNodes[i] = node + } else { + strongSelf.reactionNodes.append(node) + } + } + + node.update(type: type, isSelected: reactions[i].isSelected, count: Int(reactions[i].count), theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper, animated: false) + if node.supernode == nil { + strongSelf.addSubnode(node) + if animated { + node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + node.frame = CGRect(origin: CGPoint(x: reactionOffset, y: backgroundInsets.top + offset + 1.0), size: CGSize(width: reactionSize, height: reactionSize)) + reactionOffset += reactionSize + reactionSpacing + } + if !reactions.isEmpty { + reactionOffset += reactionTrailingSpacing + } + for _ in reactions.count ..< strongSelf.reactionNodes.count { + let node = strongSelf.reactionNodes.removeLast() + if animated { + if let previousLayoutSize = previousLayoutSize { + node.frame = node.frame.offsetBy(dx: layoutSize.width - previousLayoutSize.width, dy: 0.0) + } + node.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false) + node.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak node] _ in + node?.removeFromSupernode() + }) + } else { + node.removeFromSupernode() + } + } + + if let (layout, apply) = reactionCountLayoutAndApply { + let node = apply() + if strongSelf.reactionCountNode !== node { + strongSelf.reactionCountNode?.removeFromSupernode() + strongSelf.addSubnode(node) + strongSelf.reactionCountNode = node + if animated { + node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + node.frame = CGRect(origin: CGPoint(x: reactionOffset + 1.0, y: backgroundInsets.top + 1.0 + offset), size: layout.size) + reactionOffset += 1.0 + layout.size.width + } else if let reactionCountNode = strongSelf.reactionCountNode { + strongSelf.reactionCountNode = nil + if animated { + if let previousLayoutSize = previousLayoutSize { + reactionCountNode.frame = reactionCountNode.frame.offsetBy(dx: layoutSize.width - previousLayoutSize.width, dy: 0.0) + } + reactionCountNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak reactionCountNode] _ in + reactionCountNode?.removeFromSupernode() + }) + } else { + reactionCountNode.removeFromSupernode() + } + } if let currentRepliesIcon = currentRepliesIcon { currentRepliesIcon.displaysAsynchronously = !presentationData.isPreview @@ -658,17 +870,17 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } } - static func asyncLayout(_ node: ChatMessageDateAndStatusNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ edited: Bool, _ impressionCount: Int?, _ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize, _ replies: Int, _ isPinned: Bool, _ hasAutoremove: Bool) -> (CGSize, (Bool) -> ChatMessageDateAndStatusNode) { + static func asyncLayout(_ node: ChatMessageDateAndStatusNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ edited: Bool, _ impressionCount: Int?, _ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize, _ reactions: [MessageReaction], _ replies: Int, _ isPinned: Bool, _ hasAutoremove: Bool) -> (CGSize, (Bool) -> ChatMessageDateAndStatusNode) { let currentLayout = node?.asyncLayout() - return { context, presentationData, edited, impressionCount, dateText, type, constrainedSize, replies, isPinned, hasAutoremove in + return { context, presentationData, edited, impressionCount, dateText, type, constrainedSize, reactions, replies, isPinned, hasAutoremove in let resultNode: ChatMessageDateAndStatusNode let resultSizeAndApply: (CGSize, (Bool) -> Void) if let node = node, let currentLayout = currentLayout { resultNode = node - resultSizeAndApply = currentLayout(context, presentationData, edited, impressionCount, dateText, type, constrainedSize, replies, isPinned, hasAutoremove) + resultSizeAndApply = currentLayout(context, presentationData, edited, impressionCount, dateText, type, constrainedSize, reactions, replies, isPinned, hasAutoremove) } else { resultNode = ChatMessageDateAndStatusNode() - resultSizeAndApply = resultNode.asyncLayout()(context, presentationData, edited, impressionCount, dateText, type, constrainedSize, replies, isPinned, hasAutoremove) + resultSizeAndApply = resultNode.asyncLayout()(context, presentationData, edited, impressionCount, dateText, type, constrainedSize, reactions, replies, isPinned, hasAutoremove) } return (resultSizeAndApply.0, { animated in @@ -678,6 +890,13 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } } + func reactionNode(value: String) -> (ASDisplayNode, ASDisplayNode)? { + for node in self.reactionNodes { + return (node.emptyImageNode, node.selectedImageNode) + } + return nil + } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.pressed != nil { if self.bounds.contains(point) { diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift index ad52f59483..8cd060141f 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift @@ -305,6 +305,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } var viewCount: Int? var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -322,7 +323,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { let dateText = stringForMessageTimestampStatus(accountPeerId: context.account.peerId, message: message, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, strings: presentationData.strings) - let (size, apply) = statusLayout(context, presentationData, edited, viewCount, dateText, statusType, constrainedSize, dateReplies, isPinned && !associatedData.isInPinnedListMode, message.isSelfExpiring) + let (size, apply) = statusLayout(context, presentationData, edited, viewCount, dateText, statusType, constrainedSize, dateReactions, dateReplies, isPinned && !associatedData.isInPinnedListMode, message.isSelfExpiring) statusSize = size statusApply = apply } diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift index 37d268c64f..e92a955021 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -262,6 +262,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { let sentViaBot = false var viewCount: Int? = nil var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -288,7 +289,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { isReplyThread = true } - let (dateAndStatusSize, dateAndStatusApply) = makeDateAndStatusLayout(item.context, item.presentationData, edited && !sentViaBot, viewCount, dateText, statusType, CGSize(width: max(1.0, maxDateAndStatusWidth), height: CGFloat.greatestFiniteMagnitude), dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) + let (dateAndStatusSize, dateAndStatusApply) = makeDateAndStatusLayout(item.context, item.presentationData, edited && !sentViaBot, viewCount, dateText, statusType, CGSize(width: max(1.0, maxDateAndStatusWidth), height: CGFloat.greatestFiniteMagnitude), dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) var displayVideoFrame = videoFrame displayVideoFrame.size.width *= imageScale diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift index 07d3fdc11a..22d17d348d 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift @@ -68,6 +68,7 @@ struct ChatMessageDateAndStatus { var type: ChatMessageDateAndStatusType var edited: Bool var viewCount: Int? + var dateReactions: [MessageReaction] var dateReplies: Int var isPinned: Bool var dateText: String @@ -467,7 +468,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio var statusApply: ((Bool) -> Void)? if let dateAndStatus = dateAndStatus { - let (size, apply) = statusLayout(context, presentationData, dateAndStatus.edited, dateAndStatus.viewCount, dateAndStatus.dateText, dateAndStatus.type, CGSize(width: nativeSize.width - 30.0, height: CGFloat.greatestFiniteMagnitude), dateAndStatus.dateReplies, dateAndStatus.isPinned, message.isSelfExpiring) + let (size, apply) = statusLayout(context, presentationData, dateAndStatus.edited, dateAndStatus.viewCount, dateAndStatus.dateText, dateAndStatus.type, CGSize(width: nativeSize.width - 30.0, height: CGFloat.greatestFiniteMagnitude), dateAndStatus.dateReactions, dateAndStatus.dateReplies, dateAndStatus.isPinned, message.isSelfExpiring) statusSize = size statusApply = apply } diff --git a/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift index 93e1a0dd33..3e1a348f7c 100644 --- a/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift @@ -183,6 +183,7 @@ class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { } var viewCount: Int? var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -244,7 +245,7 @@ class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { isReplyThread = true } - let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: constrainedSize.width, height: CGFloat.greatestFiniteMagnitude), dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) + let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: constrainedSize.width, height: CGFloat.greatestFiniteMagnitude), dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) statusSize = size statusApply = apply } diff --git a/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift index 5477264c9c..5c21b1ba4b 100644 --- a/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift @@ -152,6 +152,7 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { } var viewCount: Int? var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { if case .mosaic = preparePosition { @@ -199,6 +200,7 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { type: statusType, edited: edited, viewCount: viewCount, + dateReactions: dateReactions, dateReplies: dateReplies, isPinned: item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, dateText: dateText diff --git a/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift index 232f5e0137..8d9a223bd1 100644 --- a/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift @@ -1024,6 +1024,7 @@ class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } var viewCount: Int? var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -1065,7 +1066,7 @@ class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { isReplyThread = true } - let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, textConstrainedSize, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) + let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, textConstrainedSize, dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) statusSize = size statusApply = apply } diff --git a/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift index dac61792fd..e52ca66143 100644 --- a/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift @@ -53,6 +53,7 @@ class ChatMessageRestrictedBubbleContentNode: ChatMessageBubbleContentNode { var viewCount: Int? var rawText = "" var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -96,7 +97,7 @@ class ChatMessageRestrictedBubbleContentNode: ChatMessageBubbleContentNode { isReplyThread = true } - let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, textConstrainedSize, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && isReplyThread, item.message.isSelfExpiring) + let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, textConstrainedSize, dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && isReplyThread, item.message.isSelfExpiring) statusSize = size statusApply = apply } diff --git a/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift index df013a2e16..e7a190200f 100644 --- a/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift @@ -459,6 +459,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { var edited = false var viewCount: Int? = nil var dateReplies = 0 + let dateReactions: [MessageReaction] = [] for attribute in item.message.attributes { if let _ = attribute as? EditedMessageAttribute, isEmoji { edited = true @@ -478,7 +479,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { isReplyThread = true } - let (dateAndStatusSize, dateAndStatusApply) = makeDateAndStatusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) + let (dateAndStatusSize, dateAndStatusApply) = makeDateAndStatusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) var viaBotApply: (TextNodeLayout, () -> TextNode)? var replyInfoApply: (CGSize, () -> ChatMessageReplyInfoNode)? diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index fbcc28e413..558a3f0d53 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -107,6 +107,8 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } var viewCount: Int? var dateReplies = 0 + var dateReactions: [MessageReaction] = [] + for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -116,6 +118,10 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { if let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .group = channel.info { dateReplies = Int(attribute.count) } + } else if let attribute = attribute as? PendingReactionsMessageAttribute { + if let value = attribute.value { + dateReactions = [MessageReaction(value: value, count: 1, isSelected: true)] + } } } @@ -164,7 +170,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { isReplyThread = true } - let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, textConstrainedSize, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) + let (size, apply) = statusLayout(item.context, item.presentationData, edited, viewCount, dateText, statusType, textConstrainedSize, dateReactions, dateReplies, item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, item.message.isSelfExpiring) statusSize = size statusApply = apply } @@ -616,6 +622,13 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } + override func reactionTargetNode(value: String) -> (ASDisplayNode, ASDisplayNode)? { + if !self.statusNode.isHidden { + return self.statusNode.reactionNode(value: value) + } + return nil + } + override func getStatusNode() -> ASDisplayNode? { return self.statusNode }