diff --git a/submodules/AnimatedAvatarSetNode/Sources/AnimatedAvatarSetNode.swift b/submodules/AnimatedAvatarSetNode/Sources/AnimatedAvatarSetNode.swift index a4d269394a..bf82d48648 100644 --- a/submodules/AnimatedAvatarSetNode/Sources/AnimatedAvatarSetNode.swift +++ b/submodules/AnimatedAvatarSetNode/Sources/AnimatedAvatarSetNode.swift @@ -294,3 +294,172 @@ public final class AnimatedAvatarSetNode: ASDisplayNode { } } } + +public final class AnimatedAvatarSetView: UIView { + private final class ContentView: UIView { + private let unclippedView: UIImageView + private let clippedView: UIImageView + + private var size: CGSize + private var spacing: CGFloat + + private var disposable: Disposable? + + init(context: AccountContext, peer: EnginePeer?, placeholderColor: UIColor, synchronousLoad: Bool, size: CGSize, spacing: CGFloat) { + self.size = size + self.spacing = spacing + + self.unclippedView = UIImageView() + self.clippedView = UIImageView() + + super.init() + + self.addSubview(self.unclippedView) + self.addSubview(self.clippedView) + + if let peer = peer { + if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) { + let image = generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.lightGray.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + })! + self.updateImage(image: image, size: size, spacing: spacing) + + let disposable = (signal + |> deliverOnMainQueue).start(next: { [weak self] imageVersions in + guard let strongSelf = self else { + return + } + let image = imageVersions?.0 + if let image = image { + strongSelf.updateImage(image: image, size: size, spacing: spacing) + } + }) + self.disposable = disposable + } else { + let image = generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + drawPeerAvatarLetters(context: context, size: size, font: avatarFont, letters: peer.displayLetters, peerId: peer.id) + })! + self.updateImage(image: image, size: size, spacing: spacing) + } + } else { + let image = generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(placeholderColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + })! + self.updateImage(image: image, size: size, spacing: spacing) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateImage(image: UIImage, size: CGSize, spacing: CGFloat) { + self.unclippedView.image = image + self.clippedView.image = generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + context.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size)) + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.5, dy: -1.5).offsetBy(dx: spacing - size.width, dy: 0.0)) + }) + } + + deinit { + self.disposable?.dispose() + } + + func updateLayout(size: CGSize, isClipped: Bool, animated: Bool) { + self.unclippedView.frame = CGRect(origin: CGPoint(), size: size) + self.clippedView.frame = CGRect(origin: CGPoint(), size: size) + + if animated && self.unclippedView.alpha.isZero != self.clippedView.alpha.isZero { + let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) + transition.updateAlpha(layer: self.unclippedView.layer, alpha: isClipped ? 0.0 : 1.0) + transition.updateAlpha(layer: self.clippedView.layer, alpha: isClipped ? 1.0 : 0.0) + } else { + self.unclippedView.alpha = isClipped ? 0.0 : 1.0 + self.clippedView.alpha = isClipped ? 1.0 : 0.0 + } + } + } + + private var contentViews: [AnimatedAvatarSetContext.Content.Item.Key: ContentView] = [:] + + public func update(context: AccountContext, content: AnimatedAvatarSetContext.Content, itemSize: CGSize = CGSize(width: 30.0, height: 30.0), customSpacing: CGFloat? = nil, animated: Bool, synchronousLoad: Bool) -> CGSize { + var contentWidth: CGFloat = 0.0 + let contentHeight: CGFloat = itemSize.height + + let spacing: CGFloat + if let customSpacing = customSpacing { + spacing = customSpacing + } else { + spacing = 10.0 + } + + let transition: ContainedViewLayoutTransition + if animated { + transition = .animated(duration: 0.2, curve: .easeInOut) + } else { + transition = .immediate + } + + var validKeys: [AnimatedAvatarSetContext.Content.Item.Key] = [] + var index = 0 + for i in 0 ..< content.items.count { + let (key, item) = content.items[i] + + validKeys.append(key) + + let itemFrame = CGRect(origin: CGPoint(x: contentWidth, y: 0.0), size: itemSize) + + let itemView: ContentView + if let current = self.contentViews[key] { + itemView = current + itemView.updateLayout(size: itemSize, isClipped: index != 0, animated: animated) + transition.updateFrame(layer: itemView.layer, frame: itemFrame) + } else { + itemView = ContentView(context: context, peer: item.peer, placeholderColor: item.placeholderColor, synchronousLoad: synchronousLoad, size: itemSize, spacing: spacing) + self.addSubview(itemView) + self.contentViews[key] = itemView + itemView.updateLayout(size: itemSize, isClipped: index != 0, animated: false) + itemView.frame = itemFrame + if animated { + itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + itemView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5) + } + } + itemView.layer.zPosition = CGFloat(100 - i) + contentWidth += itemSize.width - spacing + index += 1 + } + var removeKeys: [AnimatedAvatarSetContext.Content.Item.Key] = [] + for key in self.contentViews.keys { + if !validKeys.contains(key) { + removeKeys.append(key) + } + } + for key in removeKeys { + guard let itemView = self.contentViews.removeValue(forKey: key) else { + continue + } + itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemView] _ in + itemView?.removeFromSuperview() + }) + itemView.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false) + } + + return CGSize(width: contentWidth, height: contentHeight) + } +} diff --git a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift index 3998261793..e503c80d2b 100644 --- a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift +++ b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift @@ -39,18 +39,21 @@ public final class ReactionButtonComponent: Component { } public struct Colors: Equatable { - public var background: UInt32 - public var foreground: UInt32 - public var stroke: UInt32 + public var deselectedBackground: UInt32 + public var selectedBackground: UInt32 + public var deselectedForeground: UInt32 + public var selectedForeground: UInt32 public init( - background: UInt32, - foreground: UInt32, - stroke: UInt32 + deselectedBackground: UInt32, + selectedBackground: UInt32, + deselectedForeground: UInt32, + selectedForeground: UInt32 ) { - self.background = background - self.foreground = foreground - self.stroke = stroke + self.deselectedBackground = deselectedBackground + self.selectedBackground = selectedBackground + self.deselectedForeground = deselectedForeground + self.selectedForeground = selectedForeground } } @@ -99,6 +102,7 @@ public final class ReactionButtonComponent: Component { public final class View: UIButton, ComponentTaggedView { public let iconView: UIImageView private let textView: ComponentHostView + private let measureTextView: ComponentHostView private var currentComponent: ReactionButtonComponent? @@ -111,6 +115,9 @@ public final class ReactionButtonComponent: Component { self.textView = ComponentHostView() self.textView.isUserInteractionEnabled = false + self.measureTextView = ComponentHostView() + self.measureTextView.isUserInteractionEnabled = false + super.init(frame: CGRect()) self.addSubview(self.iconView) @@ -148,11 +155,11 @@ public final class ReactionButtonComponent: Component { } func update(component: ReactionButtonComponent, availableSize: CGSize, environment: Environment, transition: Transition) -> CGSize { - let sideInsets: CGFloat = 10.0 + let sideInsets: CGFloat = 8.0 let height: CGFloat = 30.0 - let spacing: CGFloat = 2.0 + let spacing: CGFloat = 4.0 - let defaultImageSize = CGSize(width: 20.0, height: 20.0) + let defaultImageSize = CGSize(width: 22.0, height: 22.0) let imageSize: CGSize if self.currentComponent?.reaction != component.reaction { @@ -179,43 +186,55 @@ public final class ReactionButtonComponent: Component { self.iconView.frame = CGRect(origin: CGPoint(x: sideInsets, y: floorToScreenPixels((height - imageSize.height) / 2.0)), size: imageSize) - let textSize: CGSize - if self.currentComponent?.count != component.count || self.currentComponent?.colors != component.colors { - textSize = self.textView.update( + let text = "\(component.count)" + var measureText = "" + for _ in 0 ..< text.count { + measureText.append("0") + } + + let minTextWidth = self.measureTextView.update( + transition: .immediate, + component: AnyComponent(Text( + text: measureText, + font: Font.regular(11.0), + color: .black + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ).width + 2.0 + + let actualTextSize: CGSize + if self.currentComponent?.count != component.count || self.currentComponent?.colors != component.colors || self.currentComponent?.isSelected != component.isSelected { + actualTextSize = self.textView.update( transition: .immediate, component: AnyComponent(Text( - text: "\(component.count)", - font: Font.regular(13.0), - color: UIColor(argb: component.colors.foreground) + text: text, + font: Font.regular(11.0), + color: UIColor(argb: component.isSelected ? component.colors.selectedForeground : component.colors.deselectedForeground) )), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) } else { - textSize = self.textView.bounds.size - } - - if self.currentComponent?.colors != component.colors { - self.backgroundColor = UIColor(argb: component.colors.background) + actualTextSize = self.textView.bounds.size } + let layoutTextSize = CGSize(width: max(actualTextSize.width, minTextWidth), height: actualTextSize.height) if self.currentComponent?.colors != component.colors || self.currentComponent?.isSelected != component.isSelected { if component.isSelected { - self.layer.borderColor = UIColor(argb: component.colors.stroke).cgColor - self.layer.borderWidth = 1.5 + self.backgroundColor = UIColor(argb: component.colors.selectedBackground) } else { - self.layer.borderColor = nil - self.layer.borderWidth = 0.0 + self.backgroundColor = UIColor(argb: component.colors.deselectedBackground) } } self.layer.cornerRadius = height / 2.0 - self.textView.frame = CGRect(origin: CGPoint(x: sideInsets + imageSize.width + spacing, y: floorToScreenPixels((height - textSize.height) / 2.0)), size: textSize) + self.textView.frame = CGRect(origin: CGPoint(x: sideInsets + imageSize.width + spacing, y: floorToScreenPixels((height - actualTextSize.height) / 2.0)), size: actualTextSize) self.currentComponent = component - return CGSize(width: imageSize.width + spacing + textSize.width + sideInsets * 2.0, height: height) + return CGSize(width: imageSize.width + spacing + layoutTextSize.width + sideInsets * 2.0, height: height) } } @@ -272,7 +291,20 @@ public final class ReactionButtonsLayoutContainer { var removedViews: [ComponentHostView] = [] var validIds = Set() - for reaction in reactions { + for reaction in reactions.sorted(by: { lhs, rhs in + var lhsCount = lhs.count + if lhs.isSelected { + lhsCount -= 1 + } + var rhsCount = rhs.count + if rhs.isSelected { + rhsCount -= 1 + } + if lhsCount != rhsCount { + return lhsCount > rhsCount + } + return lhs.reaction.value < rhs.reaction.value + }) { validIds.insert(reaction.reaction.value) let view: ComponentHostView diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index c87f5a6b59..95fae64665 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -360,6 +360,15 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi strongSelf.hapticFeedback.tap() } } + + if let reactionContextNode = strongSelf.reactionContextNode { + let reactionPoint = strongSelf.view.convert(localPoint, to: reactionContextNode.view) + let highlightedReaction = reactionContextNode.reaction(at: reactionPoint)?.reaction + if strongSelf.highlightedReaction?.rawValue != highlightedReaction?.rawValue { + strongSelf.highlightedReaction = highlightedReaction + strongSelf.hapticFeedback.tap() + } + } } } } @@ -374,6 +383,9 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi strongSelf.highlightedActionNode = nil highlightedActionNode.performAction() } + if let highlightedReaction = strongSelf.highlightedReaction { + strongSelf.reactionContextNode?.performReactionSelection(reaction: highlightedReaction) + } } else { if let highlightedActionNode = strongSelf.highlightedActionNode { strongSelf.highlightedActionNode = nil @@ -417,6 +429,15 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi strongSelf.hapticFeedback.tap() } } + + if let reactionContextNode = strongSelf.reactionContextNode { + let reactionPoint = strongSelf.view.convert(localPoint, to: reactionContextNode.view) + let highlightedReaction = reactionContextNode.reaction(at: reactionPoint)?.reaction + if strongSelf.highlightedReaction?.rawValue != highlightedReaction?.rawValue { + strongSelf.highlightedReaction = highlightedReaction + strongSelf.hapticFeedback.tap() + } + } } } } @@ -431,6 +452,10 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi strongSelf.highlightedActionNode = nil highlightedActionNode.performAction() } + + if let highlightedReaction = strongSelf.highlightedReaction { + strongSelf.reactionContextNode?.performReactionSelection(reaction: highlightedReaction) + } } else { if let highlightedActionNode = strongSelf.highlightedActionNode { strongSelf.highlightedActionNode = nil diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index 9fd87574bf..8163f87fde 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import AsyncDisplayKit +import ObjCRuntimeUtils public enum ContainedViewLayoutTransitionCurve: Equatable, Hashable { case linear @@ -62,7 +63,6 @@ public extension ContainedViewLayoutTransitionCurve { } } - #if os(iOS) var viewAnimationOptions: UIView.AnimationOptions { switch self { case .linear: @@ -77,7 +77,6 @@ public extension ContainedViewLayoutTransitionCurve { return [] } } - #endif } public enum ContainedViewLayoutTransition { @@ -390,11 +389,15 @@ public extension ContainedViewLayoutTransition { } func animatePositionWithKeyframes(node: ASDisplayNode, keyframes: [AnyObject], removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { + self.animatePositionWithKeyframes(layer: node.layer, keyframes: keyframes, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion) + } + + func animatePositionWithKeyframes(layer: CALayer, keyframes: [AnyObject], removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { switch self { case .immediate: completion?(true) case let .animated(duration, curve): - node.layer.animateKeyframes(values: keyframes, duration: duration, keyPath: "position", timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, removeOnCompletion: removeOnCompletion, completion: { value in + layer.animateKeyframes(values: keyframes, duration: duration, keyPath: "position", timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, removeOnCompletion: removeOnCompletion, completion: { value in completion?(value) }) } @@ -853,6 +856,28 @@ public extension ContainedViewLayoutTransition { } } + func animateTransformScale(layer: CALayer, from fromScale: CGPoint, to toScale: CGPoint, completion: ((Bool) -> Void)? = nil) { + switch self { + case .immediate: + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let calculatedFrom: CGPoint + let calculatedTo: CGPoint + + calculatedFrom = fromScale + calculatedTo = toScale + + layer.animateScaleX(from: calculatedFrom.x, to: calculatedTo.x, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, completion: { result in + if let completion = completion { + completion(result) + } + }) + layer.animateScaleY(from: calculatedFrom.y, to: calculatedTo.y, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction) + } + } + func animateTransformScale(view: UIView, from fromScale: CGFloat, completion: ((Bool) -> Void)? = nil) { let t = view.layer.transform let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) @@ -1417,3 +1442,499 @@ public extension ContainedViewLayoutTransition { } } } + +public protocol ControlledTransitionAnimator: AnyObject { + var duration: Double { get } + + func startAnimation() + func setAnimationProgress(_ progress: CGFloat) + func finishAnimation() + + func updateAlpha(layer: CALayer, alpha: CGFloat, completion: ((Bool) -> Void)?) + func updateScale(layer: CALayer, scale: CGFloat, completion: ((Bool) -> Void)?) + func animateScale(layer: CALayer, from fromValue: CGFloat, to toValue: CGFloat, completion: ((Bool) -> Void)?) + func updatePosition(layer: CALayer, position: CGPoint, completion: ((Bool) -> Void)?) + func updateBounds(layer: CALayer, bounds: CGRect, completion: ((Bool) -> Void)?) + func updateFrame(layer: CALayer, frame: CGRect, completion: ((Bool) -> Void)?) + func updateCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)?) +} + +protocol AnyValueProviding { + var anyValue: ControlledTransitionProperty.AnyValue { get } +} + +extension CGFloat: AnyValueProviding { + func interpolate(with other: CGFloat, fraction: CGFloat) -> CGFloat { + let invT = 1.0 - fraction + let result = other * fraction + self * invT + return result + } + + var anyValue: ControlledTransitionProperty.AnyValue { + return ControlledTransitionProperty.AnyValue( + value: self, + nsValue: self as NSNumber, + stringValue: { "\(self)" }, + isEqual: { other in + if let otherValue = other.value as? CGFloat { + return self == otherValue + } else { + return false + } + }, + interpolate: { other, fraction in + guard let otherValue = other.value as? CGFloat else { + preconditionFailure() + } + return self.interpolate(with: otherValue, fraction: fraction).anyValue + } + ) + } +} + +extension Float: AnyValueProviding { + func interpolate(with other: Float, fraction: CGFloat) -> Float { + let invT = 1.0 - Float(fraction) + let result = other * Float(fraction) + self * invT + return result + } + + var anyValue: ControlledTransitionProperty.AnyValue { + return ControlledTransitionProperty.AnyValue( + value: self, + nsValue: self as NSNumber, + stringValue: { "\(self)" }, + isEqual: { other in + if let otherValue = other.value as? Float { + return self == otherValue + } else { + return false + } + }, + interpolate: { other, fraction in + guard let otherValue = other.value as? Float else { + preconditionFailure() + } + return self.interpolate(with: otherValue, fraction: fraction).anyValue + } + ) + } +} + +extension CGPoint: AnyValueProviding { + func interpolate(with other: CGPoint, fraction: CGFloat) -> CGPoint { + return CGPoint(x: self.x.interpolate(with: other.x, fraction: fraction), y: self.y.interpolate(with: other.y, fraction: fraction)) + } + + var anyValue: ControlledTransitionProperty.AnyValue { + return ControlledTransitionProperty.AnyValue( + value: self, + nsValue: NSValue(cgPoint: self), + stringValue: { "\(self)" }, + isEqual: { other in + if let otherValue = other.value as? CGPoint { + return self == otherValue + } else { + return false + } + }, + interpolate: { other, fraction in + guard let otherValue = other.value as? CGPoint else { + preconditionFailure() + } + return self.interpolate(with: otherValue, fraction: fraction).anyValue + } + ) + } +} + +extension CGSize: AnyValueProviding { + func interpolate(with other: CGSize, fraction: CGFloat) -> CGSize { + return CGSize(width: self.width.interpolate(with: other.width, fraction: fraction), height: self.height.interpolate(with: other.height, fraction: fraction)) + } + + var anyValue: ControlledTransitionProperty.AnyValue { + return ControlledTransitionProperty.AnyValue( + value: self, + nsValue: NSValue(cgSize: self), + stringValue: { "\(self)" }, + isEqual: { other in + if let otherValue = other.value as? CGSize { + return self == otherValue + } else { + return false + } + }, + interpolate: { other, fraction in + guard let otherValue = other.value as? CGSize else { + preconditionFailure() + } + return self.interpolate(with: otherValue, fraction: fraction).anyValue + } + ) + } +} + +extension CGRect: AnyValueProviding { + func interpolate(with other: CGRect, fraction: CGFloat) -> CGRect { + return CGRect(origin: self.origin.interpolate(with: other.origin, fraction: fraction), size: self.size.interpolate(with: other.size, fraction: fraction)) + } + + var anyValue: ControlledTransitionProperty.AnyValue { + return ControlledTransitionProperty.AnyValue( + value: self, + nsValue: NSValue(cgRect: self), + stringValue: { "\(self)" }, + isEqual: { other in + if let otherValue = other.value as? CGRect { + return self == otherValue + } else { + return false + } + }, + interpolate: { other, fraction in + guard let otherValue = other.value as? CGRect else { + preconditionFailure() + } + return self.interpolate(with: otherValue, fraction: fraction).anyValue + } + ) + } +} + +final class ControlledTransitionProperty { + final class AnyValue: Equatable, CustomStringConvertible { + let value: Any + let nsValue: NSValue + let stringValue: () -> String + let isEqual: (AnyValue) -> Bool + let interpolate: (AnyValue, CGFloat) -> AnyValue + + init( + value: Any, + nsValue: NSValue, + stringValue: @escaping () -> String, + isEqual: @escaping (AnyValue) -> Bool, + interpolate: @escaping (AnyValue, CGFloat) -> AnyValue + ) { + self.value = value + self.nsValue = nsValue + self.stringValue = stringValue + self.isEqual = isEqual + self.interpolate = interpolate + } + + var description: String { + return self.stringValue() + } + + static func ==(lhs: AnyValue, rhs: AnyValue) -> Bool { + if lhs.isEqual(rhs) { + return true + } else { + return false + } + } + } + + let layer: CALayer + let path: String + var fromValue: AnyValue + let toValue: AnyValue + private let completion: ((Bool) -> Void)? + + init(layer: CALayer, path: String, fromValue: T, toValue: T, completion: ((Bool) -> Void)?) where T: AnyValueProviding { + self.layer = layer + self.path = path + self.fromValue = fromValue.anyValue + self.toValue = toValue.anyValue + self.completion = completion + + self.update(at: 0.0) + } + + deinit { + self.layer.removeAnimation(forKey: "MyCustomAnimation_\(Unmanaged.passUnretained(self).toOpaque())") + } + + func update(at fraction: CGFloat) { + let value = self.fromValue.interpolate(toValue, fraction) + + let animation = CABasicAnimation(keyPath: self.path) + animation.speed = 0.0 + animation.beginTime = CACurrentMediaTime() + 1000.0 + animation.timeOffset = 0.01 + animation.duration = 1.0 + animation.fillMode = .both + animation.fromValue = value.nsValue + animation.toValue = value.nsValue + animation.timingFunction = CAMediaTimingFunction(name: .linear) + animation.isRemovedOnCompletion = false + self.layer.add(animation, forKey: "MyCustomAnimation_\(Unmanaged.passUnretained(self).toOpaque())") + } + + func complete(atEnd: Bool) { + self.completion?(atEnd) + } +} + +public final class ControlledTransition { + public final class NativeAnimator: ControlledTransitionAnimator { + public let duration: Double + private let curve: ContainedViewLayoutTransitionCurve + + private var animations: [ControlledTransitionProperty] = [] + + init( + duration: Double, + curve: ContainedViewLayoutTransitionCurve + ) { + self.duration = duration + self.curve = curve + } + + func merge(with other: NativeAnimator) { + var removeAnimationIndices: [Int] = [] + for i in 0 ..< self.animations.count { + let animation = self.animations[i] + + var removeOtherAnimationIndices: [Int] = [] + for j in 0 ..< other.animations.count { + let otherAnimation = other.animations[j] + + if animation.layer === otherAnimation.layer && animation.path == otherAnimation.path { + if animation.toValue == otherAnimation.toValue { + removeAnimationIndices.append(i) + } else { + removeOtherAnimationIndices.append(j) + } + } + } + + for j in removeOtherAnimationIndices.reversed() { + let otherAnimation = other.animations.remove(at: j) + otherAnimation.complete(atEnd: false) + } + } + + for i in Set(removeAnimationIndices).sorted().reversed() { + self.animations.remove(at: i).complete(atEnd: false) + } + } + + public func startAnimation() { + } + + public func setAnimationProgress(_ progress: CGFloat) { + let mappedFraction: CGFloat + switch self.curve { + case .spring: + mappedFraction = springAnimationSolver(progress) + case let .custom(c1x, c1y, c2x, c2y): + mappedFraction = bezierPoint(CGFloat(c1x), CGFloat(c1y), CGFloat(c2x), CGFloat(c2y), progress) + default: + mappedFraction = progress + } + + for animation in self.animations { + animation.update(at: mappedFraction) + } + } + + public func finishAnimation() { + for animation in self.animations { + animation.update(at: 1.0) + animation.complete(atEnd: true) + } + self.animations.removeAll() + } + + private func add(animation: ControlledTransitionProperty) { + for i in 0 ..< self.animations.count { + let otherAnimation = self.animations[i] + if otherAnimation.layer === animation.layer && otherAnimation.path == animation.path { + let currentAnimation = self.animations[i] + currentAnimation.complete(atEnd: false) + self.animations.remove(at: i) + break + } + } + self.animations.append(animation) + } + + public func updateAlpha(layer: CALayer, alpha: CGFloat, completion: ((Bool) -> Void)?) { + if layer.opacity == Float(alpha) { + return + } + let fromValue = layer.presentation()?.opacity ?? layer.opacity + layer.opacity = Float(alpha) + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "opacity", + fromValue: fromValue, + toValue: Float(alpha), + completion: completion + )) + } + + public func updateScale(layer: CALayer, scale: CGFloat, completion: ((Bool) -> Void)?) { + let t = layer.presentation()?.transform ?? layer.transform + let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) + + if currentScale == scale { + return + } + layer.transform = CATransform3DMakeScale(scale, scale, 1.0) + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "transform.scale", + fromValue: currentScale, + toValue: scale, + completion: completion + )) + } + + public func animateScale(layer: CALayer, from fromValue: CGFloat, to toValue: CGFloat, completion: ((Bool) -> Void)?) { + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "transform.scale", + fromValue: fromValue, + toValue: toValue, + completion: completion + )) + } + + public func updatePosition(layer: CALayer, position: CGPoint, completion: ((Bool) -> Void)?) { + if layer.position == position { + return + } + let fromValue = layer.presentation()?.position ?? layer.position + layer.position = position + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "position", + fromValue: fromValue, + toValue: position, + completion: completion + )) + } + + public func updateBounds(layer: CALayer, bounds: CGRect, completion: ((Bool) -> Void)?) { + if layer.bounds == bounds { + return + } + let fromValue = layer.presentation()?.bounds ?? layer.bounds + layer.bounds = bounds + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "bounds", + fromValue: fromValue, + toValue: bounds, + completion: completion + )) + } + + public func updateFrame(layer: CALayer, frame: CGRect, completion: ((Bool) -> Void)?) { + self.updatePosition(layer: layer, position: frame.center, completion: completion) + self.updateBounds(layer: layer, bounds: CGRect(origin: CGPoint(), size: frame.size), completion: nil) + } + + public func updateCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)?) { + if layer.cornerRadius == cornerRadius { + return + } + let fromValue = layer.presentation()?.cornerRadius ?? layer.cornerRadius + layer.cornerRadius = cornerRadius + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "cornerRadius", + fromValue: fromValue, + toValue: cornerRadius, + completion: completion + )) + } + } + + public final class LegacyAnimator: ControlledTransitionAnimator { + public let duration: Double + public let transition: ContainedViewLayoutTransition + + init( + duration: Double, + curve: ContainedViewLayoutTransitionCurve + ) { + self.duration = duration + + if duration.isZero { + self.transition = .immediate + } else { + self.transition = .animated(duration: duration, curve: curve) + } + } + + public func startAnimation() { + } + + public func setAnimationProgress(_ progress: CGFloat) { + } + + public func finishAnimation() { + } + + public func updateAlpha(layer: CALayer, alpha: CGFloat, completion: ((Bool) -> Void)?) { + self.transition.updateAlpha(layer: layer, alpha: alpha, completion: completion) + } + + public func updateScale(layer: CALayer, scale: CGFloat, completion: ((Bool) -> Void)?) { + self.transition.updateTransformScale(layer: layer, scale: scale, completion: completion) + } + + public func animateScale(layer: CALayer, from fromValue: CGFloat, to toValue: CGFloat, completion: ((Bool) -> Void)?) { + self.transition.animateTransformScale(layer: layer, from: CGPoint(x: fromValue, y: fromValue), to: CGPoint(x: toValue, y: toValue), completion: completion) + } + + public func updatePosition(layer: CALayer, position: CGPoint, completion: ((Bool) -> Void)?) { + self.transition.updatePosition(layer: layer, position: position, completion: completion) + } + + public func updateBounds(layer: CALayer, bounds: CGRect, completion: ((Bool) -> Void)?) { + self.transition.updateBounds(layer: layer, bounds: bounds, completion: completion) + } + + public func updateFrame(layer: CALayer, frame: CGRect, completion: ((Bool) -> Void)?) { + self.transition.updateFrame(layer: layer, frame: frame, completion: completion) + } + + public func updateCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)?) { + self.transition.updateCornerRadius(layer: layer, cornerRadius: cornerRadius, completion: completion) + } + } + + public let animator: ControlledTransitionAnimator + public let legacyAnimator: LegacyAnimator + + public init( + duration: Double, + curve: ContainedViewLayoutTransitionCurve, + interactive: Bool + ) { + self.legacyAnimator = LegacyAnimator( + duration: duration, + curve: curve + ) + if interactive { + self.animator = NativeAnimator( + duration: duration, + curve: curve + ) + } else { + self.animator = self.legacyAnimator + } + } + + public func merge(with other: ControlledTransition) { + if let animator = self.animator as? NativeAnimator, let otherAnimator = other.animator as? NativeAnimator { + animator.merge(with: otherAnimator) + } + } +} diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index c4ee47b208..303016ee23 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -1578,8 +1578,24 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture DispatchQueue.main.async(execute: f) } - private func nodeForItem(synchronous: Bool, synchronousLoads: Bool, item: ListViewItem, previousNode: QueueLocalObject?, index: Int, previousItem: ListViewItem?, nextItem: ListViewItem?, params: ListViewItemLayoutParams, updateAnimation: ListViewItemUpdateAnimation, completion: @escaping (QueueLocalObject, ListViewItemNodeLayout, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + private func nodeForItem(synchronous: Bool, synchronousLoads: Bool, item: ListViewItem, previousNode: QueueLocalObject?, index: Int, previousItem: ListViewItem?, nextItem: ListViewItem?, params: ListViewItemLayoutParams, updateAnimationIsAnimated: Bool, updateAnimationIsCrossfade: Bool, completion: @escaping (QueueLocalObject, ListViewItemNodeLayout, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { if let previousNode = previousNode { + var controlledTransition: ControlledTransition? + let updateAnimation: ListViewItemUpdateAnimation + if updateAnimationIsCrossfade { + updateAnimation = .Crossfade + } else if updateAnimationIsAnimated { + let transition = ControlledTransition(duration: insertionAnimationDuration * UIView.animationDurationFactor(), curve: .spring, interactive: true) + controlledTransition = transition + updateAnimation = .System(duration: insertionAnimationDuration * UIView.animationDurationFactor(), transition: transition) + } else { + updateAnimation = .None + } + + if let controlledTransition = controlledTransition { + previousNode.syncWith({ $0 }).addPendingControlledTransition(transition: controlledTransition) + } + item.updateNode(async: { f in if synchronous { f() @@ -2017,8 +2033,6 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture if updateAdjacentItemsIndices.isEmpty { completion(state, operations) } else { - let updateAnimation: ListViewItemUpdateAnimation = animated ? .System(duration: insertionAnimationDuration) : .None - var updatedUpdateAdjacentItemsIndices = updateAdjacentItemsIndices let nodeIndex = updateAdjacentItemsIndices.first! @@ -2031,6 +2045,20 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture if case let .Node(index, _, referenceNode) = node , index == nodeIndex { if let referenceNode = referenceNode { continueWithoutNode = false + var controlledTransition: ControlledTransition? + let updateAnimation: ListViewItemUpdateAnimation + if animated { + let transition = ControlledTransition(duration: insertionAnimationDuration * UIView.animationDurationFactor(), curve: .spring, interactive: true) + controlledTransition = transition + updateAnimation = .System(duration: insertionAnimationDuration * UIView.animationDurationFactor(), transition: transition) + } else { + updateAnimation = .None + } + + if let controlledTransition = controlledTransition { + referenceNode.syncWith({ $0 }).addPendingControlledTransition(transition: controlledTransition) + } + self.items[index].updateNode(async: { f in if synchronous { f() @@ -2086,7 +2114,6 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture let previousNodes = inputPreviousNodes var operations = inputOperations let completion = inputCompletion - let updateAnimation: ListViewItemUpdateAnimation = animated ? .System(duration: insertionAnimationDuration) : .None if state.nodes.count > 1000 { print("state.nodes.count > 1000") @@ -2115,8 +2142,7 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture let index = insertionItemIndexAndDirection.0 let threadId = pthread_self() var tailRecurse = false - self.nodeForItem(synchronous: synchronous, synchronousLoads: synchronousLoads, item: self.items[index], previousNode: previousNodes[index], index: index, previousItem: index == 0 ? nil : self.items[index - 1], nextItem: self.items.count == index + 1 ? nil : self.items[index + 1], params: ListViewItemLayoutParams(width: state.visibleSize.width, leftInset: state.insets.left, rightInset: state.insets.right, availableHeight: state.visibleSize.height - state.insets.top - state.insets.bottom), updateAnimation: updateAnimation, completion: { (node, layout, apply) in - + self.nodeForItem(synchronous: synchronous, synchronousLoads: synchronousLoads, item: self.items[index], previousNode: previousNodes[index], index: index, previousItem: index == 0 ? nil : self.items[index - 1], nextItem: self.items.count == index + 1 ? nil : self.items[index + 1], params: ListViewItemLayoutParams(width: state.visibleSize.width, leftInset: state.insets.left, rightInset: state.insets.right, availableHeight: state.visibleSize.height - state.insets.top - state.insets.bottom), updateAnimationIsAnimated: animated, updateAnimationIsCrossfade: false, completion: { (node, layout, apply) in if pthread_equal(pthread_self(), threadId) != 0 && !tailRecurse { tailRecurse = true state.insertNode(index, node: node, layout: layout, apply: apply, offsetDirection: insertionItemIndexAndDirection.1, animated: animated && animatedInsertIndices.contains(index), operations: &operations, itemCount: self.items.count) @@ -2151,16 +2177,8 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture } else { let updateItem = updateIndicesAndItems[0] if let previousNode = previousNodes[updateItem.index] { - let updateAnimation: ListViewItemUpdateAnimation - if crossfade { - updateAnimation = .Crossfade - } else if animated { - updateAnimation = .System(duration: insertionAnimationDuration) - } else { - updateAnimation = .None - } - self.nodeForItem(synchronous: synchronous, synchronousLoads: synchronousLoads, item: updateItem.item, previousNode: previousNode, index: updateItem.index, previousItem: updateItem.index == 0 ? nil : self.items[updateItem.index - 1], nextItem: updateItem.index == (self.items.count - 1) ? nil : self.items[updateItem.index + 1], params: ListViewItemLayoutParams(width: state.visibleSize.width, leftInset: state.insets.left, rightInset: state.insets.right, availableHeight: state.visibleSize.height - state.insets.top - state.insets.bottom), updateAnimation: updateAnimation, completion: { _, layout, apply in - state.updateNodeAtItemIndex(updateItem.index, layout: layout, direction: updateItem.directionHint, animation: animated ? .System(duration: insertionAnimationDuration) : .None, apply: apply, operations: &operations) + self.nodeForItem(synchronous: synchronous, synchronousLoads: synchronousLoads, item: updateItem.item, previousNode: previousNode, index: updateItem.index, previousItem: updateItem.index == 0 ? nil : self.items[updateItem.index - 1], nextItem: updateItem.index == (self.items.count - 1) ? nil : self.items[updateItem.index + 1], params: ListViewItemLayoutParams(width: state.visibleSize.width, leftInset: state.insets.left, rightInset: state.insets.right, availableHeight: state.visibleSize.height - state.insets.top - state.insets.bottom), updateAnimationIsAnimated: animated, updateAnimationIsCrossfade: crossfade, completion: { _, layout, apply in + state.updateNodeAtItemIndex(updateItem.index, layout: layout, direction: updateItem.directionHint, isAnimated: animated, apply: apply, operations: &operations) updateIndicesAndItems.remove(at: 0) self.updateNodes(synchronous: synchronous, synchronousLoads: synchronousLoads, crossfade: crossfade, animated: animated, updateIndicesAndItems: updateIndicesAndItems, inputState: state, previousNodes: previousNodes, inputOperations: operations, completion: completion) @@ -2656,10 +2674,16 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture } }) - if node.rotated && currentAnimation == nil { - let insetPart: CGFloat = previousInsets.bottom - layout.insets.bottom - node.transitionOffset += previousApparentHeight - layout.size.height - insetPart - node.addTransitionOffsetAnimation(0.0, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp) + if node.rotated { + if currentAnimation == nil { + let insetPart: CGFloat = previousInsets.bottom - layout.insets.bottom + node.transitionOffset += previousApparentHeight - layout.size.height - insetPart + node.addTransitionOffsetAnimation(0.0, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp) + } else { + let insetPart: CGFloat = previousInsets.bottom - layout.insets.bottom + node.transitionOffset = previousApparentHeight - layout.size.height - insetPart + node.addTransitionOffsetAnimation(0.0, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp) + } } } } else { @@ -2708,6 +2732,10 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture } } + for itemNode in self.itemNodes { + itemNode.beginPendingControlledTransitions(beginAt: timestamp) + } + if hadInserts, let reorderNode = self.reorderNode, reorderNode.supernode != nil { self.view.bringSubviewToFront(reorderNode.view) if let verticalScrollIndicator = self.verticalScrollIndicator { diff --git a/submodules/Display/Source/ListViewAnimation.swift b/submodules/Display/Source/ListViewAnimation.swift index 482f466d3d..98ffde5ef7 100644 --- a/submodules/Display/Source/ListViewAnimation.swift +++ b/submodules/Display/Source/ListViewAnimation.swift @@ -7,15 +7,15 @@ public protocol Interpolatable { } private func floorToPixels(_ value: CGFloat) -> CGFloat { - return round(value * 10.0) / 10.0 + return value } private func floorToPixels(_ value: CGPoint) -> CGPoint { - return CGPoint(x: round(value.x * 10.0) / 10.0, y: round(value.y * 10.0) / 10.0) + return CGPoint(x: floorToPixels(value.x), y: floorToPixels(value.y)) } private func floorToPixels(_ value: CGSize) -> CGSize { - return CGSize(width: round(value.width * 10.0) / 10.0, height: round(value.height * 10.0) / 10.0) + return CGSize(width: floorToPixels(value.width), height: floorToPixels(value.height)) } private func floorToPixels(_ value: CGRect) -> CGRect { @@ -23,7 +23,7 @@ private func floorToPixels(_ value: CGRect) -> CGRect { } private func floorToPixels(_ value: UIEdgeInsets) -> UIEdgeInsets { - return UIEdgeInsets(top: round(value.top * 10.0) / 10.0, left: round(value.left * 10.0) / 10.0, bottom: round(value.bottom * 10.0) / 10.0, right: round(value.right * 10.0) / 10.0) + return UIEdgeInsets(top: floorToPixels(value.top), left: floorToPixels(value.left), bottom: floorToPixels(value.bottom), right: floorToPixels(value.right)) } extension CGFloat: Interpolatable { @@ -36,6 +36,12 @@ extension CGFloat: Interpolatable { return floorToPixels(term) } } + + static func interpolate(from fromValue: CGFloat, to toValue: CGFloat, at t: CGFloat) -> CGFloat { + let invT: CGFloat = 1.0 - t + let term: CGFloat = toValue * t + fromValue * invT + return term + } } extension UIEdgeInsets: Interpolatable { @@ -56,6 +62,10 @@ extension CGRect: Interpolatable { return floorToPixels(CGRect(x: toValue.origin.x * t + fromValue.origin.x * (1.0 - t), y: toValue.origin.y * t + fromValue.origin.y * (1.0 - t), width: toValue.size.width * t + fromValue.size.width * (1.0 - t), height: toValue.size.height * t + fromValue.size.height * (1.0 - t))) } } + + static func interpolate(from fromValue: CGRect, to toValue: CGRect, at t: CGFloat) -> CGRect { + return CGRect(origin: CGPoint.interpolate(from: fromValue.origin, to: toValue.origin, at: t), size: CGSize.interpolate(from: fromValue.size, to: toValue.size, at: t)) + } } extension CGPoint: Interpolatable { @@ -66,6 +76,16 @@ extension CGPoint: Interpolatable { return floorToPixels(CGPoint(x: toValue.x * t + fromValue.x * (1.0 - t), y: toValue.y * t + fromValue.y * (1.0 - t))) } } + + static func interpolate(from fromValue: CGPoint, to toValue: CGPoint, at t: CGFloat) -> CGPoint { + return CGPoint(x: toValue.x * t + fromValue.x * (1.0 - t), y: toValue.y * t + fromValue.y * (1.0 - t)) + } +} + +extension CGSize { + static func interpolate(from fromValue: CGSize, to toValue: CGSize, at t: CGFloat) -> CGSize { + return CGSize(width: toValue.width * t + fromValue.width * (1.0 - t), height: toValue.height * t + fromValue.height * (1.0 - t)) + } } private let springAnimationIn: CABasicAnimation = { @@ -73,7 +93,7 @@ private let springAnimationIn: CABasicAnimation = { return animation }() -private let springAnimationSolver: (CGFloat) -> CGFloat = { () -> (CGFloat) -> CGFloat in +let springAnimationSolver: (CGFloat) -> CGFloat = { () -> (CGFloat) -> CGFloat in if #available(iOS 9.0, *) { return { t in return springAnimationValueAt(springAnimationIn, t) diff --git a/submodules/Display/Source/ListViewIntermediateState.swift b/submodules/Display/Source/ListViewIntermediateState.swift index 096f485fc3..0edec15664 100644 --- a/submodules/Display/Source/ListViewIntermediateState.swift +++ b/submodules/Display/Source/ListViewIntermediateState.swift @@ -807,13 +807,12 @@ struct ListViewState { } } - mutating func updateNodeAtItemIndex(_ itemIndex: Int, layout: ListViewItemNodeLayout, direction: ListViewItemOperationDirectionHint?, animation: ListViewItemUpdateAnimation, apply: @escaping () -> (Signal?, (ListViewItemApply) -> Void), operations: inout [ListViewStateOperation]) { + mutating func updateNodeAtItemIndex(_ itemIndex: Int, layout: ListViewItemNodeLayout, direction: ListViewItemOperationDirectionHint?, isAnimated: Bool, apply: @escaping () -> (Signal?, (ListViewItemApply) -> Void), operations: inout [ListViewStateOperation]) { var i = -1 for node in self.nodes { i += 1 if node.index == itemIndex { - switch animation { - case .None, .Crossfade: + if isAnimated { let offsetDirection: ListViewInsertionOffsetDirection if let direction = direction { offsetDirection = ListViewInsertionOffsetDirection(direction) @@ -852,7 +851,7 @@ struct ListViewState { } operations.append(.UpdateLayout(index: i, layout: layout, apply: apply)) - case .System: + } else { operations.append(.UpdateLayout(index: i, layout: layout, apply: apply)) } diff --git a/submodules/Display/Source/ListViewItem.swift b/submodules/Display/Source/ListViewItem.swift index 85713785ee..d3c13fbb52 100644 --- a/submodules/Display/Source/ListViewItem.swift +++ b/submodules/Display/Source/ListViewItem.swift @@ -4,7 +4,7 @@ import SwiftSignalKit public enum ListViewItemUpdateAnimation { case None - case System(duration: Double) + case System(duration: Double, transition: ControlledTransition) case Crossfade public var isAnimated: Bool { @@ -14,6 +14,26 @@ public enum ListViewItemUpdateAnimation { return true } } + + public var animator: ControlledTransitionAnimator { + switch self { + case .None: + return ControlledTransition.LegacyAnimator(duration: 0.0, curve: .linear) + case let .System(_, transition): + return transition.animator + case .Crossfade: + return ControlledTransition.LegacyAnimator(duration: 0.0, curve: .linear) + } + } + + public var transition: ContainedViewLayoutTransition { + switch self { + case .None, .Crossfade: + return .immediate + case let .System(_, transition): + return transition.legacyAnimator.transition + } + } } public struct ListViewItemConfigureNodeFlags: OptionSet { diff --git a/submodules/Display/Source/ListViewItemNode.swift b/submodules/Display/Source/ListViewItemNode.swift index d23236a0ab..9bc586ca2c 100644 --- a/submodules/Display/Source/ListViewItemNode.swift +++ b/submodules/Display/Source/ListViewItemNode.swift @@ -83,6 +83,16 @@ public struct ListViewItemLayoutParams { } } +private final class ControlledTransitionContext { + let transition: ControlledTransition + let beginAt: Double + + init(transition: ControlledTransition, beginAt: Double) { + self.transition = transition + self.beginAt = beginAt + } +} + open class ListViewItemNode: ASDisplayNode, AccessibilityFocusableNode { public struct HeaderId: Hashable { public var space: AnyHashable @@ -126,6 +136,8 @@ open class ListViewItemNode: ASDisplayNode, AccessibilityFocusableNode { private final var spring: ListViewItemSpring? private final var animations: [(String, ListViewAnimation)] = [] + private final var pendingControlledTransitions: [ControlledTransition] = [] + private final var controlledTransitions: [ControlledTransitionContext] = [] final var tempHeaderSpaceAffinities: [ListViewItemNode.HeaderId: Int] = [:] final var headerSpaceAffinities: [ListViewItemNode.HeaderId: Int] = [:] @@ -394,6 +406,26 @@ open class ListViewItemNode: ASDisplayNode, AccessibilityFocusableNode { i += 1 } + i = 0 + var transitionCount = self.controlledTransitions.count + while i < transitionCount { + let transition = self.controlledTransitions[i] + var fraction = (timestamp - transition.beginAt) / transition.transition.animator.duration + fraction = max(0.0, min(1.0, fraction)) + transition.transition.animator.setAnimationProgress(CGFloat(fraction)) + + if timestamp >= transition.beginAt + transition.transition.animator.duration { + transition.transition.animator.finishAnimation() + self.controlledTransitions.remove(at: i) + transitionCount -= 1 + i -= 1 + } else { + continueAnimations = true + } + + i += 1 + } + if let accessoryItemNode = self.accessoryItemNode { if (accessoryItemNode.animate(timestamp)) { continueAnimations = true @@ -438,6 +470,29 @@ open class ListViewItemNode: ASDisplayNode, AccessibilityFocusableNode { } self.accessoryItemNode?.removeAllAnimations() + + for transition in self.controlledTransitions { + transition.transition.animator.finishAnimation() + } + self.controlledTransitions.removeAll() + } + + func addPendingControlledTransition(transition: ControlledTransition) { + self.pendingControlledTransitions.append(transition) + } + + func beginPendingControlledTransitions(beginAt: Double) { + for transition in self.pendingControlledTransitions { + self.addControlledTransition(transition: transition, beginAt: beginAt) + } + self.pendingControlledTransitions.removeAll() + } + + func addControlledTransition(transition: ControlledTransition, beginAt: Double) { + for controlledTransition in self.controlledTransitions { + transition.merge(with: controlledTransition.transition) + } + self.controlledTransitions.append(ControlledTransitionContext(transition: transition, beginAt: beginAt)) } public func addInsetsAnimationToValue(_ value: UIEdgeInsets, duration: Double, beginAt: Double) { diff --git a/submodules/Display/Source/NavigationBar.swift b/submodules/Display/Source/NavigationBar.swift index ede32ea4d5..ea362bcab4 100644 --- a/submodules/Display/Source/NavigationBar.swift +++ b/submodules/Display/Source/NavigationBar.swift @@ -251,6 +251,27 @@ public final class NavigationBackgroundNode: ASDisplayNode { effectView.clipsToBounds = !cornerRadius.isZero } } + + public func update(size: CGSize, cornerRadius: CGFloat = 0.0, animator: ControlledTransitionAnimator) { + self.validLayout = (size, cornerRadius) + + let contentFrame = CGRect(origin: CGPoint(), size: size) + animator.updateFrame(layer: self.backgroundNode.layer, frame: contentFrame, completion: nil) + if let effectView = self.effectView, effectView.frame != contentFrame { + animator.updateFrame(layer: effectView.layer, frame: contentFrame, completion: nil) + if let sublayers = effectView.layer.sublayers { + for sublayer in sublayers { + animator.updateFrame(layer: sublayer, frame: contentFrame, completion: nil) + } + } + } + + animator.updateCornerRadius(layer: self.backgroundNode.layer, cornerRadius: cornerRadius, completion: nil) + if let effectView = self.effectView { + animator.updateCornerRadius(layer: effectView.layer, cornerRadius: cornerRadius, completion: nil) + effectView.clipsToBounds = !cornerRadius.isZero + } + } } open class NavigationBar: ASDisplayNode { diff --git a/submodules/Display/Source/TapLongTapOrDoubleTapGestureRecognizer.swift b/submodules/Display/Source/TapLongTapOrDoubleTapGestureRecognizer.swift index 35d1372542..42e6e2beb2 100644 --- a/submodules/Display/Source/TapLongTapOrDoubleTapGestureRecognizer.swift +++ b/submodules/Display/Source/TapLongTapOrDoubleTapGestureRecognizer.swift @@ -294,7 +294,7 @@ public final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, self.state = .ended case .waitForDoubleTap: self.state = .began - let timer = Timer(timeInterval: 0.2, target: TapLongTapOrDoubleTapGestureRecognizerTimerTarget(target: self), selector: #selector(TapLongTapOrDoubleTapGestureRecognizerTimerTarget.tapEvent), userInfo: nil, repeats: false) + let timer = Timer(timeInterval: 0.16, target: TapLongTapOrDoubleTapGestureRecognizerTimerTarget(target: self), selector: #selector(TapLongTapOrDoubleTapGestureRecognizerTimerTarget.tapEvent), userInfo: nil, repeats: false) self.timer = timer RunLoop.main.add(timer, forMode: .common) case let .waitForHold(_, acceptTap): diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index 5536c3e478..6cf8a9592f 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -13,7 +13,7 @@ public enum ReactionGestureItem { } public final class ReactionContextItem { - public struct Reaction { + public struct Reaction: Equatable { public var rawValue: String public init(rawValue: String) { @@ -446,44 +446,12 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { } } - private func generateParabollicMotionKeyframes(from sourcePoint: CGPoint, to targetPosition: CGPoint, elevation: CGFloat) -> [AnyObject] { - let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - elevation) - - let x1 = sourcePoint.x - let y1 = sourcePoint.y - let x2 = midPoint.x - let y2 = midPoint.y - let x3 = targetPosition.x - let y3 = targetPosition.y - - var keyframes: [AnyObject] = [] - if abs(y1 - y3) < 5.0 || abs(x1 - x3) < 5.0 { - for i in 0 ..< 10 { - let k = CGFloat(i) / CGFloat(10 - 1) - let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k - let y = sourcePoint.y * (1.0 - k) + targetPosition.y * k - keyframes.append(NSValue(cgPoint: CGPoint(x: x, y: y))) - } - } else { - 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)) - - 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))) - } + private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) { + guard let targetSnapshotView = targetView.snapshotContentTree(unhide: true) else { + completion() + return } - return keyframes - } - - private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, targetSnapshotView: UIView, hideNode: Bool, completion: @escaping () -> Void) { - let itemFrame: CGRect = itemNode.frame - let _ = itemFrame - let targetFrame = self.view.convert(targetView.convert(targetView.bounds, to: nil), from: nil) targetSnapshotView.frame = targetFrame @@ -503,7 +471,7 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.9, removeOnCompletion: false) targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.8) - targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 0.5, duration: duration, removeOnCompletion: false, completion: { [weak self, weak targetSnapshotView] _ in + targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 1.0, duration: duration, removeOnCompletion: false, completion: { [weak self, weak targetSnapshotView] _ in if let strongSelf = self { strongSelf.hapticFeedback.tap() } @@ -514,18 +482,18 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { if hideNode { targetView.isHidden = false - targetView.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration, initialVelocity: 0.0, damping: 90.0, completion: { _ in + /*targetView.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration, initialVelocity: 0.0, damping: 90.0, completion: { _ in*/ targetSnapshotView?.isHidden = true targetScaleCompleted = true intermediateCompletion() - }) + //}) } else { targetScaleCompleted = true intermediateCompletion() } }) - itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 0.5) / itemNode.bounds.width, duration: duration, removeOnCompletion: false) + itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 1.0) / itemNode.bounds.width, duration: duration, removeOnCompletion: false) } public func willAnimateOutToReaction(value: String) { @@ -542,67 +510,65 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { if itemNode.item.reaction.rawValue != value { continue } - if let targetSnapshotView = targetView.snapshotContentTree() { - if hideNode { - targetView.isHidden = true - } - - itemNode.isExtracted = true - let selfSourceRect = itemNode.view.convert(itemNode.view.bounds, to: self.view) - let selfTargetRect = self.view.convert(targetView.bounds, from: targetView) - - let expandedScale: CGFloat = 3.0 - let expandedSize = CGSize(width: floor(selfSourceRect.width * expandedScale), height: floor(selfSourceRect.height * expandedScale)) - - let expandedFrame = CGRect(origin: CGPoint(x: floor(selfTargetRect.midX - expandedSize.width / 2.0), y: floor(selfTargetRect.midY - expandedSize.height / 2.0)), size: expandedSize) - - let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .linear) - - self.addSubnode(itemNode) - itemNode.frame = selfSourceRect - itemNode.position = expandedFrame.center - transition.updateBounds(node: itemNode, bounds: CGRect(origin: CGPoint(), size: expandedFrame.size)) - itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, transition: transition) - transition.animatePositionWithKeyframes(node: itemNode, keyframes: self.generateParabollicMotionKeyframes(from: selfSourceRect.center, to: expandedFrame.center, elevation: 30.0)) - - let additionalAnimationNode = AnimatedStickerNode() - let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0 - let animationFrame = expandedFrame.insetBy(dx: -expandedFrame.width * 0.5, dy: -expandedFrame.height * 0.5) - .offsetBy(dx: incomingMessage ? (expandedFrame.width - 50.0) : (-expandedFrame.width + 50.0), dy: 0.0) - - additionalAnimationNode.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: itemNode.item.applicationAnimation.resource), width: Int(animationFrame.width * 2.0), height: Int(animationFrame.height * 2.0), playbackMode: .once, mode: .direct(cachePathPrefix: itemNode.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(itemNode.item.applicationAnimation.resource.id))) - additionalAnimationNode.frame = animationFrame - if incomingMessage { - additionalAnimationNode.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) - } - additionalAnimationNode.updateLayout(size: animationFrame.size) - self.addSubnode(additionalAnimationNode) - - var mainAnimationCompleted = false - var additionalAnimationCompleted = false - let intermediateCompletion: () -> Void = { - if mainAnimationCompleted && additionalAnimationCompleted { - completion() - } - } - - additionalAnimationNode.completed = { _ in - additionalAnimationCompleted = true - intermediateCompletion() - } - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1 * UIView.animationDurationFactor(), execute: { - additionalAnimationNode.visibility = true - }) - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0 * UIView.animationDurationFactor(), execute: { - self.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, targetSnapshotView: targetSnapshotView, hideNode: hideNode, completion: { - mainAnimationCompleted = true - intermediateCompletion() - }) - }) - return + if hideNode { + targetView.isHidden = true } + + itemNode.isExtracted = true + let selfSourceRect = itemNode.view.convert(itemNode.view.bounds, to: self.view) + let selfTargetRect = self.view.convert(targetView.bounds, from: targetView) + + let expandedScale: CGFloat = 3.0 + let expandedSize = CGSize(width: floor(selfSourceRect.width * expandedScale), height: floor(selfSourceRect.height * expandedScale)) + + let expandedFrame = CGRect(origin: CGPoint(x: floor(selfTargetRect.midX - expandedSize.width / 2.0), y: floor(selfTargetRect.midY - expandedSize.height / 2.0)), size: expandedSize) + + let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .linear) + + self.addSubnode(itemNode) + itemNode.frame = selfSourceRect + itemNode.position = expandedFrame.center + transition.updateBounds(node: itemNode, bounds: CGRect(origin: CGPoint(), size: expandedFrame.size)) + itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, transition: transition) + transition.animatePositionWithKeyframes(node: itemNode, keyframes: generateParabollicMotionKeyframes(from: selfSourceRect.center, to: expandedFrame.center, elevation: 30.0)) + + let additionalAnimationNode = AnimatedStickerNode() + let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0 + let animationFrame = expandedFrame.insetBy(dx: -expandedFrame.width * 0.5, dy: -expandedFrame.height * 0.5) + .offsetBy(dx: incomingMessage ? (expandedFrame.width - 50.0) : (-expandedFrame.width + 50.0), dy: 0.0) + + additionalAnimationNode.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: itemNode.item.applicationAnimation.resource), width: Int(animationFrame.width * 2.0), height: Int(animationFrame.height * 2.0), playbackMode: .once, mode: .direct(cachePathPrefix: itemNode.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(itemNode.item.applicationAnimation.resource.id))) + additionalAnimationNode.frame = animationFrame + if incomingMessage { + additionalAnimationNode.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) + } + additionalAnimationNode.updateLayout(size: animationFrame.size) + self.addSubnode(additionalAnimationNode) + + var mainAnimationCompleted = false + var additionalAnimationCompleted = false + let intermediateCompletion: () -> Void = { + if mainAnimationCompleted && additionalAnimationCompleted { + completion() + } + } + + additionalAnimationNode.completed = { _ in + additionalAnimationCompleted = true + intermediateCompletion() + } + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1 * UIView.animationDurationFactor(), execute: { + additionalAnimationNode.visibility = true + }) + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0 * UIView.animationDurationFactor(), execute: { + self.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: hideNode, completion: { + mainAnimationCompleted = true + intermediateCompletion() + }) + }) + return } completion() } @@ -638,6 +604,15 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { return nil } + public func performReactionSelection(reaction: ReactionContextItem.Reaction) { + for itemNode in self.itemNodes { + if itemNode.item.reaction == reaction { + self.reactionSelected?(itemNode.item) + break + } + } + } + public func setHighlightedReaction(_ value: ReactionContextItem.Reaction?) { self.highlightedReaction = value if let (size, insets, anchorRect) = self.validLayout { @@ -668,7 +643,7 @@ public final class StandaloneReactionAnimation: ASDisplayNode { } public func animateReactionSelection(targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) { - guard let targetSnapshotView = targetView.snapshotContentTree() else { + guard let sourceSnapshotView = targetView.snapshotContentTree() else { completion() return } @@ -685,12 +660,20 @@ public final class StandaloneReactionAnimation: ASDisplayNode { let expandedFrame = CGRect(origin: CGPoint(x: floor(selfTargetRect.midX - expandedSize.width / 2.0), y: floor(selfTargetRect.midY - expandedSize.height / 2.0)), size: expandedSize) + sourceSnapshotView.frame = selfTargetRect + self.view.addSubview(sourceSnapshotView) + sourceSnapshotView.alpha = 0.0 + sourceSnapshotView.layer.animateSpring(from: 1.0 as NSNumber, to: (expandedFrame.width / selfTargetRect.width) as NSNumber, keyPath: "transform.scale", duration: 0.4) + sourceSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08, completion: { [weak sourceSnapshotView] _ in + sourceSnapshotView?.removeFromSuperview() + }) + self.addSubnode(itemNode) itemNode.frame = expandedFrame itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, transition: .immediate) - itemNode.layer.animateScale(from: 0.1, to: 1.0, duration: 0.18) - itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) + itemNode.layer.animateSpring(from: (selfTargetRect.width / expandedFrame.width) as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4) + itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.04) let additionalAnimationNode = AnimatedStickerNode() let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0 @@ -724,17 +707,19 @@ public final class StandaloneReactionAnimation: ASDisplayNode { additionalAnimationNode.visibility = true }) - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0 * UIView.animationDurationFactor(), execute: { - self.animateFromItemNodeToReaction(itemNode: self.itemNode, targetView: targetView, targetSnapshotView: targetSnapshotView, hideNode: hideNode, completion: { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0, execute: { + self.animateFromItemNodeToReaction(itemNode: self.itemNode, targetView: targetView, hideNode: hideNode, completion: { mainAnimationCompleted = true intermediateCompletion() }) }) } - private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, targetSnapshotView: UIView, hideNode: Bool, completion: @escaping () -> Void) { - let itemFrame: CGRect = itemNode.frame - let _ = itemFrame + private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) { + guard let targetSnapshotView = targetView.snapshotContentTree(unhide: true) else { + completion() + return + } let targetFrame = self.view.convert(targetView.convert(targetView.bounds, to: nil), from: nil) @@ -755,7 +740,7 @@ public final class StandaloneReactionAnimation: ASDisplayNode { itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.9, removeOnCompletion: false) targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.8) - targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 0.5, duration: duration, removeOnCompletion: false, completion: { [weak self, weak targetSnapshotView] _ in + targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 1.0, duration: duration, removeOnCompletion: false, completion: { [weak self, weak targetSnapshotView] _ in if let strongSelf = self { strongSelf.hapticFeedback.tap() } @@ -766,18 +751,18 @@ public final class StandaloneReactionAnimation: ASDisplayNode { if hideNode { targetView.isHidden = false - targetView.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration, initialVelocity: 0.0, damping: 90.0, completion: { _ in + /*targetView.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: duration, initialVelocity: 0.0, damping: 90.0, completion: { _ in*/ targetSnapshotView?.isHidden = true targetScaleCompleted = true intermediateCompletion() - }) + //}) } else { targetScaleCompleted = true intermediateCompletion() } }) - itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 0.5) / itemNode.bounds.width, duration: duration, removeOnCompletion: false) + itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 1.0) / itemNode.bounds.width, duration: duration, removeOnCompletion: false) } public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { @@ -785,3 +770,84 @@ public final class StandaloneReactionAnimation: ASDisplayNode { transition.animateOffsetAdditive(node: self, offset: -offset.y) } } + +public final class StandaloneDismissReactionAnimation: ASDisplayNode { + private let hapticFeedback = HapticFeedback() + + override public init() { + super.init() + + self.isUserInteractionEnabled = false + } + + public func animateReactionDismiss(sourceView: UIView, hideNode: Bool, completion: @escaping () -> Void) { + guard let sourceSnapshotView = sourceView.snapshotContentTree() else { + completion() + return + } + if hideNode { + sourceView.isHidden = true + } + + let sourceRect = self.view.convert(sourceView.bounds, from: sourceView) + sourceSnapshotView.frame = sourceRect + self.view.addSubview(sourceSnapshotView) + + var targetOffset: CGFloat = 120.0 + if sourceRect.midX > self.bounds.width / 2.0 { + targetOffset = -targetOffset + } + let targetPoint = CGPoint(x: sourceRect.midX + targetOffset, y: sourceRect.midY) + + let hapticFeedback = self.hapticFeedback + hapticFeedback.prepareImpact(.soft) + + let keyframes = generateParabollicMotionKeyframes(from: sourceRect.center, to: targetPoint, elevation: 25.0) + let transition: ContainedViewLayoutTransition = .animated(duration: 0.18, curve: .easeInOut) + sourceSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.04, delay: 0.18 - 0.04, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak sourceSnapshotView, weak hapticFeedback] _ in + sourceSnapshotView?.removeFromSuperview() + hapticFeedback?.impact(.soft) + completion() + }) + transition.animatePositionWithKeyframes(layer: sourceSnapshotView.layer, keyframes: keyframes, removeOnCompletion: false) + } + + public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { + self.bounds = self.bounds.offsetBy(dx: 0.0, dy: offset.y) + transition.animateOffsetAdditive(node: self, offset: -offset.y) + } +} + +private func generateParabollicMotionKeyframes(from sourcePoint: CGPoint, to targetPosition: CGPoint, elevation: CGFloat) -> [AnyObject] { + let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - elevation) + + let x1 = sourcePoint.x + let y1 = sourcePoint.y + let x2 = midPoint.x + let y2 = midPoint.y + let x3 = targetPosition.x + let y3 = targetPosition.y + + var keyframes: [AnyObject] = [] + if abs(y1 - y3) < 5.0 && abs(x1 - x3) < 5.0 { + for i in 0 ..< 10 { + let k = CGFloat(i) / CGFloat(10 - 1) + let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k + let y = sourcePoint.y * (1.0 - k) + targetPosition.y * k + keyframes.append(NSValue(cgPoint: CGPoint(x: x, y: y))) + } + } else { + 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)) + + 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))) + } + } + + return keyframes +} diff --git a/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift index fdb54662f2..14335c656a 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift @@ -60,8 +60,6 @@ final class ReactionNode: ASDisplayNode { super.init() - //self.backgroundColor = UIColor(white: 0.0, alpha: 0.1) - self.addSubnode(self.staticImageNode) self.addSubnode(self.stillAnimationNode) @@ -120,11 +118,14 @@ final class ReactionNode: ASDisplayNode { animationNode.visibility = true self.stillAnimationNode.alpha = 0.0 - self.stillAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in - self?.stillAnimationNode.visibility = false - }) - - animationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + if transition.isAnimated { + self.stillAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in + self?.stillAnimationNode.visibility = false + }) + animationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } else { + self.stillAnimationNode.visibility = false + } } if self.validSize != size { @@ -137,13 +138,15 @@ final class ReactionNode: ASDisplayNode { } if !self.didSetupStillAnimation { - self.didSetupStillAnimation = true - - self.stillAnimationNode.setup(source: AnimatedStickerResourceSource(account: self.context.account, resource: self.item.stillAnimation.resource), width: Int(animationDisplaySize.width * 2.0), height: Int(animationDisplaySize.height * 2.0), playbackMode: .loop, mode: .direct(cachePathPrefix: self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(self.item.stillAnimation.resource.id))) - self.stillAnimationNode.position = animationFrame.center - self.stillAnimationNode.bounds = CGRect(origin: CGPoint(), size: animationFrame.size) - self.stillAnimationNode.updateLayout(size: animationFrame.size) - self.stillAnimationNode.visibility = true + if self.animationNode == nil { + self.didSetupStillAnimation = true + + self.stillAnimationNode.setup(source: AnimatedStickerResourceSource(account: self.context.account, resource: self.item.stillAnimation.resource), width: Int(animationDisplaySize.width * 2.0), height: Int(animationDisplaySize.height * 2.0), playbackMode: .loop, mode: .direct(cachePathPrefix: self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(self.item.stillAnimation.resource.id))) + self.stillAnimationNode.position = animationFrame.center + self.stillAnimationNode.bounds = CGRect(origin: CGPoint(), size: animationFrame.size) + self.stillAnimationNode.updateLayout(size: animationFrame.size) + self.stillAnimationNode.visibility = true + } } else { transition.updatePosition(node: self.stillAnimationNode, position: animationFrame.center) transition.updateTransformScale(node: self.stillAnimationNode, scale: animationFrame.size.width / self.stillAnimationNode.bounds.width) diff --git a/submodules/TelegramCallsUI/Sources/PresentationCall.swift b/submodules/TelegramCallsUI/Sources/PresentationCall.swift index 56cfa62c67..74f2c80a7c 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationCall.swift @@ -613,7 +613,7 @@ public final class PresentationCallImpl: PresentationCall { self.callKitIntegration?.reportIncomingCall( uuid: self.internalId, stableId: stableId, - handle: "\(self.peerId.id)", + handle: "\(self.peerId.id._internalGetInt64Value())", isVideo: sessionState.type == .video, displayTitle: self.peer?.debugDisplayTitle ?? "Unknown", completion: { [weak self] error in diff --git a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift index 99c1264e63..478dd24bf5 100644 --- a/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift +++ b/submodules/TelegramCore/Sources/Account/AccountIntermediateState.swift @@ -65,7 +65,7 @@ enum AccountStateMutationOperation { case DeleteMessages([MessageId]) case EditMessage(MessageId, StoreMessage) case UpdateMessagePoll(MediaId, Api.Poll?, Api.PollResults) - //case UpdateMessageReactions(MessageId, Api.MessageReactions) + case UpdateMessageReactions(MessageId, Api.MessageReactions) case UpdateMedia(MediaId, Media?) case ReadInbox(MessageId) case ReadOutbox(MessageId, Int32?) @@ -258,9 +258,9 @@ struct AccountMutableState { self.addOperation(.UpdateMessagePoll(id, poll, results)) } - /*mutating func updateMessageReactions(_ messageId: MessageId, reactions: Api.MessageReactions) { + 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)) @@ -498,7 +498,7 @@ struct AccountMutableState { mutating func addOperation(_ operation: AccountStateMutationOperation) { switch operation { - case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll/*, .UpdateMessageReactions*/, .UpdateMedia, .ReadOutbox, .ReadGroupFeedInbox, .MergePeerPresences, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdatePeerChatUnreadMark, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilterOrder, .UpdateChatListFilter, .UpdateReadThread, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateMessagesPinned, .UpdateAutoremoveTimeout: + case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .ReadOutbox, .ReadGroupFeedInbox, .MergePeerPresences, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdatePeerChatUnreadMark, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilterOrder, .UpdateChatListFilter, .UpdateReadThread, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateMessagesPinned, .UpdateAutoremoveTimeout: break case let .AddMessages(messages, location): for message in messages { diff --git a/submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift b/submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift index 2a4b008e46..d7a3d850fa 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/ReactionsMessageAttribute.swift @@ -5,7 +5,7 @@ import TelegramApi extension ReactionsMessageAttribute { func withUpdatedResults(_ reactions: Api.MessageReactions) -> ReactionsMessageAttribute { switch reactions { - case let .messageReactions(flags, results, _): + case let .messageReactions(flags, results, recentReactions): let min = (flags & (1 << 0)) != 0 var reactions = results.map { result -> MessageReaction in switch result { @@ -13,6 +13,18 @@ extension ReactionsMessageAttribute { return MessageReaction(value: reaction, count: count, isSelected: (flags & (1 << 0)) != 0) } } + let parsedRecentReactions: [ReactionsMessageAttribute.RecentPeer] + if let recentReactions = recentReactions { + parsedRecentReactions = recentReactions.map { recentReaction -> ReactionsMessageAttribute.RecentPeer in + switch recentReaction { + case let .messageUserReaction(userId, reaction): + return ReactionsMessageAttribute.RecentPeer(value: reaction, peerId: PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId))) + } + } + } else { + parsedRecentReactions = [] + } + if min { var currentSelectedReaction: String? for reaction in self.reactions { @@ -29,7 +41,7 @@ extension ReactionsMessageAttribute { } } } - return ReactionsMessageAttribute(reactions: reactions) + return ReactionsMessageAttribute(reactions: reactions, recentPeers: parsedRecentReactions) } } } @@ -47,6 +59,7 @@ public func mergedMessageReactions(attributes: [MessageAttribute]) -> ReactionsM if let pending = pending { var reactions = current?.reactions ?? [] + let recentPeers = current?.recentPeers ?? [] if let value = pending.value { var found = false for i in 0 ..< reactions.count { @@ -73,7 +86,7 @@ public func mergedMessageReactions(attributes: [MessageAttribute]) -> ReactionsM } } if !reactions.isEmpty { - return ReactionsMessageAttribute(reactions: reactions) + return ReactionsMessageAttribute(reactions: reactions, recentPeers: recentPeers) } else { return nil } @@ -87,13 +100,28 @@ public func mergedMessageReactions(attributes: [MessageAttribute]) -> ReactionsM 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) + case let .messageReactions(_, results, recentReactions): + let parsedRecentReactions: [ReactionsMessageAttribute.RecentPeer] + if let recentReactions = recentReactions { + parsedRecentReactions = recentReactions.map { recentReaction -> ReactionsMessageAttribute.RecentPeer in + switch recentReaction { + case let .messageUserReaction(userId, reaction): + return ReactionsMessageAttribute.RecentPeer(value: reaction, peerId: PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userId))) + } } - }) + } else { + parsedRecentReactions = [] + } + + 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) + } + }, + recentPeers: parsedRecentReactions + ) } } } diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 4eaf554904..9893bb20e7 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -1473,6 +1473,8 @@ private func finalStateWithUpdatesAndServerTime(postbox: Postbox, network: Netwo return current } }) + case let .updateMessageReactions(peer, msgId, reactions): + updatedState.updateMessageReactions(MessageId(peerId: peer.peerId, namespace: Namespaces.Message.Cloud, id: msgId), reactions: reactions) default: break } @@ -2260,7 +2262,7 @@ private func optimizedOperations(_ operations: [AccountStateMutationOperation]) var currentAddScheduledMessages: OptimizeAddMessagesState? for operation in operations { switch operation { - case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll/*, .UpdateMessageReactions*/, .UpdateMedia, .MergeApiChats, .MergeApiUsers, .MergePeerPresences, .UpdatePeer, .ReadInbox, .ReadOutbox, .ReadGroupFeedInbox, .ResetReadState, .ResetIncomingReadState, .UpdatePeerChatUnreadMark, .ResetMessageTagSummary, .UpdateNotificationSettings, .UpdateGlobalNotificationSettings, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilter, .UpdateChatListFilterOrder, .UpdateReadThread, .UpdateMessagesPinned, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateAutoremoveTimeout: + case .DeleteMessages, .DeleteMessagesWithGlobalIds, .EditMessage, .UpdateMessagePoll, .UpdateMessageReactions, .UpdateMedia, .MergeApiChats, .MergeApiUsers, .MergePeerPresences, .UpdatePeer, .ReadInbox, .ReadOutbox, .ReadGroupFeedInbox, .ResetReadState, .ResetIncomingReadState, .UpdatePeerChatUnreadMark, .ResetMessageTagSummary, .UpdateNotificationSettings, .UpdateGlobalNotificationSettings, .UpdateSecretChat, .AddSecretMessages, .ReadSecretOutbox, .AddPeerInputActivity, .UpdateCachedPeerData, .UpdatePinnedItemIds, .ReadMessageContents, .UpdateMessageImpressionCount, .UpdateMessageForwardsCount, .UpdateInstalledStickerPacks, .UpdateRecentGifs, .UpdateChatInputState, .UpdateCall, .AddCallSignalingData, .UpdateLangPack, .UpdateMinAvailableMessage, .UpdateIsContact, .UpdatePeerChatInclusion, .UpdatePeersNearby, .UpdateTheme, .SyncChatListFilters, .UpdateChatListFilter, .UpdateChatListFilterOrder, .UpdateReadThread, .UpdateMessagesPinned, .UpdateGroupCallParticipants, .UpdateGroupCall, .UpdateAutoremoveTimeout: if let currentAddMessages = currentAddMessages, !currentAddMessages.messages.isEmpty { result.append(.AddMessages(currentAddMessages.messages, currentAddMessages.location)) } @@ -3222,6 +3224,31 @@ func replayFinalState( return state }) } + case let .UpdateMessageReactions(messageId, reactions): + transaction.updateMessage(messageId, update: { currentMessage in + var updatedReactions = ReactionsMessageAttribute(apiReactions: reactions) + + let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) + var attributes = currentMessage.attributes + var added = false + loop: for j in 0 ..< attributes.count { + if let attribute = attributes[j] as? ReactionsMessageAttribute { + added = true + updatedReactions = attribute.withUpdatedResults(reactions) + + if updatedReactions.reactions == attribute.reactions { + return .skip + } + attributes[j] = updatedReactions + break loop + } + } + if !added { + attributes.append(updatedReactions) + } + + 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)) + }) } } diff --git a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift index f903311bfa..3d44d900cd 100644 --- a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift @@ -856,13 +856,16 @@ public final class AccountViewTracker { 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) + var updatedReactions = ReactionsMessageAttribute(apiReactions: reactions) let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) + var added = false var attributes = currentMessage.attributes loop: for j in 0 ..< attributes.count { if let attribute = attributes[j] as? ReactionsMessageAttribute { + added = true + updatedReactions = attribute.withUpdatedResults(reactions) + if updatedReactions.reactions == attribute.reactions { return .skip } @@ -870,6 +873,9 @@ public final class AccountViewTracker { break loop } } + if !added { + attributes.append(updatedReactions) + } 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)) }) default: diff --git a/submodules/TelegramCore/Sources/State/SynchronizeEmojiKeywordsOperation.swift b/submodules/TelegramCore/Sources/State/SynchronizeEmojiKeywordsOperation.swift index a8a57dd5af..a99610cfc9 100644 --- a/submodules/TelegramCore/Sources/State/SynchronizeEmojiKeywordsOperation.swift +++ b/submodules/TelegramCore/Sources/State/SynchronizeEmojiKeywordsOperation.swift @@ -5,7 +5,7 @@ import MurMurHash32 func addSynchronizeEmojiKeywordsOperation(transaction: Transaction, inputLanguageCode: String, languageCode: String?, fromVersion: Int32?) { let tag = OperationLogTags.SynchronizeEmojiKeywords - let peerId = PeerId(namespace: PeerId.Namespace._internalFromInt32Value(1), id: PeerId.Id._internalFromInt64Value(Int64(murMurHashString32(inputLanguageCode)))) + let peerId = PeerId(namespace: PeerId.Namespace._internalFromInt32Value(1), id: PeerId.Id._internalFromInt64Value(Int64(abs(murMurHashString32(inputLanguageCode))))) var hasExistingOperation = false transaction.operationLogEnumerateEntries(peerId: peerId, tag: tag) { entry -> Bool in diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift index e281a8614f..f575e7adb0 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_ReactionsMessageAttribute.swift @@ -6,6 +6,12 @@ public struct MessageReaction: Equatable, PostboxCoding { public var isSelected: Bool public init(value: String, count: Int32, isSelected: Bool) { + var value = value + + if value == "❤️" { + value = "❤" + } + self.value = value self.count = count self.isSelected = isSelected @@ -24,19 +30,53 @@ public struct MessageReaction: Equatable, PostboxCoding { } } -public final class ReactionsMessageAttribute: MessageAttribute { - public let reactions: [MessageReaction] +public final class ReactionsMessageAttribute: Equatable, MessageAttribute { + public struct RecentPeer: Equatable, PostboxCoding { + public var value: String + public var peerId: PeerId + + public init(value: String, peerId: PeerId) { + self.value = value + self.peerId = peerId + } + + public init(decoder: PostboxDecoder) { + self.value = decoder.decodeStringForKey("v", orElse: "") + self.peerId = PeerId(decoder.decodeInt64ForKey("p", orElse: 0)) + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(self.value, forKey: "v") + encoder.encodeInt64(self.peerId.toInt64(), forKey: "p") + } + } - public init(reactions: [MessageReaction]) { + public let reactions: [MessageReaction] + public let recentPeers: [RecentPeer] + + public init(reactions: [MessageReaction], recentPeers: [RecentPeer]) { self.reactions = reactions + self.recentPeers = recentPeers } required public init(decoder: PostboxDecoder) { self.reactions = decoder.decodeObjectArrayWithDecoderForKey("r") + self.recentPeers = decoder.decodeObjectArrayWithDecoderForKey("rp") } public func encode(_ encoder: PostboxEncoder) { encoder.encodeObjectArray(self.reactions, forKey: "r") + encoder.encodeObjectArray(self.recentPeers, forKey: "rp") + } + + public static func ==(lhs: ReactionsMessageAttribute, rhs: ReactionsMessageAttribute) -> Bool { + if lhs.reactions != rhs.reactions { + return false + } + if lhs.recentPeers != rhs.recentPeers { + return false + } + return true } } diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 1d31cf59a8..58ce4b2f49 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -1544,7 +1544,7 @@ private func extractAccountManagerState(records: AccountRecordsView 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()) - } - }*/ }) } }) @@ -1096,79 +1074,125 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.window?.presentInGlobalOverlay(controller) }) } - }, updateMessageReaction: { [weak self] message, value in + }, updateMessageReaction: { [weak self] initialMessage, reaction in guard let strongSelf = self else { return } - + guard let messages = strongSelf.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(initialMessage.id) else { + return + } + guard let message = messages.first else { + return + } if !canAddMessageReactions(message: message) { return } - var updatedReaction: String? = value - for attribute in message.attributes { - if let attribute = attribute as? ReactionsMessageAttribute { - for reaction in attribute.reactions { - if reaction.value == updatedReaction { - if reaction.isSelected { + strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in + guard let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item else { + return + } + guard item.message.id == message.id else { + return + } + + var updatedReaction: String? + switch reaction { + case .default: + updatedReaction = item.associatedData.defaultReaction + case let .reaction(value): + updatedReaction = value + } + + var removedReaction: String? + + for attribute in message.attributes { + if let attribute = attribute as? ReactionsMessageAttribute { + for listReaction in attribute.reactions { + switch reaction { + case .default: + if listReaction.isSelected { + updatedReaction = nil + removedReaction = listReaction.value + } + case let .reaction(value): + if listReaction.value == value && listReaction.isSelected { + updatedReaction = nil + removedReaction = value + } + } + } + } else if let attribute = attribute as? PendingReactionsMessageAttribute { + if attribute.value != nil { + switch reaction { + case .default: updatedReaction = nil + removedReaction = attribute.value + case let .reaction(value): + if attribute.value == value { + updatedReaction = nil + removedReaction = value + } } } } - } else if let attribute = attribute as? PendingReactionsMessageAttribute { - if let current = attribute.value, current == updatedReaction { - updatedReaction = nil - } } - } - - if updatedReaction != nil { - strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? ChatMessageItemView, let item = itemNode.item { - if item.message.id == message.id { - itemNode.awaitingAppliedReaction = (updatedReaction, { [weak itemNode] in - guard let strongSelf = self else { - return - } - if let updatedReaction = updatedReaction, let itemNode = itemNode, let item = itemNode.item, let availableReactions = item.associatedData.availableReactions, let targetView = itemNode.targetReactionView(value: updatedReaction) { - for reaction in availableReactions.reactions { - if reaction.value == updatedReaction { - let standaloneReactionAnimation = StandaloneReactionAnimation(context: strongSelf.context, theme: strongSelf.presentationData.theme, reaction: ReactionContextItem( - reaction: ReactionContextItem.Reaction(rawValue: reaction.value), - stillAnimation: reaction.selectAnimation, - listAnimation: reaction.activateAnimation, - applicationAnimation: reaction.effectAnimation - )) - - strongSelf.currentStandaloneReactionAnimation = standaloneReactionAnimation - strongSelf.currentStandaloneReactionItemNode = itemNode - - strongSelf.chatDisplayNode.addSubnode(standaloneReactionAnimation) - standaloneReactionAnimation.frame = strongSelf.chatDisplayNode.bounds - standaloneReactionAnimation.animateReactionSelection(targetView: targetView, hideNode: true, completion: { [weak standaloneReactionAnimation] in - standaloneReactionAnimation?.removeFromSupernode() - }) - - break - } - } + + if let updatedReaction = updatedReaction { + if strongSelf.selectPollOptionFeedback == nil { + strongSelf.selectPollOptionFeedback = HapticFeedback() + } + strongSelf.selectPollOptionFeedback?.tap() + + itemNode.awaitingAppliedReaction = (updatedReaction, { [weak itemNode] in + guard let strongSelf = self else { + return + } + if let itemNode = itemNode, let item = itemNode.item, let availableReactions = item.associatedData.availableReactions, let targetView = itemNode.targetReactionView(value: updatedReaction) { + for reaction in availableReactions.reactions { + if reaction.value == updatedReaction { + let standaloneReactionAnimation = StandaloneReactionAnimation(context: strongSelf.context, theme: strongSelf.presentationData.theme, reaction: ReactionContextItem( + reaction: ReactionContextItem.Reaction(rawValue: reaction.value), + stillAnimation: reaction.selectAnimation, + listAnimation: reaction.activateAnimation, + applicationAnimation: reaction.effectAnimation + )) - /*targetView.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 0.0, damping: 90.0) + strongSelf.currentStandaloneReactionAnimation = standaloneReactionAnimation + strongSelf.currentStandaloneReactionItemNode = itemNode - if let strongSelf = self { - if strongSelf.selectPollOptionFeedback == nil { - strongSelf.selectPollOptionFeedback = HapticFeedback() - } - strongSelf.selectPollOptionFeedback?.tap() - }*/ + strongSelf.chatDisplayNode.addSubnode(standaloneReactionAnimation) + standaloneReactionAnimation.frame = strongSelf.chatDisplayNode.bounds + standaloneReactionAnimation.animateReactionSelection(targetView: targetView, hideNode: true, completion: { [weak standaloneReactionAnimation] in + standaloneReactionAnimation?.removeFromSupernode() + }) + + break } - }) + } + } + }) + } else if let removedReaction = removedReaction, let targetView = itemNode.targetReactionView(value: removedReaction), shouldDisplayInlineDateReactions(message: message) { + var hideRemovedReaction: Bool = false + if let reactions = mergedMessageReactions(attributes: message.attributes) { + for reaction in reactions.reactions { + if reaction.value == removedReaction { + hideRemovedReaction = reaction.count == 1 + break + } } } + + let standaloneDismissAnimation = StandaloneDismissReactionAnimation() + standaloneDismissAnimation.frame = strongSelf.chatDisplayNode.bounds + strongSelf.chatDisplayNode.addSubnode(standaloneDismissAnimation) + standaloneDismissAnimation.animateReactionDismiss(sourceView: targetView, hideNode: hideRemovedReaction, completion: { [weak standaloneDismissAnimation] in + standaloneDismissAnimation?.removeFromSupernode() + }) } + + let _ = updateMessageReactionsInteractively(postbox: strongSelf.context.account.postbox, messageId: message.id, reaction: updatedReaction).start() } - - let _ = updateMessageReactionsInteractively(postbox: strongSelf.context.account.postbox, messageId: message.id, reaction: updatedReaction).start() }, activateMessagePinch: { [weak self] sourceNode in guard let strongSelf = self else { return diff --git a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift index cdd31605cc..087df11a59 100644 --- a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift @@ -46,12 +46,17 @@ public enum ChatControllerInteractionSwipeAction { case reply } +public enum ChatControllerInteractionReaction { + case `default` + case reaction(String) +} + public final class ChatControllerInteraction { let openMessage: (Message, ChatControllerInteractionOpenMessageMode) -> Bool let openPeer: (PeerId?, ChatControllerInteractionNavigateToPeer, Message?) -> Void let openPeerMention: (String) -> Void let openMessageContextMenu: (Message, Bool, ASDisplayNode, CGRect, UIGestureRecognizer?) -> Void - let updateMessageReaction: (Message, String) -> Void + let updateMessageReaction: (Message, ChatControllerInteractionReaction) -> Void let activateMessagePinch: (PinchSourceContainerNode) -> Void let openMessageContextActions: (Message, ASDisplayNode, CGRect, ContextGesture?) -> Void let navigateToMessage: (MessageId, MessageId) -> Void @@ -148,7 +153,7 @@ public final class ChatControllerInteraction { openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer, Message?) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (Message, Bool, ASDisplayNode, CGRect, UIGestureRecognizer?) -> Void, - updateMessageReaction: @escaping (Message, String) -> Void, + updateMessageReaction: @escaping (Message, ChatControllerInteractionReaction) -> Void, activateMessagePinch: @escaping (PinchSourceContainerNode) -> Void, openMessageContextActions: @escaping (Message, ASDisplayNode, CGRect, ContextGesture?) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, diff --git a/submodules/TelegramUI/Sources/ChatMessageActionButtonsNode.swift b/submodules/TelegramUI/Sources/ChatMessageActionButtonsNode.swift index ee7a76074c..d41796e3cd 100644 --- a/submodules/TelegramUI/Sources/ChatMessageActionButtonsNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageActionButtonsNode.swift @@ -38,8 +38,6 @@ private final class ChatMessageActionButtonNode: ASDisplayNode { self.addSubnode(self.backgroundBlurNode) self.addSubnode(self.accessibilityArea) - - //self.backgroundBlurNode.view.mask = backgroundMaskNode.view self.accessibilityArea.activate = { [weak self] in self?.buttonPressed() @@ -85,7 +83,7 @@ private final class ChatMessageActionButtonNode: ASDisplayNode { } } - class func asyncLayout(_ maybeNode: ChatMessageActionButtonNode?) -> (_ context: AccountContext, _ theme: ChatPresentationThemeData, _ bubbleCorners: PresentationChatBubbleCorners, _ strings: PresentationStrings, _ message: Message, _ button: ReplyMarkupButton, _ constrainedWidth: CGFloat, _ position: MessageBubbleActionButtonPosition) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, () -> ChatMessageActionButtonNode))) { + class func asyncLayout(_ maybeNode: ChatMessageActionButtonNode?) -> (_ context: AccountContext, _ theme: ChatPresentationThemeData, _ bubbleCorners: PresentationChatBubbleCorners, _ strings: PresentationStrings, _ message: Message, _ button: ReplyMarkupButton, _ constrainedWidth: CGFloat, _ position: MessageBubbleActionButtonPosition) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonNode))) { let titleLayout = TextNode.asyncLayout(maybeNode?.titleNode) return { context, theme, bubbleCorners, strings, message, button, constrainedWidth, position in @@ -142,12 +140,15 @@ private final class ChatMessageActionButtonNode: ASDisplayNode { } return (titleSize.size.width + sideInset + sideInset, { width in - return (CGSize(width: width, height: 42.0), { + return (CGSize(width: width, height: 42.0), { animation in + var animation = animation + let node: ChatMessageActionButtonNode if let maybeNode = maybeNode { node = maybeNode } else { node = ChatMessageActionButtonNode() + animation = .None } node.button = button @@ -160,10 +161,10 @@ private final class ChatMessageActionButtonNode: ASDisplayNode { } node.backgroundMaskNode.image = backgroundMaskImage - node.backgroundMaskNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: max(0.0, width), height: 42.0)) + animation.animator.updateFrame(layer: node.backgroundMaskNode.layer, frame: CGRect(origin: CGPoint(), size: CGSize(width: max(0.0, width), height: 42.0)), completion: nil) - node.backgroundBlurNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: max(0.0, width), height: 42.0)) - node.backgroundBlurNode.update(size: node.backgroundBlurNode.bounds.size, cornerRadius: bubbleCorners.auxiliaryRadius, transition: .immediate) + animation.animator.updateFrame(layer: node.backgroundBlurNode.layer, frame: CGRect(origin: CGPoint(), size: CGSize(width: max(0.0, width), height: 42.0)), completion: nil) + node.backgroundBlurNode.update(size: node.backgroundBlurNode.bounds.size, cornerRadius: bubbleCorners.auxiliaryRadius, animator: animation.animator) node.backgroundBlurNode.updateColor(color: selectDateFillStaticColor(theme: theme.theme, wallpaper: theme.wallpaper), enableBlur: dateFillNeedsBlur(theme: theme.theme, wallpaper: theme.wallpaper), transition: .immediate) if iconImage != nil { @@ -185,11 +186,17 @@ private final class ChatMessageActionButtonNode: ASDisplayNode { node.addSubnode(titleNode) titleNode.isUserInteractionEnabled = false } - titleNode.frame = CGRect(origin: CGPoint(x: floor((width - titleSize.size.width) / 2.0), y: floor((42.0 - titleSize.size.height) / 2.0) + 1.0), size: titleSize.size) + let titleFrame = CGRect(origin: CGPoint(x: floor((width - titleSize.size.width) / 2.0), y: floor((42.0 - titleSize.size.height) / 2.0) + 1.0), size: titleSize.size) + titleNode.layer.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + animation.animator.updatePosition(layer: titleNode.layer, position: titleFrame.center, completion: nil) - node.buttonView?.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: 42.0)) - node.iconNode?.frame = CGRect(x: width - 16.0, y: 4.0, width: 12.0, height: 12.0) + if let buttonView = node.buttonView { + animation.animator.updateFrame(layer: buttonView.layer, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: 42.0)), completion: nil) + } + if let iconNode = node.iconNode { + animation.animator.updateFrame(layer: iconNode.layer, frame: CGRect(origin: CGPoint(), size: CGSize(width: width, height: 42.0)), completion: nil) + } node.accessibilityArea.accessibilityLabel = title node.accessibilityArea.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: 42.0)) @@ -225,7 +232,7 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { } } - class func asyncLayout(_ maybeNode: ChatMessageActionButtonsNode?) -> (_ context: AccountContext, _ theme: ChatPresentationThemeData, _ chatBubbleCorners: PresentationChatBubbleCorners, _ strings: PresentationStrings, _ replyMarkup: ReplyMarkupMessageAttribute, _ message: Message, _ constrainedWidth: CGFloat) -> (minWidth: CGFloat, layout: (CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode)) { + class func asyncLayout(_ maybeNode: ChatMessageActionButtonsNode?) -> (_ context: AccountContext, _ theme: ChatPresentationThemeData, _ chatBubbleCorners: PresentationChatBubbleCorners, _ strings: PresentationStrings, _ replyMarkup: ReplyMarkupMessageAttribute, _ message: Message, _ constrainedWidth: CGFloat) -> (minWidth: CGFloat, layout: (CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)) { let currentButtonLayouts = maybeNode?.buttonNodes.map { ChatMessageActionButtonNode.asyncLayout($0) } ?? [] return { context, theme, chatBubbleCorners, strings, replyMarkup, message, constrainedWidth in @@ -234,14 +241,14 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { var overallMinimumRowWidth: CGFloat = 0.0 - var finalizeRowLayouts: [[((CGFloat) -> (CGSize, () -> ChatMessageActionButtonNode))]] = [] + var finalizeRowLayouts: [[((CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonNode))]] = [] var rowIndex = 0 var buttonIndex = 0 for row in replyMarkup.rows { var maximumRowButtonWidth: CGFloat = 0.0 let maximumButtonWidth: CGFloat = max(1.0, floor((constrainedWidth - CGFloat(max(0, row.buttons.count - 1)) * buttonSpacing) / CGFloat(row.buttons.count))) - var finalizeRowButtonLayouts: [((CGFloat) -> (CGSize, () -> ChatMessageActionButtonNode))] = [] + var finalizeRowButtonLayouts: [((CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonNode))] = [] var rowButtonIndex = 0 for button in row.buttons { let buttonPosition: MessageBubbleActionButtonPosition @@ -259,7 +266,7 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { buttonPosition = .middle } - let prepareButtonLayout: (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, () -> ChatMessageActionButtonNode))) + let prepareButtonLayout: (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonNode))) if buttonIndex < currentButtonLayouts.count { prepareButtonLayout = currentButtonLayouts[buttonIndex](context, theme, chatBubbleCorners, strings, message, button, maximumButtonWidth, buttonPosition) } else { @@ -280,7 +287,7 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { } return (min(constrainedWidth, overallMinimumRowWidth), { constrainedWidth in - var buttonFramesAndApply: [(CGRect, () -> ChatMessageActionButtonNode)] = [] + var buttonFramesAndApply: [(CGRect, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonNode)] = [] var verticalRowOffset: CGFloat = 0.0 verticalRowOffset += buttonSpacing @@ -303,7 +310,7 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { verticalRowOffset = max(0.0, verticalRowOffset - buttonSpacing) } - return (CGSize(width: constrainedWidth, height: verticalRowOffset), { animated in + return (CGSize(width: constrainedWidth, height: verticalRowOffset), { animation in let node: ChatMessageActionButtonsNode if let maybeNode = maybeNode { node = maybeNode @@ -314,13 +321,15 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { var updatedButtons: [ChatMessageActionButtonNode] = [] var index = 0 for (buttonFrame, buttonApply) in buttonFramesAndApply { - let buttonNode = buttonApply() - buttonNode.frame = buttonFrame + let buttonNode = buttonApply(animation) updatedButtons.append(buttonNode) if buttonNode.supernode == nil { node.addSubnode(buttonNode) buttonNode.pressed = node.buttonPressedWrapper buttonNode.longTapped = node.buttonLongTappedWrapper + buttonNode.frame = buttonFrame + } else { + animation.animator.updateFrame(layer: buttonNode.layer, frame: buttonFrame, completion: nil) } index += 1 } @@ -345,12 +354,6 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { } node.buttonNodes = updatedButtons - if animated { - /*UIView.transition(with: node.view, duration: 0.2, options: [.transitionCrossDissolve], animations: { - - }, completion: nil)*/ - } - return node }) }) diff --git a/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift index 2937ace7e5..e9bdaa240c 100644 --- a/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -231,6 +231,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { private var replyBackgroundNode: NavigationBackgroundNode? private var actionButtonsNode: ChatMessageActionButtonsNode? + private var reactionButtonsNode: ChatMessageReactionButtonsNode? private let messageAccessibilityArea: AccessibilityAreaNode @@ -373,7 +374,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } } } - return .waitForSingleTap + return .waitForDoubleTap } recognizer.longTap = { [weak self] point, recognizer in guard let strongSelf = self else { @@ -754,6 +755,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { let imageLayout = self.imageNode.asyncLayout() let makeDateAndStatusLayout = self.dateAndStatusNode.asyncLayout() let actionButtonsLayout = ChatMessageActionButtonsNode.asyncLayout(self.actionButtonsNode) + let reactionButtonsLayout = ChatMessageReactionButtonsNode.asyncLayout(self.reactionButtonsNode) let makeForwardInfoLayout = ChatMessageForwardInfoNode.asyncLayout(self.forwardInfoNode) @@ -951,7 +953,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .standalone, + layoutInput: .standalone(reactionSettings: shouldDisplayInlineDateReactions(message: item.message) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil), constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -1076,22 +1078,53 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } var maxContentWidth = imageSize.width - var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode))? + var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode))? if let replyMarkup = replyMarkup { let (minWidth, buttonsLayout) = actionButtonsLayout(item.context, item.presentationData.theme, item.presentationData.chatBubbleCorners, item.presentationData.strings, replyMarkup, item.message, maxContentWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout } - var actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)? + var actionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)? if let actionButtonsFinalize = actionButtonsFinalize { actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) } + let reactions: ReactionsMessageAttribute + if shouldDisplayInlineDateReactions(message: item.message) { + reactions = ReactionsMessageAttribute(reactions: [], recentPeers: []) + } else { + reactions = mergedMessageReactions(attributes: item.message.attributes) ?? ReactionsMessageAttribute(reactions: [], recentPeers: []) + } + var reactionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode))? + if !reactions.reactions.isEmpty { + let totalInset = params.leftInset + layoutConstants.bubble.edgeInset * 2.0 + avatarInset + layoutConstants.bubble.contentInsets.left + params.rightInset + layoutConstants.bubble.contentInsets.right + + let maxReactionsWidth = params.width - totalInset + let (minWidth, buttonsLayout) = reactionButtonsLayout(ChatMessageReactionButtonsNode.Arguments( + context: item.context, + presentationData: item.presentationData, + availableReactions: item.associatedData.availableReactions, + reactions: reactions, + isIncoming: item.message.effectivelyIncoming(item.context.account.peerId), + constrainedWidth: maxReactionsWidth + )) + maxContentWidth = max(maxContentWidth, minWidth) + reactionButtonsFinalize = buttonsLayout + } + + var reactionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)? + if let reactionButtonsFinalize = reactionButtonsFinalize { + reactionButtonsSizeAndApply = reactionButtonsFinalize(maxContentWidth) + } + var layoutSize = CGSize(width: params.width, height: contentHeight) if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { layoutSize.height += actionButtonsSizeAndApply.0.height } + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + layoutSize.height += reactionButtonsSizeAndApply.0.height + } return (ListViewItemNodeLayout(contentSize: layoutSize, insets: layoutInsets), { [weak self] animation, _, _ in if let strongSelf = self { @@ -1104,7 +1137,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { strongSelf.contextSourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: layoutSize) var transition: ContainedViewLayoutTransition = .immediate - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { if let subject = item.associatedData.subject, case .forwardedMessages = subject { transition = .animated(duration: duration, curve: .linear) } else { @@ -1177,8 +1210,9 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { strongSelf.shareButtonNode = nil } - dateAndStatusApply(false) - strongSelf.dateAndStatusNode.frame = CGRect(origin: CGPoint(x: max(displayLeftInset, updatedImageFrame.maxX - dateAndStatusSize.width - 4.0), y: updatedImageFrame.maxY - dateAndStatusSize.height - 4.0), size: dateAndStatusSize) + let dateAndStatusFrame = CGRect(origin: CGPoint(x: max(displayLeftInset, updatedImageFrame.maxX - dateAndStatusSize.width - 4.0), y: updatedImageFrame.maxY - dateAndStatusSize.height - 4.0), size: dateAndStatusSize) + animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: dateAndStatusFrame, completion: nil) + dateAndStatusApply(animation) if needsReplyBackground { if let replyBackgroundNode = strongSelf.replyBackgroundNode { @@ -1327,13 +1361,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { - var animated = false - if let _ = strongSelf.actionButtonsNode { - if case .System = animation { - animated = true - } - } - let actionButtonsNode = actionButtonsSizeAndApply.1(animated) + let actionButtonsNode = actionButtonsSizeAndApply.1(animation) let previousFrame = actionButtonsNode.frame let actionButtonsFrame = CGRect(origin: CGPoint(x: imageFrame.minX, y: imageFrame.maxY), size: actionButtonsSizeAndApply.0) actionButtonsNode.frame = actionButtonsFrame @@ -1351,7 +1379,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } strongSelf.addSubnode(actionButtonsNode) } else { - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { actionButtonsNode.layer.animateFrame(from: previousFrame, to: actionButtonsFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } } @@ -1360,6 +1388,36 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { strongSelf.actionButtonsNode = nil } + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + let reactionButtonsNode = reactionButtonsSizeAndApply.1(animation) + let reactionButtonsFrame = CGRect(origin: CGPoint(x: imageFrame.minX, y: imageFrame.maxY), size: reactionButtonsSizeAndApply.0) + if reactionButtonsNode !== strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = reactionButtonsNode + reactionButtonsNode.reactionSelected = { value in + guard let strongSelf = self, let item = strongSelf.item else { + return + } + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) + } + reactionButtonsNode.frame = reactionButtonsFrame + strongSelf.addSubnode(reactionButtonsNode) + if animation.isAnimated { + reactionButtonsNode.animateIn(animation: animation) + } + } else { + animation.animator.updateFrame(layer: reactionButtonsNode.layer, frame: reactionButtonsFrame, completion: nil) + } + } else if let reactionButtonsNode = strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = nil + if animation.isAnimated { + reactionButtonsNode.animateOut(animation: animation, completion: { [weak reactionButtonsNode] in + reactionButtonsNode?.removeFromSupernode() + }) + } else { + reactionButtonsNode.removeFromSupernode() + } + } + if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported) { strongSelf.dateAndStatusNode.pressed = { guard let strongSelf = self else { @@ -1384,7 +1442,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { @objc private func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: - if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { + if let item = self.item, let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { if let action = self.gestureRecognized(gesture: gesture, location: location, recognizer: recognizer) { if case .doubleTap = gesture { self.containerNode.cancelGesture() @@ -1395,10 +1453,14 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { case let .optionalAction(f): f() case let .openContextMenu(tapMessage, selectAll, subFrame): - self.item?.controllerInteraction.openMessageContextMenu(tapMessage, selectAll, self, subFrame, nil) + if canAddMessageReactions(message: item.message) { + item.controllerInteraction.updateMessageReaction(item.message, .default) + } else { + item.controllerInteraction.openMessageContextMenu(tapMessage, selectAll, self, subFrame, nil) + } } } else if case .tap = gesture { - self.item?.controllerInteraction.clickThroughMessage() + item.controllerInteraction.clickThroughMessage() } } default: @@ -1971,6 +2033,12 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { return shareButtonNode.view } + if let reactionButtonsNode = self.reactionButtonsNode { + if let result = reactionButtonsNode.hitTest(self.view.convert(point, to: reactionButtonsNode.view), with: event) { + return result + } + } + return super.hitTest(point, with: event) } @@ -2280,6 +2348,9 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } override func targetReactionView(value: String) -> UIView? { + if let result = self.reactionButtonsNode?.reactionTargetView(value: value) { + return result + } if !self.dateAndStatusNode.isHidden { return self.dateAndStatusNode.reactionView(value: value) } diff --git a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift index b275905129..da3525ce53 100644 --- a/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageAttachedContentNode.swift @@ -360,10 +360,10 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { var updateInlineImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var textCutout = TextNodeCutout() var initialWidth: CGFloat = CGFloat.greatestFiniteMagnitude - var refineContentImageLayout: ((CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ContainedViewLayoutTransition, Bool) -> ChatMessageInteractiveMediaNode)))? - var refineContentFileLayout: ((CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool) -> ChatMessageInteractiveFileNode)))? + var refineContentImageLayout: ((CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> ChatMessageInteractiveMediaNode)))? + var refineContentFileLayout: ((CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation) -> ChatMessageInteractiveFileNode)))? - var contentInstantVideoSizeAndApply: (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ContainedViewLayoutTransition) -> ChatMessageInteractiveInstantVideoNode)? + var contentInstantVideoSizeAndApply: (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ListViewItemUpdateAnimation) -> ChatMessageInteractiveInstantVideoNode)? let string = NSMutableAttributedString() var notEmpty = false @@ -514,7 +514,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { } else if file.isInstantVideo { let displaySize = CGSize(width: 212.0, height: 212.0) let automaticDownload = shouldDownloadMediaAutomatically(settings: automaticDownloadSettings, peerType: associatedData.automaticDownloadPeerType, networkType: associatedData.automaticDownloadNetworkType, authorPeerId: message.author?.id, contactsPeerIds: associatedData.contactsPeerIds, media: file) - let (videoLayout, apply) = contentInstantVideoLayout(ChatMessageBubbleContentItem(context: context, controllerInteraction: controllerInteraction, message: message, read: messageRead, chatLocation: chatLocation, presentationData: presentationData, associatedData: associatedData, attributes: attributes, isItemPinned: message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), constrainedSize.width - horizontalInsets.left - horizontalInsets.right, displaySize, displaySize, 0.0, .bubble, automaticDownload) + let (videoLayout, apply) = contentInstantVideoLayout(ChatMessageBubbleContentItem(context: context, controllerInteraction: controllerInteraction, message: message, topMessage: message, read: messageRead, chatLocation: chatLocation, presentationData: presentationData, associatedData: associatedData, attributes: attributes, isItemPinned: message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), constrainedSize.width - horizontalInsets.left - horizontalInsets.right, displaySize, displaySize, 0.0, .bubble, automaticDownload) initialWidth = videoLayout.contentSize.width + videoLayout.overflowLeft + videoLayout.overflowRight contentInstantVideoSizeAndApply = (videoLayout, apply) } else if file.isVideo { @@ -564,7 +564,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { } } - let (_, refineLayout) = contentFileLayout(context, presentationData, message, associatedData, chatLocation, attributes, message.tags.contains(.pinned) && !associatedData.isInPinnedListMode && !isReplyThread, false, file, automaticDownload, message.effectivelyIncoming(context.account.peerId), false, associatedData.forcedResourceStatus, statusType, nil, CGSize(width: constrainedSize.width - horizontalInsets.left - horizontalInsets.right, height: constrainedSize.height)) + let (_, refineLayout) = contentFileLayout(context, presentationData, message, message, associatedData, chatLocation, attributes, message.tags.contains(.pinned) && !associatedData.isInPinnedListMode && !isReplyThread, false, file, automaticDownload, message.effectivelyIncoming(context.account.peerId), false, associatedData.forcedResourceStatus, statusType, nil, CGSize(width: constrainedSize.width - horizontalInsets.left - horizontalInsets.right, height: constrainedSize.height)) refineContentFileLayout = refineLayout } } else if let image = media as? TelegramMediaImage { @@ -625,7 +625,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { let (textLayout, textApply) = textAsyncLayout(TextNodeLayoutArguments(attributedString: textString, backgroundColor: nil, maximumNumberOfLines: 12, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: upatedTextCutout, insets: UIEdgeInsets())) - var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))? + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if statusInText, let textStatusType = textStatusType { statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments( context: context, @@ -634,7 +634,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { impressionCount: viewCount, dateText: dateText, type: textStatusType, - layoutInput: .trailingContent(contentWidth: textLayout.trailingLineWidth, preferAdditionalInset: false), + layoutInput: .trailingContent(contentWidth: textLayout.trailingLineWidth, reactionSettings: shouldDisplayInlineDateReactions(message: message) ? ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: true, preferAdditionalInset: false) : nil), constrainedSize: textConstrainedSize, availableReactions: associatedData.availableReactions, reactions: dateReactions, @@ -666,14 +666,14 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { boundingSize.width = max(boundingSize.width, statusSuggestedWidthAndContinue.0) } - var finalizeContentImageLayout: ((CGFloat) -> (CGSize, (ContainedViewLayoutTransition, Bool) -> ChatMessageInteractiveMediaNode))? + var finalizeContentImageLayout: ((CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> ChatMessageInteractiveMediaNode))? if let refineContentImageLayout = refineContentImageLayout { let (refinedWidth, finalizeImageLayout) = refineContentImageLayout(textConstrainedSize, automaticPlayback, true, ImageCorners(radius: 4.0)) finalizeContentImageLayout = finalizeImageLayout boundingSize.width = max(boundingSize.width, refinedWidth) } - var finalizeContentFileLayout: ((CGFloat) -> (CGSize, (Bool) -> ChatMessageInteractiveFileNode))? + var finalizeContentFileLayout: ((CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation) -> ChatMessageInteractiveFileNode))? if let refineContentFileLayout = refineContentFileLayout { let (refinedWidth, finalizeFileLayout) = refineContentFileLayout(textConstrainedSize) finalizeContentFileLayout = finalizeFileLayout @@ -740,7 +740,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { imageFrame = CGRect(origin: CGPoint(x: boundingWidth - inlineImageSize.width - insets.right, y: 0.0), size: inlineImageSize) } - var contentImageSizeAndApply: (CGSize, (ContainedViewLayoutTransition, Bool) -> ChatMessageInteractiveMediaNode)? + var contentImageSizeAndApply: (CGSize, (ListViewItemUpdateAnimation, Bool) -> ChatMessageInteractiveMediaNode)? if let finalizeContentImageLayout = finalizeContentImageLayout { let (size, apply) = finalizeContentImageLayout(boundingWidth - insets.left - insets.right) contentImageSizeAndApply = (size, apply) @@ -754,7 +754,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { adjustedLineHeight += imageHeightAddition + 4.0 } - var contentFileSizeAndApply: (CGSize, (Bool) -> ChatMessageInteractiveFileNode)? + var contentFileSizeAndApply: (CGSize, (Bool, ListViewItemUpdateAnimation) -> ChatMessageInteractiveFileNode)? if let finalizeContentFileLayout = finalizeContentFileLayout { let (size, apply) = finalizeContentFileLayout(boundingWidth - insets.left - insets.right) contentFileSizeAndApply = (size, apply) @@ -788,19 +788,15 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { adjustedBoundingSize.height += 7.0 + size.height } - var statusSizeAndApply: ((CGSize), (Bool) -> Void)? + var statusSizeAndApply: ((CGSize), (ListViewItemUpdateAnimation) -> Void)? if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue { statusSizeAndApply = statusSuggestedWidthAndContinue.1(boundingWidth - insets.left - insets.right) } if let statusSizeAndApply = statusSizeAndApply { adjustedBoundingSize.height += statusSizeAndApply.0.height + adjustedLineHeight += statusSizeAndApply.0.height } - /*var adjustedStatusFrame: CGRect? - if statusInText, let statusFrame = statusFrame { - adjustedStatusFrame = CGRect(origin: CGPoint(x: boundingWidth - statusFrame.size.width - insets.right, y: statusFrame.origin.y), size: statusFrame.size) - }*/ - adjustedBoundingSize.width = max(boundingWidth, adjustedBoundingSize.width) return (adjustedBoundingSize, { [weak self] animation, synchronousLoads in @@ -810,17 +806,6 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { strongSelf.media = mediaAndFlags?.0 strongSelf.theme = presentationData.theme - var hasAnimation = true - var transition: ContainedViewLayoutTransition = .immediate - switch animation { - case .None, .Crossfade: - hasAnimation = false - case let .System(duration): - hasAnimation = true - transition = .animated(duration: duration, curve: .easeInOut) - } - let _ = hasAnimation - strongSelf.lineNode.image = lineImage strongSelf.lineNode.frame = CGRect(origin: CGPoint(x: 13.0, y: insets.top), size: CGSize(width: 2.0, height: adjustedLineHeight - insets.top - insets.bottom - 2.0)) strongSelf.lineNode.isHidden = !displayLine @@ -851,7 +836,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { if let (contentImageSize, contentImageApply) = contentImageSizeAndApply { contentMediaHeight = contentImageSize.height - let contentImageNode = contentImageApply(transition, synchronousLoads) + let contentImageNode = contentImageApply(animation, synchronousLoads) if strongSelf.contentImageNode !== contentImageNode { strongSelf.contentImageNode = contentImageNode contentImageNode.activatePinch = { sourceNode in @@ -863,9 +848,15 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { strongSelf.openMedia?(mode) } } + contentImageNode.updateMessageReaction = { [weak controllerInteraction] message, value in + guard let controllerInteraction = controllerInteraction else { + return + } + controllerInteraction.updateMessageReaction(message, value) + } contentImageNode.visibility = strongSelf.visibility != .none } - let _ = contentImageApply(transition, synchronousLoads) + let _ = contentImageApply(animation, synchronousLoads) let contentImageFrame: CGRect if let (_, flags) = mediaAndFlags, flags.contains(.preferMediaBeforeText) { contentImageFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: contentImageSize) @@ -901,7 +892,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { if let (contentFileSize, contentFileApply) = contentFileSizeAndApply { contentMediaHeight = contentFileSize.height - let contentFileNode = contentFileApply(synchronousLoads) + let contentFileNode = contentFileApply(synchronousLoads, animation) if strongSelf.contentFileNode !== contentFileNode { strongSelf.contentFileNode = contentFileNode strongSelf.addSubnode(contentFileNode) @@ -923,7 +914,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { if let (videoLayout, apply) = contentInstantVideoSizeAndApply { contentMediaHeight = videoLayout.contentSize.height - let contentInstantVideoNode = apply(.unconstrained(width: boundingWidth - insets.left - insets.right), transition) + let contentInstantVideoNode = apply(.unconstrained(width: boundingWidth - insets.left - insets.right), animation) if strongSelf.contentInstantVideoNode !== contentInstantVideoNode { strongSelf.contentInstantVideoNode = contentInstantVideoNode strongSelf.addSubnode(contentInstantVideoNode) @@ -949,7 +940,7 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { strongSelf.addSubnode(strongSelf.statusNode) } strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: strongSelf.textNode.frame.minX, y: strongSelf.textNode.frame.maxY), size: statusSizeAndApply.0) - statusSizeAndApply.1(animation.isAnimated) + statusSizeAndApply.1(animation) } else if strongSelf.statusNode.supernode != nil { strongSelf.statusNode.removeFromSupernode() } @@ -1089,6 +1080,24 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { } } + func reactionTargetView(value: String) -> UIView? { + if !self.statusNode.isHidden { + if let result = self.statusNode.reactionView(value: value) { + return result + } + } + if let result = self.contentFileNode?.dateAndStatusNode.reactionView(value: value) { + return result + } + if let result = self.contentImageNode?.dateAndStatusNode.reactionView(value: value) { + return result + } + if let result = self.contentInstantVideoNode?.dateAndStatusNode.reactionView(value: value) { + return result + } + return nil + } + func playMediaWithSound() -> ((Double?) -> Void, Bool, Bool, Bool, ASDisplayNode?)? { return self.contentImageNode?.playMediaWithSound() } diff --git a/submodules/TelegramUI/Sources/ChatMessageBackground.swift b/submodules/TelegramUI/Sources/ChatMessageBackground.swift index da4f57d14e..4b5eee990c 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBackground.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBackground.swift @@ -93,6 +93,11 @@ class ChatMessageBackground: ASDisplayNode { transition.updateFrame(node: self.outlineImageNode, frame: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.0, dy: -1.0)) } + func updateLayout(size: CGSize, transition: ListViewItemUpdateAnimation) { + transition.animator.updateFrame(layer: self.imageNode.layer, frame: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.0, dy: -1.0), completion: nil) + transition.animator.updateFrame(layer: self.outlineImageNode.layer, frame: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.0, dy: -1.0), completion: nil) + } + func setMaskMode(_ maskMode: Bool) { if let type = self.type, let hasWallpaper = self.hasWallpaper, let highlighted = self.currentHighlighted, let graphics = self.graphics, let backgroundNode = self.backgroundNode { self.setType(type: type, highlighted: highlighted, graphics: graphics, maskMode: maskMode, hasWallpaper: hasWallpaper, transition: .immediate, backgroundNode: backgroundNode) diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleContentCalclulateImageCorners.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleContentCalclulateImageCorners.swift index a99c5992df..63bd5c68c5 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleContentCalclulateImageCorners.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleContentCalclulateImageCorners.swift @@ -27,6 +27,9 @@ func chatMessageBubbleImageContentCorners(relativeContentPosition position: Chat case .Right: topLeftCorner = .Corner(normalRadius) topRightCorner = .Corner(mergedRadius) + case .Both: + topLeftCorner = .Corner(mergedRadius) + topRightCorner = .Corner(mergedRadius) } } case let .mosaic(position, _): @@ -65,6 +68,9 @@ func chatMessageBubbleImageContentCorners(relativeContentPosition position: Chat case .Left: bottomLeftCorner = .Corner(mergedRadius) bottomRightCorner = .Corner(normalRadius) + case .Both: + bottomLeftCorner = .Corner(mergedRadius) + bottomRightCorner = .Corner(mergedRadius) case let .None(status): let bubbleInsets: UIEdgeInsets if case .color = chatPresentationData.theme.wallpaper { diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift index b2f6eb3f9f..9f56b3f135 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift @@ -37,6 +37,7 @@ enum ChatMessageBubbleMergeStatus { case None(ChatMessageBubbleNoneMergeStatus) case Left case Right + case Both } enum ChatMessageBubbleRelativePosition { @@ -104,6 +105,7 @@ final class ChatMessageBubbleContentItem { let context: AccountContext let controllerInteraction: ChatControllerInteraction let message: Message + let topMessage: Message let read: Bool let chatLocation: ChatLocation let presentationData: ChatPresentationData @@ -112,10 +114,11 @@ final class ChatMessageBubbleContentItem { let isItemPinned: Bool let isItemEdited: Bool - init(context: AccountContext, controllerInteraction: ChatControllerInteraction, message: Message, read: Bool, chatLocation: ChatLocation, presentationData: ChatPresentationData, associatedData: ChatMessageItemAssociatedData, attributes: ChatMessageEntryAttributes, isItemPinned: Bool, isItemEdited: Bool) { + init(context: AccountContext, controllerInteraction: ChatControllerInteraction, message: Message, topMessage: Message, read: Bool, chatLocation: ChatLocation, presentationData: ChatPresentationData, associatedData: ChatMessageItemAssociatedData, attributes: ChatMessageEntryAttributes, isItemPinned: Bool, isItemEdited: Bool) { self.context = context self.controllerInteraction = controllerInteraction self.message = message + self.topMessage = topMessage self.read = read self.chatLocation = chatLocation self.presentationData = presentationData diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 5ed47d2e3a..061e5d0a89 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -46,7 +46,7 @@ private final class ChatMessageBubbleClippingNode: ASDisplayNode { } } -private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([(Message, AnyClass, ChatMessageEntryAttributes, BubbleItemAttributes)], Bool) { +private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([(Message, AnyClass, ChatMessageEntryAttributes, BubbleItemAttributes)], Bool, Bool) { var result: [(Message, AnyClass, ChatMessageEntryAttributes, BubbleItemAttributes)] = [] var skipText = false var messageWithCaptionToAdd: (Message, ChatMessageEntryAttributes)? @@ -56,10 +56,13 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ var previousItemIsFile = false var hasFiles = false + var needReactions = true + outer: for (message, itemAttributes) in item.content { for attribute in message.attributes { if let attribute = attribute as? RestrictedContentMessageAttribute, attribute.platformText(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) != nil { result.append((message, ChatMessageRestrictedBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false break outer } } @@ -86,6 +89,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ isFile = true hasFiles = true result.append((message, ChatMessageFileBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: neighborSpacing))) + needReactions = false } } else if let action = media as? TelegramMediaAction { isAction = true @@ -94,26 +98,33 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } else { result.append((message, ChatMessageActionBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) } + needReactions = false } else if let _ = media as? TelegramMediaMap { result.append((message, ChatMessageMapBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) } else if let _ = media as? TelegramMediaGame { skipText = true result.append((message, ChatMessageGameBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false break inner } else if let _ = media as? TelegramMediaInvoice { skipText = true result.append((message, ChatMessageInvoiceBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false break inner } else if let _ = media as? TelegramMediaContact { result.append((message, ChatMessageContactBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false } else if let _ = media as? TelegramMediaExpiredContent { result.removeAll() result.append((message, ChatMessageActionBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) - return (result, false) + needReactions = false + return (result, false, false) } else if let _ = media as? TelegramMediaPoll { result.append((message, ChatMessagePollBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false } else if let _ = media as? TelegramMediaUnsupported { isUnsupportedMedia = true + needReactions = false } previousItemIsFile = isFile } @@ -130,6 +141,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ skipText = true } else { result.append((message, ChatMessageTextBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: isFile ? .condensed : .default))) + needReactions = false } } else { if case .group = item.content { @@ -142,6 +154,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ if let webpage = media as? TelegramMediaWebpage { if case .Loaded = webpage.content { result.append((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false } break inner } @@ -151,29 +164,41 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ result.removeAll() result.append((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false } if isUnsupportedMedia { result.append((message, ChatMessageUnsupportedBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false } } if let (messageWithCaptionToAdd, itemAttributes) = messageWithCaptionToAdd { result.append((messageWithCaptionToAdd, ChatMessageTextBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false } if let additionalContent = item.additionalContent { switch additionalContent { case let .eventLogPreviousMessage(previousMessage): result.append((previousMessage, ChatMessageEventLogPreviousMessageContentNode.self, ChatMessageEntryAttributes(), BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false case let .eventLogPreviousDescription(previousMessage): result.append((previousMessage, ChatMessageEventLogPreviousDescriptionContentNode.self, ChatMessageEntryAttributes(), BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false case let .eventLogPreviousLink(previousMessage): result.append((previousMessage, ChatMessageEventLogPreviousLinkContentNode.self, ChatMessageEntryAttributes(), BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false } } let firstMessage = item.content.firstMessage + + let reactionsAreInline = shouldDisplayInlineDateReactions(message: firstMessage) + if reactionsAreInline { + needReactions = false + } + if !isAction && !Namespaces.Message.allScheduled.contains(firstMessage.id.namespace) { var hasDiscussion = false if let channel = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, info.flags.contains(.hasDiscussionGroup) { @@ -215,12 +240,25 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } } + if !reactionsAreInline, let reactionsAttribute = mergedMessageReactions(attributes: firstMessage.attributes), !reactionsAttribute.reactions.isEmpty { + if result.last?.1 == ChatMessageWebpageBubbleContentNode.self || + result.last?.1 == ChatMessagePollBubbleContentNode.self || + result.last?.1 == ChatMessageContactBubbleContentNode.self { + result.append((firstMessage, ChatMessageReactionsFooterContentNode.self, ChatMessageEntryAttributes(), BubbleItemAttributes(isAttachment: true, neighborType: .freeform, neighborSpacing: .default))) + needReactions = false + } else if result.last?.1 == ChatMessageCommentFooterContentNode.self { + result.insert((firstMessage, ChatMessageReactionsFooterContentNode.self, ChatMessageEntryAttributes(), BubbleItemAttributes(isAttachment: true, neighborType: .freeform, neighborSpacing: .default)), at: result.count - 1) + needReactions = false + } + } + var needSeparateContainers = false if case .group = item.content, hasFiles { needSeparateContainers = true + needReactions = false } - return (result, needSeparateContainers) + return (result, needSeparateContainers, needReactions) } private let chatMessagePeerIdColors: [UIColor] = [ @@ -421,6 +459,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode private(set) var contentNodes: [ChatMessageBubbleContentNode] = [] private var mosaicStatusNode: ChatMessageDateAndStatusNode? private var actionButtonsNode: ChatMessageActionButtonsNode? + private var reactionButtonsNode: ChatMessageReactionButtonsNode? private var shareButtonNode: ChatMessageShareButton? @@ -883,6 +922,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let forwardInfoLayout = ChatMessageForwardInfoNode.asyncLayout(self.forwardInfoNode) let replyInfoLayout = ChatMessageReplyInfoNode.asyncLayout(self.replyInfoNode) let actionButtonsLayout = ChatMessageActionButtonsNode.asyncLayout(self.actionButtonsNode) + let reactionButtonsLayout = ChatMessageReactionButtonsNode.asyncLayout(self.reactionButtonsNode) let mosaicStatusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.mosaicStatusNode) @@ -904,6 +944,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode forwardInfoLayout: forwardInfoLayout, replyInfoLayout: replyInfoLayout, actionButtonsLayout: actionButtonsLayout, + reactionButtonsLayout: reactionButtonsLayout, mosaicStatusLayout: mosaicStatusLayout, layoutConstants: layoutConstants, currentItem: currentItem, @@ -919,8 +960,9 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode adminBadgeLayout: (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode), 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: (ChatMessageDateAndStatusNode.Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (Bool) -> ChatMessageDateAndStatusNode)), + actionButtonsLayout: (AccountContext, ChatPresentationThemeData, PresentationChatBubbleCorners, PresentationStrings, ReplyMarkupMessageAttribute, Message, CGFloat) -> (minWidth: CGFloat, layout: (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)), + reactionButtonsLayout: (ChatMessageReactionButtonsNode.Arguments) -> (minWidth: CGFloat, layout: (CGFloat) -> (size: CGSize, apply: (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)), + mosaicStatusLayout: (ChatMessageDateAndStatusNode.Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)), layoutConstants: ChatMessageItemLayoutConstants, currentItem: ChatMessageItem?, currentForwardInfo: (Peer?, String?)?, @@ -1153,7 +1195,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode var contentPropertiesAndPrepareLayouts: [(Message, Bool, ChatMessageEntryAttributes, BubbleItemAttributes, (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))))] = [] var addedContentNodes: [(Message, Bool, ChatMessageBubbleContentNode)]? - let (contentNodeMessagesAndClasses, needSeparateContainers) = contentNodeMessagesAndClassesForItem(item) + let (contentNodeMessagesAndClasses, needSeparateContainers, needReactions) = contentNodeMessagesAndClassesForItem(item) for contentNodeItemValue in contentNodeMessagesAndClasses { let contentNodeItem = contentNodeItemValue as (message: Message, type: AnyClass, attributes: ChatMessageEntryAttributes, bubbleAttributes: BubbleItemAttributes) @@ -1232,9 +1274,6 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode var contentPropertiesAndLayouts: [(CGSize?, ChatMessageBubbleContentProperties, ChatMessageBubblePreparePosition, BubbleItemAttributes, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void)), UInt32?, Bool?)] = [] - let topNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedTop.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) - let bottomNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedBottom.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) - var backgroundHiding: ChatMessageBubbleContentBackgroundHiding? var hasSolidWallpaper = false switch item.presentationData.theme.wallpaper { @@ -1330,7 +1369,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode prepareContentPosition = .linear(top: topPosition, bottom: refinedBottomPosition) } - let contentItem = ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: message, read: read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: attributes, isItemPinned: isItemPinned, isItemEdited: isItemEdited) + let contentItem = ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: message, topMessage: item.content.firstMessage, read: read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: attributes, isItemPinned: isItemPinned, isItemEdited: isItemEdited) var itemSelection: Bool? switch content { @@ -1376,6 +1415,19 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode index += 1 } + let topNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedTop.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) + var bottomNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedBottom.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) + + let bubbleReactions: ReactionsMessageAttribute + if needReactions { + bubbleReactions = mergedMessageReactions(attributes: item.message.attributes) ?? ReactionsMessageAttribute(reactions: [], recentPeers: []) + } else { + bubbleReactions = ReactionsMessageAttribute(reactions: [], recentPeers: []) + } + if !bubbleReactions.reactions.isEmpty { + bottomNodeMergeStatus = .Both + } + var currentCredibilityIconImage: UIImage? var initialDisplayHeader = true @@ -1457,7 +1509,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let lastNodeTopPosition: ChatMessageBubbleRelativePosition = .None(bottomNodeMergeStatus) var calculatedGroupFramesAndSize: ([(CGRect, MosaicItemPosition)], CGSize)? - var mosaicStatusSizeAndApply: (CGSize, (Bool) -> ChatMessageDateAndStatusNode)? + var mosaicStatusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)? if let mosaicRange = mosaicRange { let maxSize = layoutConstants.image.maxDimensions.fittedToWidthOrSmaller(maximumContentWidth - layoutConstants.image.bubbleInsets.left - layoutConstants.image.bubbleInsets.right) @@ -1531,7 +1583,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .standalone, + layoutInput: .standalone(reactionSettings: shouldDisplayInlineDateReactions(message: item.message) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil), constrainedSize: CGSize(width: 200.0, height: CGFloat.greatestFiniteMagnitude), availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -1707,13 +1759,27 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode var maxContentWidth: CGFloat = headerSize.width - var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode))? + var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode))? if let replyMarkup = replyMarkup { let (minWidth, buttonsLayout) = actionButtonsLayout(item.context, item.presentationData.theme, item.presentationData.chatBubbleCorners, item.presentationData.strings, replyMarkup, item.message, maximumNodeWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout } + var reactionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode))? + if !bubbleReactions.reactions.isEmpty { + let (minWidth, buttonsLayout) = reactionButtonsLayout(ChatMessageReactionButtonsNode.Arguments( + context: item.context, + presentationData: item.presentationData, + availableReactions: item.associatedData.availableReactions, + reactions: bubbleReactions, + isIncoming: item.message.effectivelyIncoming(item.context.account.peerId), + constrainedWidth: maximumNodeWidth + )) + maxContentWidth = max(maxContentWidth, minWidth) + reactionButtonsFinalize = buttonsLayout + } + for i in 0 ..< contentPropertiesAndLayouts.count { let (_, contentNodeProperties, preparePosition, _, contentNodeLayout, contentGroupId, itemSelection) = contentPropertiesAndLayouts[i] @@ -1737,7 +1803,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode case let .None(status): if position.contains(.top) && position.contains(.left) { switch status { - case .Left: + case .Left, .Both: topLeft = .mergedBubble case .Right: topLeft = .none(tail: false) @@ -1752,7 +1818,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode switch status { case .Left: topRight = .none(tail: false) - case .Right: + case .Right, .Both: topRight = .mergedBubble case .None: topRight = .none(tail: false) @@ -1788,7 +1854,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode case let .None(status): if position.contains(.bottom) && position.contains(.left) { switch status { - case .Left: + case .Left, .Both: bottomLeft = .mergedBubble case .Right: bottomLeft = .none(tail: false) @@ -1807,7 +1873,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode switch status { case .Left: bottomRight = .none(tail: false) - case .Right: + case .Right, .Both: bottomRight = .mergedBubble case let .None(tailStatus): if case .Outgoing = tailStatus { @@ -1963,11 +2029,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode contentSize.height += totalContentNodesHeight - var actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)? + var actionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)? if let actionButtonsFinalize = actionButtonsFinalize { actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) } + var reactionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)? + if let reactionButtonsFinalize = reactionButtonsFinalize { + reactionButtonsSizeAndApply = reactionButtonsFinalize(maxContentWidth) + } + let minimalContentSize: CGSize if hideBackground { minimalContentSize = CGSize(width: 1.0, height: 1.0) @@ -2000,6 +2071,9 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let bubbleContentWidth = maxContentWidth - layoutConstants.bubble.edgeInset * 2.0 - (layoutConstants.bubble.contentInsets.right + layoutConstants.bubble.contentInsets.left) var layoutSize = CGSize(width: params.width, height: layoutBubbleSize.height) + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + layoutSize.height += 4.0 + reactionButtonsSizeAndApply.0.height + } if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { layoutSize.height += actionButtonsSizeAndApply.0.height } @@ -2025,7 +2099,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode if headerSize.height.isZero && contentNodePropertiesAndFinalize.first?.0.forceFullCorners ?? false { updatedMergedBottom = .none } - if actionButtonsSizeAndApply != nil { + if actionButtonsSizeAndApply != nil || reactionButtonsSizeAndApply != nil { updatedMergedTop = .fullyMerged } } @@ -2040,6 +2114,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode forwardAuthorSignature: forwardAuthorSignature, accessibilityData: accessibilityData, actionButtonsSizeAndApply: actionButtonsSizeAndApply, + reactionButtonsSizeAndApply: reactionButtonsSizeAndApply, updatedMergedTop: updatedMergedTop, updatedMergedBottom: updatedMergedBottom, hideBackground: hideBackground, @@ -2080,7 +2155,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode forwardSource: Peer?, forwardAuthorSignature: String?, accessibilityData: ChatMessageAccessibilityData, - actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)?, + actionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)?, + reactionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)?, updatedMergedTop: ChatMessageMerge, updatedMergedBottom: ChatMessageMerge, hideBackground: Bool, @@ -2107,7 +2183,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode contentNodeFramesPropertiesAndApply: [(CGRect, ChatMessageBubbleContentProperties, Bool, (ListViewItemUpdateAnimation, Bool) -> Void)], contentContainerNodeFrames: [(UInt32, CGRect, Bool?, CGFloat)], mosaicStatusOrigin: CGPoint?, - mosaicStatusSizeAndApply: (CGSize, (Bool) -> ChatMessageDateAndStatusNode)?, + mosaicStatusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)?, needsShareButton: Bool ) -> Void { guard let strongSelf = selfReference.value else { @@ -2123,10 +2199,10 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.appliedForwardInfo = (forwardSource, forwardAuthorSignature) strongSelf.updateAccessibilityData(accessibilityData) - var transition: ContainedViewLayoutTransition = .immediate + var legacyTransition: ContainedViewLayoutTransition = .immediate var useDisplayLinkAnimations = false - if case let .System(duration) = animation { - transition = .animated(duration: duration, curve: .spring) + if case let .System(duration, _) = animation { + legacyTransition = .animated(duration: duration, curve: .spring) if let subject = item.associatedData.subject, case .forwardedMessages = subject { useDisplayLinkAnimations = true @@ -2134,7 +2210,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } var forceBackgroundSide = false - if actionButtonsSizeAndApply != nil { + if actionButtonsSizeAndApply != nil || reactionButtonsSizeAndApply != nil { forceBackgroundSide = true } else if case .semanticallyMerged = updatedMergedTop { forceBackgroundSide = true @@ -2150,9 +2226,9 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } let hasWallpaper = item.presentationData.theme.wallpaper.hasWallpaper if item.presentationData.theme.theme.forceSync { - transition = .immediate + legacyTransition = .immediate } - strongSelf.backgroundNode.setType(type: backgroundType, highlighted: strongSelf.highlightedState, graphics: graphics, maskMode: strongSelf.backgroundMaskMode, hasWallpaper: hasWallpaper, transition: transition, backgroundNode: presentationContext.backgroundNode) + strongSelf.backgroundNode.setType(type: backgroundType, highlighted: strongSelf.highlightedState, graphics: graphics, maskMode: strongSelf.backgroundMaskMode, hasWallpaper: hasWallpaper, transition: legacyTransition, backgroundNode: presentationContext.backgroundNode) strongSelf.backgroundWallpaperNode.setType(type: backgroundType, theme: item.presentationData.theme, essentialGraphics: graphics, maskMode: strongSelf.backgroundMaskMode, backgroundNode: presentationContext.backgroundNode) strongSelf.shadowNode.setType(type: backgroundType, hasWallpaper: hasWallpaper, graphics: graphics) @@ -2178,14 +2254,14 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let deliveryFailedFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + deliveryFailedInset - deliveryFailedSize.width, y: backgroundFrame.maxY - deliveryFailedSize.height), size: deliveryFailedSize) if isAppearing { deliveryFailedNode.frame = deliveryFailedFrame - transition.animatePositionAdditive(node: deliveryFailedNode, offset: CGPoint(x: deliveryFailedInset, y: 0.0)) + legacyTransition.animatePositionAdditive(node: deliveryFailedNode, offset: CGPoint(x: deliveryFailedInset, y: 0.0)) } else { - transition.updateFrame(node: deliveryFailedNode, frame: deliveryFailedFrame) + animation.animator.updateFrame(layer: deliveryFailedNode.layer, frame: deliveryFailedFrame, completion: nil) } } else if let deliveryFailedNode = strongSelf.deliveryFailedNode { strongSelf.deliveryFailedNode = nil - transition.updateAlpha(node: deliveryFailedNode, alpha: 0.0) - transition.updateFrame(node: deliveryFailedNode, frame: deliveryFailedNode.frame.offsetBy(dx: 24.0, dy: 0.0), completion: { [weak deliveryFailedNode] _ in + animation.animator.updateAlpha(layer: deliveryFailedNode.layer, alpha: 0.0, completion: nil) + animation.animator.updateFrame(layer: deliveryFailedNode.layer, frame: deliveryFailedNode.frame.offsetBy(dx: 24.0, dy: 0.0), completion: { [weak deliveryFailedNode] _ in deliveryFailedNode?.removeFromSupernode() }) } @@ -2194,16 +2270,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.nameNode = nameNode nameNode.displaysAsynchronously = !item.presentationData.isPreview && !item.presentationData.theme.theme.forceSync - let previousNameNodeFrame = nameNode.frame + //let previousNameNodeFrame = nameNode.frame let nameNodeFrame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY), size: nameNodeSizeApply.0) - nameNode.frame = nameNodeFrame if nameNode.supernode == nil { if !nameNode.isNodeLoaded { nameNode.isUserInteractionEnabled = false } strongSelf.clippingNode.addSubnode(nameNode) + nameNode.frame = nameNodeFrame } else { - transition.animatePositionAdditive(node: nameNode, offset: CGPoint(x: previousNameNodeFrame.maxX - nameNodeFrame.maxX, y: 0.0)) + animation.animator.updateFrame(layer: nameNode.layer, frame: nameNodeFrame, completion: nil) } if let credibilityIconImage = currentCredibilityIconImage { @@ -2232,9 +2308,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.clippingNode.addSubnode(adminBadgeNode) adminBadgeNode.frame = adminBadgeFrame } else { - let previousAdminBadgeFrame = adminBadgeNode.frame - adminBadgeNode.frame = adminBadgeFrame - transition.animatePositionAdditive(node: adminBadgeNode, offset: CGPoint(x: previousAdminBadgeFrame.maxX - adminBadgeFrame.maxX, y: 0.0)) + //let previousAdminBadgeFrame = adminBadgeNode.frame + animation.animator.updateFrame(layer: adminBadgeNode.layer, frame: adminBadgeFrame, completion: nil) } } else { strongSelf.adminBadgeNode?.removeFromSupernode() @@ -2269,7 +2344,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } let previousForwardInfoNodeFrame = forwardInfoNode.frame let forwardInfoFrame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + forwardInfoOriginY), size: CGSize(width: bubbleContentWidth, height: forwardInfoSizeApply.0.height)) - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { if animateFrame { if useDisplayLinkAnimations { let animation = ListViewAnimation(from: previousForwardInfoNodeFrame, to: forwardInfoFrame, duration: duration * UIView.animationDurationFactor(), curve: strongSelf.preferredAnimationCurve, beginAt: beginAt, update: { _, frame in @@ -2309,7 +2384,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } let previousReplyInfoNodeFrame = replyInfoNode.frame replyInfoNode.frame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + replyInfoOriginY), size: replyInfoSizeApply.0) - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { if animateFrame { replyInfoNode.layer.animateFrame(from: previousReplyInfoNodeFrame, to: replyInfoNode.frame, duration: duration, timingFunction: timingFunction) } @@ -2561,7 +2636,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let contentNodeFrame = relativeFrame.offsetBy(dx: contentOrigin.x, dy: useContentOrigin ? contentOrigin.y : 0.0) let previousContentNodeFrame = contentNode.frame - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { var animateFrame = false var animateAlpha = false if let addedContentNodes = addedContentNodes { @@ -2581,8 +2656,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode }) strongSelf.setAnimationForKey("contentNode\(contentNodeIndex)Frame", animation: animation) } else { - contentNode.frame = contentNodeFrame - contentNode.layer.animateFrame(from: previousContentNodeFrame, to: contentNodeFrame, duration: duration, timingFunction: timingFunction) + animation.animator.updateFrame(layer: contentNode.layer, frame: contentNodeFrame, completion: nil) } } else if animateAlpha { contentNode.frame = contentNodeFrame @@ -2600,7 +2674,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } if let mosaicStatusOrigin = mosaicStatusOrigin, let (size, apply) = mosaicStatusSizeAndApply { - let mosaicStatusNode = apply(transition.isAnimated) + let mosaicStatusNode = apply(animation) if mosaicStatusNode !== strongSelf.mosaicStatusNode { strongSelf.mosaicStatusNode?.removeFromSupernode() strongSelf.mosaicStatusNode = mosaicStatusNode @@ -2627,24 +2701,47 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode if case .System = animation, !strongSelf.mainContextSourceNode.isExtractedToContextPreview { if !strongSelf.backgroundNode.frame.equalTo(backgroundFrame) { - strongSelf.backgroundFrameTransition = (strongSelf.backgroundNode.frame, backgroundFrame) + animation.animator.updateFrame(layer: strongSelf.backgroundNode.layer, frame: backgroundFrame, completion: nil) + animation.animator.updatePosition(layer: strongSelf.clippingNode.layer, position: backgroundFrame.center, completion: nil) + strongSelf.clippingNode.clipsToBounds = true + animation.animator.updateBounds(layer: strongSelf.clippingNode.layer, bounds: CGRect(origin: CGPoint(x: backgroundFrame.minX, y: backgroundFrame.minY), size: backgroundFrame.size), completion: { [weak strongSelf] _ in + let _ = strongSelf + //strongSelf?.clippingNode.clipsToBounds = false + }) + + strongSelf.backgroundNode.updateLayout(size: backgroundFrame.size, transition: animation) + animation.animator.updateFrame(layer: strongSelf.backgroundWallpaperNode.layer, frame: backgroundFrame, completion: nil) + strongSelf.shadowNode.updateLayout(backgroundFrame: backgroundFrame, transition: animation.transition) + strongSelf.backgroundWallpaperNode.updateFrame(backgroundFrame, transition: animation.transition) + if let type = strongSelf.backgroundNode.type { - if case .none = type { - } else { - strongSelf.clippingNode.clipsToBounds = true + var incomingOffset: CGFloat = 0.0 + switch type { + case .incoming: + incomingOffset = 5.0 + default: + break + } + strongSelf.mainContextSourceNode.contentRect = backgroundFrame.offsetBy(dx: incomingOffset, dy: 0.0) + strongSelf.mainContainerNode.targetNodeForActivationProgressContentRect = strongSelf.mainContextSourceNode.contentRect + if !strongSelf.mainContextSourceNode.isExtractedToContextPreview { + if let (rect, size) = strongSelf.absoluteRect { + strongSelf.updateAbsoluteRect(rect, within: size) + } } } + strongSelf.messageAccessibilityArea.frame = backgroundFrame } if let shareButtonNode = strongSelf.shareButtonNode { let currentBackgroundFrame = strongSelf.backgroundNode.frame let buttonSize = shareButtonNode.update(presentationData: item.presentationData, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: item.message, account: item.context.account, disableComments: true) - shareButtonNode.frame = CGRect(origin: CGPoint(x: currentBackgroundFrame.maxX + 8.0, y: currentBackgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize) + animation.animator.updateFrame(layer: shareButtonNode.layer, frame: CGRect(origin: CGPoint(x: currentBackgroundFrame.maxX + 8.0, y: currentBackgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize), completion: nil) } } else { - if let _ = strongSelf.backgroundFrameTransition { + /*if let _ = strongSelf.backgroundFrameTransition { strongSelf.animateFrameTransition(1.0, backgroundFrame.size.height) strongSelf.backgroundFrameTransition = nil - } + }*/ strongSelf.messageAccessibilityArea.frame = backgroundFrame if let shareButtonNode = strongSelf.shareButtonNode { let buttonSize = shareButtonNode.update(presentationData: item.presentationData, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: item.message, account: item.context.account, disableComments: true) @@ -2652,14 +2749,14 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } if case .System = animation, strongSelf.mainContextSourceNode.isExtractedToContextPreview { - transition.updateFrame(node: strongSelf.backgroundNode, frame: backgroundFrame) + legacyTransition.updateFrame(node: strongSelf.backgroundNode, frame: backgroundFrame) - transition.updateFrame(node: strongSelf.clippingNode, frame: backgroundFrame) - transition.updateBounds(node: strongSelf.clippingNode, bounds: CGRect(origin: CGPoint(x: backgroundFrame.minX, y: backgroundFrame.minY), size: backgroundFrame.size)) + legacyTransition.updateFrame(node: strongSelf.clippingNode, frame: backgroundFrame) + legacyTransition.updateBounds(node: strongSelf.clippingNode, bounds: CGRect(origin: CGPoint(x: backgroundFrame.minX, y: backgroundFrame.minY), size: backgroundFrame.size)) - strongSelf.backgroundNode.updateLayout(size: backgroundFrame.size, transition: transition) - strongSelf.backgroundWallpaperNode.updateFrame(backgroundFrame, transition: transition) - strongSelf.shadowNode.updateLayout(backgroundFrame: backgroundFrame, transition: transition) + strongSelf.backgroundNode.updateLayout(size: backgroundFrame.size, transition: legacyTransition) + strongSelf.backgroundWallpaperNode.updateFrame(backgroundFrame, transition: legacyTransition) + strongSelf.shadowNode.updateLayout(backgroundFrame: backgroundFrame, transition: legacyTransition) } else { strongSelf.backgroundNode.frame = backgroundFrame strongSelf.clippingNode.frame = backgroundFrame @@ -2677,17 +2774,11 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.selectionNode?.frame = selectionFrame strongSelf.selectionNode?.updateLayout(size: selectionFrame.size, leftInset: params.leftInset) + var reactionButtonsOffset: CGFloat = 0.0 + if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { - var animated = false - if let _ = strongSelf.actionButtonsNode { - if case .System = animation { - animated = true - } - } - let actionButtonsNode = actionButtonsSizeAndApply.1(animated) - let previousFrame = actionButtonsNode.frame + let actionButtonsNode = actionButtonsSizeAndApply.1(animation) let actionButtonsFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.maxY), size: actionButtonsSizeAndApply.0) - actionButtonsNode.frame = actionButtonsFrame if actionButtonsNode !== strongSelf.actionButtonsNode { strongSelf.actionButtonsNode = actionButtonsNode actionButtonsNode.buttonPressed = { [weak strongSelf] button in @@ -2701,16 +2792,47 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } } strongSelf.insertSubnode(actionButtonsNode, belowSubnode: strongSelf.messageAccessibilityArea) + actionButtonsNode.frame = actionButtonsFrame } else { - if case let .System(duration) = animation { - actionButtonsNode.layer.animateFrame(from: previousFrame, to: actionButtonsFrame, duration: duration, timingFunction: timingFunction) - } + animation.animator.updateFrame(layer: actionButtonsNode.layer, frame: actionButtonsFrame, completion: nil) } + + reactionButtonsOffset += actionButtonsSizeAndApply.0.height } else if let actionButtonsNode = strongSelf.actionButtonsNode { actionButtonsNode.removeFromSupernode() strongSelf.actionButtonsNode = nil } + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + let reactionButtonsNode = reactionButtonsSizeAndApply.1(animation) + let reactionButtonsFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.maxY + reactionButtonsOffset + 4.0), size: reactionButtonsSizeAndApply.0) + if reactionButtonsNode !== strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = reactionButtonsNode + reactionButtonsNode.reactionSelected = { [weak strongSelf] value in + guard let strongSelf = strongSelf, let item = strongSelf.item else { + return + } + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) + } + reactionButtonsNode.frame = reactionButtonsFrame + strongSelf.addSubnode(reactionButtonsNode) + if animation.isAnimated { + reactionButtonsNode.animateIn(animation: animation) + } + } else { + animation.animator.updateFrame(layer: reactionButtonsNode.layer, frame: reactionButtonsFrame, completion: nil) + } + } else if let reactionButtonsNode = strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = nil + if animation.isAnimated { + reactionButtonsNode.animateOut(animation: animation, completion: { [weak reactionButtonsNode] in + reactionButtonsNode?.removeFromSupernode() + }) + } else { + reactionButtonsNode.removeFromSupernode() + } + } + let previousContextContentFrame = strongSelf.mainContextSourceNode.contentRect strongSelf.mainContextSourceNode.contentRect = backgroundFrame.offsetBy(dx: incomingOffset, dy: 0.0) strongSelf.mainContainerNode.targetNodeForActivationProgressContentRect = strongSelf.mainContextSourceNode.contentRect @@ -2780,7 +2902,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { super.animateFrameTransition(progress, currentValue) - if let backgroundFrameTransition = self.backgroundFrameTransition { + /*if let backgroundFrameTransition = self.backgroundFrameTransition { let backgroundFrame = CGRect.interpolator()(backgroundFrameTransition.0, backgroundFrameTransition.1, progress) as! CGRect self.backgroundNode.frame = backgroundFrame @@ -2819,7 +2941,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode self.clippingNode.clipsToBounds = false } - } + }*/ } @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { @@ -2836,8 +2958,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode case let .optionalAction(f): f() case let .openContextMenu(tapMessage, selectAll, subFrame): - if canAddMessageReactions(message: tapMessage), let defaultReaction = item.associatedData.defaultReaction { - item.controllerInteraction.updateMessageReaction(tapMessage, defaultReaction) + if canAddMessageReactions(message: tapMessage) { + item.controllerInteraction.updateMessageReaction(tapMessage, .default) } else { item.controllerInteraction.openMessageContextMenu(tapMessage, selectAll, self, subFrame, nil) } @@ -3673,6 +3795,9 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } override func targetReactionView(value: String) -> UIView? { + if let result = self.reactionButtonsNode?.reactionTargetView(value: value) { + return result + } for contentNode in self.contentNodes { if let result = contentNode.reactionTargetView(value: value) { return result diff --git a/submodules/TelegramUI/Sources/ChatMessageCommentFooterContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageCommentFooterContentNode.swift index d14942f98c..2c833524bf 100644 --- a/submodules/TelegramUI/Sources/ChatMessageCommentFooterContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageCommentFooterContentNode.swift @@ -105,12 +105,15 @@ final class ChatMessageCommentFooterContentNode: ChatMessageBubbleContentNode { let displaySeparator: Bool let topOffset: CGFloat + let topSeparatorOffset: CGFloat if case let .linear(top, _) = preparePosition, case .Neighbour(_, .media, _) = top { displaySeparator = false topOffset = 2.0 + topSeparatorOffset = 0.0 } else { displaySeparator = true - topOffset = 0.0 + topOffset = 2.0 + topSeparatorOffset = 2.0 } return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in @@ -374,7 +377,7 @@ final class ChatMessageCommentFooterContentNode: ChatMessageBubbleContentNode { strongSelf.separatorNode.backgroundColor = messageTheme.polls.separator strongSelf.separatorNode.isHidden = !displaySeparator - strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: layoutConstants.bubble.strokeInsets.left, y: -3.0), size: CGSize(width: boundingWidth - layoutConstants.bubble.strokeInsets.left - layoutConstants.bubble.strokeInsets.right, height: UIScreenPixel)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: layoutConstants.bubble.strokeInsets.left, y: -3.0 + topSeparatorOffset), size: CGSize(width: boundingWidth - layoutConstants.bubble.strokeInsets.left - layoutConstants.bubble.strokeInsets.right, height: UIScreenPixel)) strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: boundingWidth, height: boundingSize.height)) @@ -417,7 +420,3 @@ final class ChatMessageCommentFooterContentNode: ChatMessageBubbleContentNode { return nil } } - - - - diff --git a/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift index 620888a171..871d909ba8 100644 --- a/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageContactBubbleContentNode.swift @@ -46,7 +46,7 @@ class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { guard let strongSelf = self, let item = strongSelf.item else { return } - item.controllerInteraction.updateMessageReaction(item.message, value) + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) } } @@ -196,7 +196,7 @@ class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { statusType = nil } - var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))? + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if let statusType = statusType { var isReplyThread = false if case .replyThread = item.chatLocation { @@ -210,7 +210,7 @@ class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .trailingContent(contentWidth: 1000.0, preferAdditionalInset: true), + layoutInput: .trailingContent(contentWidth: 1000.0, reactionSettings: nil), constrainedSize: CGSize(width: constrainedSize.width - sideInsets, height: .greatestFiniteMagnitude), availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -305,9 +305,9 @@ class ChatMessageContactBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.dateAndStatusNode.frame = CGRect(origin: CGPoint(x: layoutConstants.text.bubbleInsets.left, y: strongSelf.textNode.frame.maxY + 2.0), size: statusSizeAndApply.0) if strongSelf.dateAndStatusNode.supernode == nil { strongSelf.addSubnode(strongSelf.dateAndStatusNode) - statusSizeAndApply.1(false) + statusSizeAndApply.1(.None) } else { - statusSizeAndApply.1(animation.isAnimated) + statusSizeAndApply.1(animation) } } else if strongSelf.dateAndStatusNode.supernode != nil { strongSelf.dateAndStatusNode.removeFromSupernode() diff --git a/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift b/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift index e6cdb1e5ab..6315227cd7 100644 --- a/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageDateAndStatusNode.swift @@ -9,6 +9,7 @@ import TelegramPresentationData import AccountContext import AppBundle import ReactionButtonListComponent +import WebPBinding private func maybeAddRotationAnimation(_ layer: CALayer, duration: Double) { if let _ = layer.animation(forKey: "clockFrameAnimation") { @@ -45,51 +46,91 @@ private let reactionCountFont = Font.semibold(11.0) private let reactionFont = Font.regular(12.0) private final class StatusReactionNode: ASDisplayNode { - let selectedImageNode: ASImageNode + let iconView: UIImageView + + private let iconImageDisposable = MetaDisposable() private var theme: PresentationTheme? private var value: String? private var isSelected: Bool? override init() { - self.selectedImageNode = ASImageNode() - self.selectedImageNode.displaysAsynchronously = false + self.iconView = UIImageView() super.init() - self.addSubnode(self.selectedImageNode) + self.view.addSubview(self.iconView) } - func update(type: ChatMessageDateAndStatusType, value: String, isSelected: Bool, count: Int, theme: PresentationTheme, wallpaper: TelegramWallpaper, animated: Bool) { + deinit { + self.iconImageDisposable.dispose() + } + + func update(context: AccountContext, type: ChatMessageDateAndStatusType, value: String, file: TelegramMediaFile?, isSelected: Bool, count: Int, theme: PresentationTheme, wallpaper: TelegramWallpaper, animated: Bool) { if self.value != value { self.value = value - let selectedImage: UIImage? = generateImage(CGSize(width: 14.0, height: 14.0), rotatedContext: { size, context in - UIGraphicsPushContext(context) - - context.clear(CGRect(origin: CGPoint(), size: size)) - - context.scaleBy(x: size.width / 20.0, y: size.width / 20.0) - - let string = NSAttributedString(string: value, font: reactionFont, textColor: .black) - string.draw(at: CGPoint(x: 1.0, y: 2.0)) - - UIGraphicsPopContext() - }) - - if let selectedImage = selectedImage { - self.selectedImageNode.image = selectedImage - self.selectedImageNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: selectedImage.size) + let defaultImageSize = CGSize(width: 19.0, height: 19.0) + let imageSize: CGSize + if let file = file { + self.iconImageDisposable.set((context.account.postbox.mediaBox.resourceData(file.resource) + |> deliverOnMainQueue).start(next: { [weak self] data in + guard let strongSelf = self else { + return + } + + if data.complete, let dataValue = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { + if let image = WebP.convert(fromWebP: dataValue) { + strongSelf.iconView.image = image + } + } + })) + imageSize = file.dimensions?.cgSize.aspectFitted(defaultImageSize) ?? defaultImageSize + } else { + imageSize = defaultImageSize } + + self.iconView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((defaultImageSize.width - imageSize.width) / 2.0), y: floorToScreenPixels((defaultImageSize.height - imageSize.height) / 2.0)), size: imageSize) } } } - class ChatMessageDateAndStatusNode: ASDisplayNode { + struct TrailingReactionSettings { + var displayInline: Bool + var preferAdditionalInset: Bool + + init(displayInline: Bool, preferAdditionalInset: Bool) { + self.displayInline = displayInline + self.preferAdditionalInset = preferAdditionalInset + } + } + + struct StandaloneReactionSettings { + init() { + } + } + enum LayoutInput { - case trailingContent(contentWidth: CGFloat, preferAdditionalInset: Bool) - case standalone + case trailingContent(contentWidth: CGFloat, reactionSettings: TrailingReactionSettings?) + case standalone(reactionSettings: StandaloneReactionSettings?) + + var displayInlineReactions: Bool { + switch self { + case let .trailingContent(_, reactionSettings): + if let reactionSettings = reactionSettings { + return reactionSettings.displayInline + } else { + return false + } + case let .standalone(reactionSettings): + if let _ = reactionSettings { + return true + } else { + return false + } + } + } } struct Arguments { @@ -146,7 +187,7 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { private var clockMinNode: ASImageNode? private let dateNode: TextNode private var impressionIcon: ASImageNode? - private var reactionNodes: [StatusReactionNode] = [] + private var reactionNodes: [String: StatusReactionNode] = [:] private let reactionButtonsContainer = ReactionButtonsLayoutContainer() private var reactionCountNode: TextNode? private var reactionButtonNode: HighlightTrackingButtonNode? @@ -193,7 +234,7 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } } - func asyncLayout() -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void)) { + func asyncLayout() -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void)) { let dateLayout = TextNode.asyncLayout(self.dateNode) var checkReadNode = self.checkReadNode @@ -211,8 +252,6 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { let makeReplyCountLayout = TextNode.asyncLayout(self.replyCountNode) let makeReactionCountLayout = TextNode.asyncLayout(self.reactionCountNode) - let previousLayoutSize = self.layoutSize - let reactionButtonsContainer = self.reactionButtonsContainer return { [weak self] arguments in @@ -241,22 +280,24 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { switch arguments.type { case .BubbleIncoming, .ImageIncoming, .FreeIncoming: reactionColors = ReactionButtonComponent.Colors( - background: arguments.presentationData.theme.theme.chat.message.incoming.accentControlColor.withMultipliedAlpha(0.1).argb, - foreground: arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor.argb, - stroke: arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor.argb + deselectedBackground: arguments.presentationData.theme.theme.chat.message.incoming.accentControlColor.withMultipliedAlpha(0.1).argb, + selectedBackground: arguments.presentationData.theme.theme.chat.message.incoming.accentControlColor.withMultipliedAlpha(1.0).argb, + deselectedForeground: arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor.argb, + selectedForeground: arguments.presentationData.theme.theme.chat.message.incoming.bubble.withWallpaper.fill.last!.argb ) case .BubbleOutgoing, .ImageOutgoing, .FreeOutgoing: reactionColors = ReactionButtonComponent.Colors( - background: arguments.presentationData.theme.theme.chat.message.outgoing.accentControlColor.withMultipliedAlpha(0.1).argb, - foreground: arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor.argb, - stroke: arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor.argb + deselectedBackground: arguments.presentationData.theme.theme.chat.message.outgoing.accentControlColor.withMultipliedAlpha(0.1).argb, + selectedBackground: arguments.presentationData.theme.theme.chat.message.outgoing.accentControlColor.withMultipliedAlpha(1.0).argb, + deselectedForeground: arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor.argb, + selectedForeground: arguments.presentationData.theme.theme.chat.message.outgoing.bubble.withWallpaper.fill.last!.argb ) } switch arguments.type { case .BubbleIncoming: dateColor = arguments.presentationData.theme.theme.chat.message.incoming.secondaryTextColor - leftInset = 10.0 + leftInset = 5.0 loadedCheckFullImage = PresentationResourcesChat.chatOutgoingFullCheck(arguments.presentationData.theme.theme, size: checkSize) loadedCheckPartialImage = PresentationResourcesChat.chatOutgoingPartialCheck(arguments.presentationData.theme.theme, size: checkSize) clockFrameImage = graphics.clockBubbleIncomingFrameImage @@ -272,7 +313,7 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { case let .BubbleOutgoing(status): dateColor = arguments.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor outgoingStatus = status - leftInset = 10.0 + leftInset = 5.0 loadedCheckFullImage = PresentationResourcesChat.chatOutgoingFullCheck(arguments.presentationData.theme.theme, size: checkSize) loadedCheckPartialImage = PresentationResourcesChat.chatOutgoingPartialCheck(arguments.presentationData.theme.theme, size: checkSize) clockFrameImage = graphics.clockBubbleOutgoingFrameImage @@ -518,13 +559,13 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { var replyCountLayoutAndApply: (TextNodeLayout, () -> TextNode)? - let reactionSize: CGFloat = 14.0 + let reactionSize: CGFloat = 19.0 var reactionCountLayoutAndApply: (TextNodeLayout, () -> TextNode)? - let reactionSpacing: CGFloat = -4.0 - let reactionTrailingSpacing: CGFloat = 4.0 + let reactionSpacing: CGFloat = 2.0 + let reactionTrailingSpacing: CGFloat = 6.0 var reactionInset: CGFloat = 0.0 - if !"".isEmpty && !arguments.reactions.isEmpty { + if arguments.layoutInput.displayInlineReactions, !arguments.reactions.isEmpty { reactionInset = -1.0 + CGFloat(arguments.reactions.count) * reactionSize + CGFloat(arguments.reactions.count - 1) * reactionSpacing + reactionTrailingSpacing var count = 0 @@ -541,9 +582,11 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { 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 count > arguments.reactions.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 += layoutAndApply.0.size.width + 4.0 + reactionCountLayoutAndApply = layoutAndApply + } } if arguments.replyCount > 0 { @@ -592,40 +635,56 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { constrainedWidth: arguments.constrainedSize.width, transition: .immediate ) - case let .trailingContent(contentWidth, preferAdditionalInset): - reactionButtons = reactionButtonsContainer.update( - context: arguments.context, - action: { value in - guard let strongSelf = self else { - return - } - strongSelf.reactionSelected?(value) - }, - reactions: arguments.reactions.map { reaction in - var iconFile: TelegramMediaFile? - - if let availableReactions = arguments.availableReactions { - for availableReaction in availableReactions.reactions { - if availableReaction.value == reaction.value { - iconFile = availableReaction.staticIcon - break + case let .trailingContent(contentWidth, reactionSettings): + if let reactionSettings = reactionSettings, !reactionSettings.displayInline { + reactionButtons = reactionButtonsContainer.update( + context: arguments.context, + action: { value in + guard let strongSelf = self else { + return + } + strongSelf.reactionSelected?(value) + }, + reactions: arguments.reactions.map { reaction in + var iconFile: TelegramMediaFile? + + if let availableReactions = arguments.availableReactions { + for availableReaction in availableReactions.reactions { + if availableReaction.value == reaction.value { + iconFile = availableReaction.staticIcon + break + } } } - } - - return ReactionButtonsLayoutContainer.Reaction( - reaction: ReactionButtonComponent.Reaction( - value: reaction.value, - iconFile: iconFile - ), - count: Int(reaction.count), - isSelected: reaction.isSelected - ) - }, - colors: reactionColors, - constrainedWidth: arguments.constrainedSize.width, - transition: .immediate - ) + + return ReactionButtonsLayoutContainer.Reaction( + reaction: ReactionButtonComponent.Reaction( + value: reaction.value, + iconFile: iconFile + ), + count: Int(reaction.count), + isSelected: reaction.isSelected + ) + }, + colors: reactionColors, + constrainedWidth: arguments.constrainedSize.width, + transition: .immediate + ) + } else { + reactionButtons = reactionButtonsContainer.update( + context: arguments.context, + action: { value in + guard let strongSelf = self else { + return + } + strongSelf.reactionSelected?(value) + }, + reactions: [], + colors: reactionColors, + constrainedWidth: arguments.constrainedSize.width, + transition: .immediate + ) + } var reactionButtonsSize = CGSize() var currentRowWidth: CGFloat = 0.0 @@ -664,25 +723,32 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { resultingHeight = 0.0 } } else { - if preferAdditionalInset { - verticalReactionsInset = 5.0 + var additionalVerticalInset: CGFloat = 0.0 + if let reactionSettings = reactionSettings { + if reactionSettings.preferAdditionalInset { + verticalReactionsInset = 8.0 + additionalVerticalInset += 1.0 + } else { + verticalReactionsInset = 3.0 + } } else { - verticalReactionsInset = 2.0 + verticalReactionsInset = 0.0 } + if currentRowWidth + layoutSize.width > arguments.constrainedSize.width { resultingWidth = max(layoutSize.width, reactionButtonsSize.width) - resultingHeight = verticalReactionsInset + reactionButtonsSize.height + layoutSize.height - verticalInset = verticalReactionsInset + reactionButtonsSize.height + resultingHeight = verticalReactionsInset + reactionButtonsSize.height + 1.0 + layoutSize.height + verticalInset = verticalReactionsInset + reactionButtonsSize.height + 3.0 } else { - resultingWidth = layoutSize.width + currentRowWidth - verticalInset = verticalReactionsInset + reactionButtonsSize.height - layoutSize.height - resultingHeight = verticalReactionsInset + reactionButtonsSize.height + resultingWidth = max(layoutSize.width + currentRowWidth, reactionButtonsSize.width) + verticalInset = verticalReactionsInset + reactionButtonsSize.height - layoutSize.height + additionalVerticalInset + resultingHeight = verticalReactionsInset + reactionButtonsSize.height + 1.0 } } } return (resultingWidth, { boundingWidth in - return (CGSize(width: boundingWidth, height: resultingHeight), { animated in + return (CGSize(width: boundingWidth, height: resultingHeight), { animation in if let strongSelf = self { let leftOffset = boundingWidth - layoutSize.width @@ -690,7 +756,7 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { strongSelf.type = arguments.type strongSelf.layoutSize = layoutSize - var reactionButtonPosition = CGPoint(x: 0.0, y: verticalReactionsInset) + var reactionButtonPosition = CGPoint(x: -1.0, y: verticalReactionsInset) for item in reactionButtons.items { if reactionButtonPosition.x + item.size.width > boundingWidth { reactionButtonPosition.x = 0.0 @@ -699,13 +765,27 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { if item.view.superview == nil { strongSelf.view.addSubview(item.view) + item.view.frame = CGRect(origin: reactionButtonPosition, size: item.size) + + if animation.isAnimated { + item.view.layer.animateScale(from: 0.01, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + item.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } else { + animation.animator.updateFrame(layer: item.view.layer, frame: CGRect(origin: reactionButtonPosition, size: item.size), completion: nil) } - item.view.frame = CGRect(origin: reactionButtonPosition, size: item.size) reactionButtonPosition.x += item.size.width + 6.0 } for view in reactionButtons.removedViews { - view.removeFromSuperview() + if animation.isAnimated { + view.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak view] _ in + view?.removeFromSuperview() + }) + } else { + view.removeFromSuperview() + } } if backgroundImage != nil { @@ -719,11 +799,7 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } } if let backgroundNode = strongSelf.backgroundNode { - let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate - if let previousLayoutSize = previousLayoutSize { - backgroundNode.frame = backgroundNode.frame.offsetBy(dx: layoutSize.width - previousLayoutSize.width, dy: 0.0) - } - transition.updateFrame(node: backgroundNode, frame: CGRect(origin: CGPoint(), size: layoutSize)) + animation.animator.updateFrame(layer: backgroundNode.layer, frame: CGRect(origin: CGPoint(), size: layoutSize), completion: nil) } } else { if let backgroundNode = strongSelf.backgroundNode { @@ -735,12 +811,8 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { if let blurredBackgroundColor = blurredBackgroundColor { if let blurredBackgroundNode = strongSelf.blurredBackgroundNode { blurredBackgroundNode.updateColor(color: blurredBackgroundColor.0, enableBlur: blurredBackgroundColor.1, transition: .immediate) - let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate - if let previousLayoutSize = previousLayoutSize { - blurredBackgroundNode.frame = blurredBackgroundNode.frame.offsetBy(dx: layoutSize.width - previousLayoutSize.width, dy: 0.0) - } - transition.updateFrame(node: blurredBackgroundNode, frame: CGRect(origin: CGPoint(), size: layoutSize)) - blurredBackgroundNode.update(size: blurredBackgroundNode.bounds.size, cornerRadius: blurredBackgroundNode.bounds.height / 2.0, transition: transition) + animation.animator.updateFrame(layer: blurredBackgroundNode.layer, frame: CGRect(origin: CGPoint(), size: layoutSize), completion: nil) + blurredBackgroundNode.update(size: blurredBackgroundNode.bounds.size, cornerRadius: blurredBackgroundNode.bounds.height / 2.0, animator: animation.animator) } else { let blurredBackgroundNode = NavigationBackgroundNode(color: blurredBackgroundColor.0, enableBlur: blurredBackgroundColor.1) strongSelf.blurredBackgroundNode = blurredBackgroundNode @@ -753,35 +825,42 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { blurredBackgroundNode.removeFromSupernode() } - strongSelf.dateNode.displaysAsynchronously = !arguments.presentationData.isPreview let _ = dateApply() if let currentImpressionIcon = currentImpressionIcon { - currentImpressionIcon.displaysAsynchronously = !arguments.presentationData.isPreview + let impressionIconFrame = CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left, y: backgroundInsets.top + 1.0 + offset + verticalInset + floor((date.size.height - impressionSize.height) / 2.0)), size: impressionSize) + currentImpressionIcon.displaysAsynchronously = false if currentImpressionIcon.image !== impressionImage { currentImpressionIcon.image = impressionImage } if currentImpressionIcon.supernode == nil { strongSelf.impressionIcon = currentImpressionIcon strongSelf.addSubnode(currentImpressionIcon) + currentImpressionIcon.frame = impressionIconFrame + } else { + animation.animator.updateFrame(layer: currentImpressionIcon.layer, frame: impressionIconFrame, completion: nil) } - currentImpressionIcon.frame = CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left, y: backgroundInsets.top + 1.0 + offset + verticalInset + floor((date.size.height - impressionSize.height) / 2.0)), size: impressionSize) } else if let impressionIcon = strongSelf.impressionIcon { impressionIcon.removeFromSupernode() strongSelf.impressionIcon = nil } - strongSelf.dateNode.frame = CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left + impressionWidth, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: date.size) + animation.animator.updateFrame(layer: strongSelf.dateNode.layer, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left + impressionWidth, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: date.size), completion: nil) if let clockFrameNode = clockFrameNode { + let clockPosition = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if strongSelf.clockFrameNode == nil { strongSelf.clockFrameNode = clockFrameNode clockFrameNode.image = clockFrameImage strongSelf.addSubnode(clockFrameNode) - } else if themeUpdated { - clockFrameNode.image = clockFrameImage + + clockFrameNode.position = clockPosition + } else { + if themeUpdated { + clockFrameNode.image = clockFrameImage + } + animation.animator.updatePosition(layer: clockFrameNode.layer, position: clockPosition, completion: nil) } - clockFrameNode.position = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if let clockFrameNode = strongSelf.clockFrameNode { maybeAddRotationAnimation(clockFrameNode.layer, duration: 6.0) } @@ -791,14 +870,19 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } if let clockMinNode = clockMinNode { + let clockMinPosition = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if strongSelf.clockMinNode == nil { strongSelf.clockMinNode = clockMinNode clockMinNode.image = clockMinImage strongSelf.addSubnode(clockMinNode) - } else if themeUpdated { - clockMinNode.image = clockMinImage + + clockMinNode.position = clockMinPosition + } else { + if themeUpdated { + clockMinNode.image = clockMinImage + } + animation.animator.updatePosition(layer: clockMinNode.layer, position: clockMinPosition, completion: nil) } - clockMinNode.position = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if let clockMinNode = strongSelf.clockMinNode { maybeAddRotationAnimation(clockMinNode.layer, duration: 1.0) } @@ -813,24 +897,26 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { checkSentNode.image = loadedCheckFullImage strongSelf.checkSentNode = checkSentNode strongSelf.addSubnode(checkSentNode) - animateSentNode = animated + animateSentNode = animation.isAnimated } else if themeUpdated { checkSentNode.image = loadedCheckFullImage } if let checkSentFrame = checkSentFrame { + let actualCheckSentFrame = checkSentFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset) + if checkSentNode.isHidden { - animateSentNode = animated + animateSentNode = animation.isAnimated } checkSentNode.isHidden = false - checkSentNode.frame = checkSentFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset) + animation.animator.updateFrame(layer: checkSentNode.layer, frame: actualCheckSentFrame, completion: nil) } else { checkSentNode.isHidden = true } var animateReadNode = false if strongSelf.checkReadNode == nil { - animateReadNode = animated + animateReadNode = animation.isAnimated checkReadNode.image = loadedCheckPartialImage strongSelf.checkReadNode = checkReadNode strongSelf.addSubnode(checkReadNode) @@ -840,10 +926,12 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { if let checkReadFrame = checkReadFrame { if checkReadNode.isHidden { - animateReadNode = animated + animateReadNode = animation.isAnimated + checkReadNode.frame = checkReadFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset) + } else { + animation.animator.updateFrame(layer: checkReadNode.layer, frame: checkReadFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset), completion: nil) } checkReadNode.isHidden = false - checkReadNode.frame = checkReadFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset) } else { checkReadNode.isHidden = true } @@ -862,56 +950,77 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } var reactionOffset: CGFloat = leftOffset + leftInset - reactionInset + backgroundInsets.left - if !"".isEmpty { - for i in 0 ..< arguments.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 + if arguments.layoutInput.displayInlineReactions { + var validReactions = Set() + for reaction in arguments.reactions.sorted(by: { lhs, rhs in + if lhs.isSelected != rhs.isSelected { + if lhs.isSelected { + return true } else { - strongSelf.reactionNodes.append(node) + return false + } + } else { + return lhs.value < rhs.value + } + }) { + let node: StatusReactionNode + var animateNode = true + if let current = strongSelf.reactionNodes[reaction.value] { + node = current + } else { + animateNode = false + node = StatusReactionNode() + strongSelf.reactionNodes[reaction.value] = node + } + validReactions.insert(reaction.value) + + var iconFile: TelegramMediaFile? + + if let availableReactions = arguments.availableReactions { + for availableReaction in availableReactions.reactions { + if availableReaction.value == reaction.value { + iconFile = availableReaction.staticIcon + break + } } } - node.update(type: arguments.type, value: arguments.reactions[i].value, isSelected: arguments.reactions[i].isSelected, count: Int(arguments.reactions[i].count), theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper, animated: false) + node.update(context: arguments.context, type: arguments.type, value: reaction.value, file: iconFile, isSelected: reaction.isSelected, count: Int(reaction.count), theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper, animated: false) if node.supernode == nil { strongSelf.addSubnode(node) - if animated { + if animation.isAnimated { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } - node.frame = CGRect(origin: CGPoint(x: reactionOffset, y: backgroundInsets.top + offset + verticalInset + 1.0), size: CGSize(width: reactionSize, height: reactionSize)) + let nodeFrame = CGRect(origin: CGPoint(x: reactionOffset, y: backgroundInsets.top + offset + verticalInset - 2.0), size: CGSize(width: reactionSize, height: reactionSize)) + if animateNode { + animation.animator.updateFrame(layer: node.layer, frame: nodeFrame, completion: nil) + } else { + node.frame = nodeFrame + } reactionOffset += reactionSize + reactionSpacing } if !arguments.reactions.isEmpty { reactionOffset += reactionTrailingSpacing } - - for _ in arguments.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) + + var removeIds: [String] = [] + for (id, node) in strongSelf.reactionNodes { + if !validReactions.contains(id) { + removeIds.append(id) + if animation.isAnimated { + 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() } - 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() } } + for id in removeIds { + strongSelf.reactionNodes.removeValue(forKey: id) + } } if let (layout, apply) = reactionCountLayoutAndApply { @@ -920,18 +1029,16 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { strongSelf.reactionCountNode?.removeFromSupernode() strongSelf.addSubnode(node) strongSelf.reactionCountNode = node - if animated { + if animation.isAnimated { 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 + verticalInset), size: layout.size) + let nodeFrame = CGRect(origin: CGPoint(x: reactionOffset - 4.0, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: layout.size) + animation.animator.updateFrame(layer: node.layer, frame: nodeFrame, completion: nil) reactionOffset += 1.0 + layout.size.width + 4.0 } 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) - } + if animation.isAnimated { reactionCountNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak reactionCountNode] _ in reactionCountNode?.removeFromSupernode() }) @@ -941,25 +1048,23 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } if let currentRepliesIcon = currentRepliesIcon { - currentRepliesIcon.displaysAsynchronously = !arguments.presentationData.isPreview + currentRepliesIcon.displaysAsynchronously = false if currentRepliesIcon.image !== repliesImage { currentRepliesIcon.image = repliesImage } if currentRepliesIcon.supernode == nil { strongSelf.repliesIcon = currentRepliesIcon strongSelf.addSubnode(currentRepliesIcon) - if animated { + if animation.isAnimated { currentRepliesIcon.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } - currentRepliesIcon.frame = CGRect(origin: CGPoint(x: reactionOffset - 2.0, y: backgroundInsets.top + offset + verticalInset + floor((date.size.height - repliesIconSize.height) / 2.0)), size: repliesIconSize) + let repliesIconFrame = CGRect(origin: CGPoint(x: reactionOffset - 2.0, y: backgroundInsets.top + offset + verticalInset + floor((date.size.height - repliesIconSize.height) / 2.0)), size: repliesIconSize) + animation.animator.updateFrame(layer: currentRepliesIcon.layer, frame: repliesIconFrame, completion: nil) reactionOffset += 9.0 } else if let repliesIcon = strongSelf.repliesIcon { strongSelf.repliesIcon = nil - if animated { - if let previousLayoutSize = previousLayoutSize { - repliesIcon.frame = repliesIcon.frame.offsetBy(dx: layoutSize.width - previousLayoutSize.width, dy: 0.0) - } + if animation.isAnimated { repliesIcon.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak repliesIcon] _ in repliesIcon?.removeFromSupernode() }) @@ -974,18 +1079,16 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { strongSelf.replyCountNode?.removeFromSupernode() strongSelf.addSubnode(node) strongSelf.replyCountNode = node - if animated { + if animation.isAnimated { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } - node.frame = CGRect(origin: CGPoint(x: reactionOffset + 4.0, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: layout.size) + let replyCountFrame = CGRect(origin: CGPoint(x: reactionOffset + 4.0, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: layout.size) + animation.animator.updateFrame(layer: node.layer, frame: replyCountFrame, completion: nil) reactionOffset += 4.0 + layout.size.width } else if let replyCountNode = strongSelf.replyCountNode { strongSelf.replyCountNode = nil - if animated { - if let previousLayoutSize = previousLayoutSize { - replyCountNode.frame = replyCountNode.frame.offsetBy(dx: layoutSize.width - previousLayoutSize.width, dy: 0.0) - } + if animation.isAnimated { replyCountNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak replyCountNode] _ in replyCountNode?.removeFromSupernode() }) @@ -999,11 +1102,11 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } } - static func asyncLayout(_ node: ChatMessageDateAndStatusNode?) -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (Bool) -> ChatMessageDateAndStatusNode)) { + static func asyncLayout(_ node: ChatMessageDateAndStatusNode?) -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)) { let currentLayout = node?.asyncLayout() return { arguments in let resultNode: ChatMessageDateAndStatusNode - let resultSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void)) + let resultSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void)) if let node = node, let currentLayout = currentLayout { resultNode = node resultSuggestedWidthAndContinue = currentLayout(arguments) @@ -1014,8 +1117,8 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { return (resultSuggestedWidthAndContinue.0, { boundingWidth in let (size, apply) = resultSuggestedWidthAndContinue.1(boundingWidth) - return (size, { animated in - apply(animated) + return (size, { animation in + apply(animation) return resultNode }) @@ -1024,6 +1127,11 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { } func reactionView(value: String) -> UIView? { + for (id, node) in self.reactionNodes { + if id == value { + return node.iconView + } + } for (_, button) in self.reactionButtonsContainer.buttons { if let result = button.findTaggedView(tag: ReactionButtonComponent.ViewTag(value: value)) as? ReactionButtonComponent.View { return result.iconView @@ -1048,3 +1156,10 @@ class ChatMessageDateAndStatusNode: ASDisplayNode { return nil } } + +func shouldDisplayInlineDateReactions(message: Message) -> Bool { + if message.id.peerId.namespace == Namespaces.Peer.CloudUser || message.id.peerId.namespace == Namespaces.Peer.SecretChat { + return true + } + return false +} diff --git a/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift index 30ba4badc4..935f6aa1b1 100644 --- a/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift @@ -61,7 +61,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { guard let strongSelf = self, let item = strongSelf.item else { return } - item.controllerInteraction.updateMessageReaction(item.message, value) + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) } } @@ -108,7 +108,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { let automaticDownload = shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: selectedFile!) - let (initialWidth, refineLayout) = interactiveFileLayout(item.context, item.presentationData, item.message, item.associatedData, item.chatLocation, item.attributes, item.isItemPinned, item.isItemEdited, selectedFile!, automaticDownload, item.message.effectivelyIncoming(item.context.account.peerId), item.associatedData.isRecentActions, item.associatedData.forcedResourceStatus, statusType, item.message.groupingKey != nil ? selection : nil, CGSize(width: constrainedSize.width - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right, height: constrainedSize.height)) + let (initialWidth, refineLayout) = interactiveFileLayout(item.context, item.presentationData, item.message, item.topMessage, item.associatedData, item.chatLocation, item.attributes, item.isItemPinned, item.isItemEdited, selectedFile!, automaticDownload, item.message.effectivelyIncoming(item.context.account.peerId), item.associatedData.isRecentActions, item.associatedData.forcedResourceStatus, statusType, item.message.groupingKey != nil ? selection : nil, CGSize(width: constrainedSize.width - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right, height: constrainedSize.height)) let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none) @@ -130,13 +130,13 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { } } - return (CGSize(width: fileSize.width + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, height: fileSize.height + layoutConstants.file.bubbleInsets.top + bottomInset), { [weak self] _, synchronousLoads in + return (CGSize(width: fileSize.width + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, height: fileSize.height + layoutConstants.file.bubbleInsets.top + bottomInset), { [weak self] animation, synchronousLoads in if let strongSelf = self { strongSelf.item = item strongSelf.interactiveFileNode.frame = CGRect(origin: CGPoint(x: layoutConstants.file.bubbleInsets.left, y: layoutConstants.file.bubbleInsets.top), size: fileSize) - fileApply(synchronousLoads) + fileApply(synchronousLoads, animation) } }) }) diff --git a/submodules/TelegramUI/Sources/ChatMessageInstantVideoItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageInstantVideoItemNode.swift index 0f676c9eca..8b8447208f 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInstantVideoItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInstantVideoItemNode.swift @@ -50,6 +50,7 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD private var replyBackgroundNode: NavigationBackgroundNode? private var actionButtonsNode: ChatMessageActionButtonsNode? + private var reactionButtonsNode: ChatMessageReactionButtonsNode? private let messageAccessibilityArea: AccessibilityAreaNode @@ -255,6 +256,7 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD let currentForwardBackgroundNode = self.forwardBackgroundNode let actionButtonsLayout = ChatMessageActionButtonsNode.asyncLayout(self.actionButtonsNode) + let reactionButtonsLayout = ChatMessageReactionButtonsNode.asyncLayout(self.reactionButtonsNode) let currentItem = self.appliedItem let currentForwardInfo = self.appliedForwardInfo @@ -387,7 +389,7 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD isReplyThread = true } - let (videoLayout, videoApply) = makeVideoLayout(ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: item.message, read: item.read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: item.content.firstMessageAttributes, isItemPinned: item.message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), params.width - params.leftInset - params.rightInset - avatarInset, displaySize, maximumDisplaySize, isPlaying ? 1.0 : 0.0, .free, automaticDownload) + let (videoLayout, videoApply) = makeVideoLayout(ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: item.message, topMessage: item.content.firstMessage, read: item.read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: item.content.firstMessageAttributes, isItemPinned: item.message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), params.width - params.leftInset - params.rightInset - avatarInset, displaySize, maximumDisplaySize, isPlaying ? 1.0 : 0.0, .free, automaticDownload) let videoFrame = CGRect(origin: CGPoint(x: (incoming ? (params.leftInset + layoutConstants.bubble.edgeInset + effectiveAvatarInset + layoutConstants.bubble.contentInsets.left) : (params.width - params.rightInset - videoLayout.contentSize.width - layoutConstants.bubble.edgeInset - layoutConstants.bubble.contentInsets.left - deliveryFailedInset)), y: 0.0), size: videoLayout.contentSize) @@ -527,22 +529,54 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD } var maxContentWidth = normalDisplaySize.width - var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode))? + var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode))? if let replyMarkup = replyMarkup { let (minWidth, buttonsLayout) = actionButtonsLayout(item.context, item.presentationData.theme, item.presentationData.chatBubbleCorners, item.presentationData.strings, replyMarkup, item.message, maxContentWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout } - var actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)? + var actionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)? if let actionButtonsFinalize = actionButtonsFinalize { actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) } + let reactions: ReactionsMessageAttribute + if shouldDisplayInlineDateReactions(message: item.message) { + reactions = ReactionsMessageAttribute(reactions: [], recentPeers: []) + } else { + reactions = mergedMessageReactions(attributes: item.message.attributes) ?? ReactionsMessageAttribute(reactions: [], recentPeers: []) + } + + var reactionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode))? + if !reactions.reactions.isEmpty { + let totalInset = params.leftInset + layoutConstants.bubble.edgeInset * 2.0 + avatarInset + layoutConstants.bubble.contentInsets.left + params.rightInset + layoutConstants.bubble.contentInsets.right + + let maxReactionsWidth = params.width - totalInset + let (minWidth, buttonsLayout) = reactionButtonsLayout(ChatMessageReactionButtonsNode.Arguments( + context: item.context, + presentationData: item.presentationData, + availableReactions: item.associatedData.availableReactions, + reactions: reactions, + isIncoming: item.message.effectivelyIncoming(item.context.account.peerId), + constrainedWidth: maxReactionsWidth + )) + maxContentWidth = max(maxContentWidth, minWidth) + reactionButtonsFinalize = buttonsLayout + } + + var reactionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)? + if let reactionButtonsFinalize = reactionButtonsFinalize { + reactionButtonsSizeAndApply = reactionButtonsFinalize(maxContentWidth) + } + var layoutSize = CGSize(width: params.width, height: videoLayout.contentSize.height) if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { layoutSize.height += actionButtonsSizeAndApply.0.height } + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + layoutSize.height += 6.0 + reactionButtonsSizeAndApply.0.height + } return (ListViewItemNodeLayout(contentSize: layoutSize, insets: layoutInsets), { [weak self] animation, _, _ in if let strongSelf = self { @@ -577,7 +611,7 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD let animating = (currentItem != nil && currentPlaying != isPlaying) || strongSelf.animatingHeight if !animating { strongSelf.interactiveVideoNode.frame = videoFrame - videoApply(videoLayoutData, transition) + videoApply(videoLayoutData, animation) } if currentPlaying != isPlaying { @@ -750,14 +784,38 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD } } - if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { - var animated = false - if let _ = strongSelf.actionButtonsNode { - if case .System = animation { - animated = true + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + let reactionButtonsNode = reactionButtonsSizeAndApply.1(animation) + let reactionButtonsFrame = CGRect(origin: CGPoint(x: videoFrame.minX, y: videoFrame.maxY + 6.0), size: reactionButtonsSizeAndApply.0) + if reactionButtonsNode !== strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = reactionButtonsNode + reactionButtonsNode.reactionSelected = { value in + guard let strongSelf = self, let item = strongSelf.item else { + return + } + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) } + reactionButtonsNode.frame = reactionButtonsFrame + strongSelf.addSubnode(reactionButtonsNode) + if animation.isAnimated { + reactionButtonsNode.animateIn(animation: animation) + } + } else { + animation.animator.updateFrame(layer: reactionButtonsNode.layer, frame: reactionButtonsFrame, completion: nil) } - let actionButtonsNode = actionButtonsSizeAndApply.1(animated) + } else if let reactionButtonsNode = strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = nil + if animation.isAnimated { + reactionButtonsNode.animateOut(animation: animation, completion: { [weak reactionButtonsNode] in + reactionButtonsNode?.removeFromSupernode() + }) + } else { + reactionButtonsNode.removeFromSupernode() + } + } + + if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { + let actionButtonsNode = actionButtonsSizeAndApply.1(animation) let previousFrame = actionButtonsNode.frame let actionButtonsFrame = CGRect(origin: CGPoint(x: videoFrame.minX, y: videoFrame.maxY), size: actionButtonsSizeAndApply.0) actionButtonsNode.frame = actionButtonsFrame @@ -775,7 +833,7 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD } strongSelf.addSubnode(actionButtonsNode) } else { - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { actionButtonsNode.layer.animateFrame(from: previousFrame, to: actionButtonsFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } } @@ -784,6 +842,12 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD strongSelf.actionButtonsNode = nil } } + + if let (_, f) = strongSelf.awaitingAppliedReaction { + strongSelf.awaitingAppliedReaction = nil + + f() + } } }) } @@ -1184,7 +1248,7 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD effectiveAvatarInset *= (1.0 - scaleProgress) displaySize = CGSize(width: initialSize.width + (targetSize.width - initialSize.width) * animationProgress, height: initialSize.height + (targetSize.height - initialSize.height) * animationProgress) - let (videoLayout, videoApply) = makeVideoLayout(ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: item.message, read: item.read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: item.content.firstMessageAttributes, isItemPinned: item.message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), params.width - params.leftInset - params.rightInset - avatarInset, displaySize, maximumDisplaySize, scaleProgress, .free, self.appliedAutomaticDownload) + let (videoLayout, videoApply) = makeVideoLayout(ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: item.message, topMessage: item.message, read: item.read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: item.content.firstMessageAttributes, isItemPinned: item.message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), params.width - params.leftInset - params.rightInset - avatarInset, displaySize, maximumDisplaySize, scaleProgress, .free, self.appliedAutomaticDownload) let availableContentWidth = params.width - params.leftInset - params.rightInset - layoutConstants.bubble.edgeInset * 2.0 - avatarInset - layoutConstants.bubble.contentInsets.left let videoFrame = CGRect(origin: CGPoint(x: (incoming ? (params.leftInset + layoutConstants.bubble.edgeInset + effectiveAvatarInset + layoutConstants.bubble.contentInsets.left) : (params.width - params.rightInset - videoLayout.contentSize.width - layoutConstants.bubble.edgeInset - layoutConstants.bubble.contentInsets.left - deliveryFailedInset)), y: 0.0), size: videoLayout.contentSize) @@ -1196,9 +1260,7 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD } else { videoLayoutData = .constrained(left: max(0.0, availableContentWidth - videoFrame.width), right: 0.0) } - videoApply(videoLayoutData, .immediate) - - + videoApply(videoLayoutData, .None) if let shareButtonNode = self.shareButtonNode { let buttonSize = shareButtonNode.frame.size @@ -1249,6 +1311,9 @@ class ChatMessageInstantVideoItemNode: ChatMessageItemView, UIGestureRecognizerD } override func targetReactionView(value: String) -> UIView? { + if let result = self.reactionButtonsNode?.reactionTargetView(value: value) { + return result + } if !self.interactiveVideoNode.dateAndStatusNode.isHidden { return self.interactiveVideoNode.dateAndStatusNode.reactionView(value: value) } diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift index c6a638689d..3bde6244d4 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift @@ -213,7 +213,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } } - func asyncLayout() -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ chatLocation: ChatLocation, _ attributes: ChatMessageEntryAttributes, _ isPinned: Bool, _ forcedIsEdited: Bool, _ file: TelegramMediaFile, _ automaticDownload: Bool, _ incoming: Bool, _ isRecentActions: Bool, _ forcedResourceStatus: FileMediaResourceStatus?, _ dateAndStatusType: ChatMessageDateAndStatusType?, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))) { + func asyncLayout() -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ topMessage: Message, _ associatedData: ChatMessageItemAssociatedData, _ chatLocation: ChatLocation, _ attributes: ChatMessageEntryAttributes, _ isPinned: Bool, _ forcedIsEdited: Bool, _ file: TelegramMediaFile, _ automaticDownload: Bool, _ incoming: Bool, _ isRecentActions: Bool, _ forcedResourceStatus: FileMediaResourceStatus?, _ dateAndStatusType: ChatMessageDateAndStatusType?, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation) -> Void))) { let currentFile = self.file let titleAsyncLayout = TextNode.asyncLayout(self.titleNode) @@ -223,7 +223,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { let currentMessage = self.message - return { context, presentationData, message, associatedData, chatLocation, attributes, isPinned, forcedIsEdited, file, automaticDownload, incoming, isRecentActions, forcedResourceStatus, dateAndStatusType, messageSelection, constrainedSize in + return { context, presentationData, message, topMessage, associatedData, chatLocation, attributes, isPinned, forcedIsEdited, file, automaticDownload, incoming, isRecentActions, forcedResourceStatus, dateAndStatusType, messageSelection, constrainedSize in return (CGFloat.greatestFiniteMagnitude, { constrainedSize in let titleFont = Font.regular(floor(presentationData.fontSize.baseDisplaySize * 16.0 / 17.0)) let descriptionFont = Font.with(size: floor(presentationData.fontSize.baseDisplaySize * 13.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers]) @@ -422,7 +422,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { controlAreaWidth = progressFrame.maxX + 8.0 } - var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))? + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if let statusType = dateAndStatusType { var edited = false if attributes.updatingMedia != nil { @@ -430,7 +430,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } var viewCount: Int? var dateReplies = 0 - let dateReactions: [MessageReaction] = mergedMessageReactions(attributes: message.attributes)?.reactions ?? [] + let dateReactions: [MessageReaction] = mergedMessageReactions(attributes: topMessage.attributes)?.reactions ?? [] for attribute in message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden @@ -455,7 +455,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .trailingContent(contentWidth: iconFrame == nil ? 1000.0 : controlAreaWidth, preferAdditionalInset: true), + layoutInput: .trailingContent(contentWidth: iconFrame == nil ? 1000.0 : controlAreaWidth, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: message), preferAdditionalInset: !shouldDisplayInlineDateReactions(message: message))), constrainedSize: constrainedSize, availableReactions: associatedData.availableReactions, reactions: dateReactions, @@ -520,7 +520,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { fittedLayoutSize = CGSize(width: unionSize.width, height: unionSize.height) } - var statusSizeAndApply: (CGSize, (Bool) -> Void)? + var statusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> Void)? if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue { statusSizeAndApply = statusSuggestedWidthAndContinue.1(boundingWidth) } @@ -541,7 +541,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { streamingCacheStatusFrame = CGRect() } - return (fittedLayoutSize, { [weak self] synchronousLoads in + return (fittedLayoutSize, { [weak self] synchronousLoads, animation in if let strongSelf = self { strongSelf.context = context strongSelf.presentationData = presentationData @@ -575,11 +575,14 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { statusReferenceFrame = progressFrame.offsetBy(dx: 0.0, dy: 8.0) } if let statusSizeAndApply = statusSizeAndApply { + let statusFrame = CGRect(origin: CGPoint(x: statusReferenceFrame.minX, y: statusReferenceFrame.maxY + statusOffset), size: statusSizeAndApply.0) if strongSelf.dateAndStatusNode.supernode == nil { - strongSelf.addSubnode(strongSelf.dateAndStatusNode) + strongSelf.dateAndStatusNode.frame = statusFrame + strongSelf.addSubnode(strongSelf.dateAndStatusNode) + } else { + animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: statusFrame, completion: nil) } - strongSelf.dateAndStatusNode.frame = CGRect(origin: CGPoint(x: statusReferenceFrame.minX, y: statusReferenceFrame.maxY + statusOffset), size: statusSizeAndApply.0) - statusSizeAndApply.1(false) + statusSizeAndApply.1(animation) } else if strongSelf.dateAndStatusNode.supernode != nil { strongSelf.dateAndStatusNode.removeFromSupernode() } @@ -1057,12 +1060,12 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { self.fetchingCompactTextNode.frame = CGRect(origin: self.descriptionNode.frame.origin, size: fetchingCompactSize) } - static func asyncLayout(_ node: ChatMessageInteractiveFileNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ chatLocation: ChatLocation, _ attributes: ChatMessageEntryAttributes, _ isPinned: Bool, _ forcedIsEdited: Bool, _ file: TelegramMediaFile, _ automaticDownload: Bool, _ incoming: Bool, _ isRecentActions: Bool, _ forcedResourceStatus: FileMediaResourceStatus?, _ dateAndStatusType: ChatMessageDateAndStatusType?, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool) -> ChatMessageInteractiveFileNode))) { + static func asyncLayout(_ node: ChatMessageInteractiveFileNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ topMessage: Message, _ associatedData: ChatMessageItemAssociatedData, _ chatLocation: ChatLocation, _ attributes: ChatMessageEntryAttributes, _ isPinned: Bool, _ forcedIsEdited: Bool, _ file: TelegramMediaFile, _ automaticDownload: Bool, _ incoming: Bool, _ isRecentActions: Bool, _ forcedResourceStatus: FileMediaResourceStatus?, _ dateAndStatusType: ChatMessageDateAndStatusType?, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation) -> ChatMessageInteractiveFileNode))) { let currentAsyncLayout = node?.asyncLayout() - return { context, presentationData, message, associatedData, chatLocation, attributes, isPinned, forcedIsEdited, file, automaticDownload, incoming, isRecentActions, forcedResourceStatus, dateAndStatusType, messageSelection, constrainedSize in + return { context, presentationData, message, topMessage, associatedData, chatLocation, attributes, isPinned, forcedIsEdited, file, automaticDownload, incoming, isRecentActions, forcedResourceStatus, dateAndStatusType, messageSelection, constrainedSize in var fileNode: ChatMessageInteractiveFileNode - var fileLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ chatLocation: ChatLocation, _ attributes: ChatMessageEntryAttributes, _ isPinned: Bool, _ forcedIsEdited: Bool, _ file: TelegramMediaFile, _ automaticDownload: Bool, _ incoming: Bool, _ isRecentActions: Bool, _ forcedResourceStatus: FileMediaResourceStatus?, _ dateAndStatusType: ChatMessageDateAndStatusType?, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))) + var fileLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ topMessage: Message, _ associatedData: ChatMessageItemAssociatedData, _ chatLocation: ChatLocation, _ attributes: ChatMessageEntryAttributes, _ isPinned: Bool, _ forcedIsEdited: Bool, _ file: TelegramMediaFile, _ automaticDownload: Bool, _ incoming: Bool, _ isRecentActions: Bool, _ forcedResourceStatus: FileMediaResourceStatus?, _ dateAndStatusType: ChatMessageDateAndStatusType?, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation) -> Void))) if let node = node, let currentAsyncLayout = currentAsyncLayout { fileNode = node @@ -1072,7 +1075,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { fileLayout = fileNode.asyncLayout() } - let (initialWidth, continueLayout) = fileLayout(context, presentationData, message, associatedData, chatLocation, attributes, isPinned, forcedIsEdited, file, automaticDownload, incoming, isRecentActions, forcedResourceStatus, dateAndStatusType, messageSelection, constrainedSize) + let (initialWidth, continueLayout) = fileLayout(context, presentationData, message, topMessage, associatedData, chatLocation, attributes, isPinned, forcedIsEdited, file, automaticDownload, incoming, isRecentActions, forcedResourceStatus, dateAndStatusType, messageSelection, constrainedSize) return (initialWidth, { constrainedSize in let (finalWidth, finalLayout) = continueLayout(constrainedSize) @@ -1080,8 +1083,8 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { return (finalWidth, { boundingWidth in let (finalSize, apply) = finalLayout(boundingWidth) - return (finalSize, { synchronousLoads in - apply(synchronousLoads) + return (finalSize, { synchronousLoads, animation in + apply(synchronousLoads, animation) return fileNode }) }) @@ -1129,8 +1132,15 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if self.dateAndStatusNode.supernode != nil, let result = self.dateAndStatusNode.hitTest(self.view.convert(point, to: self.dateAndStatusNode.view), with: event) { - return result + if self.dateAndStatusNode.supernode != nil { + if let result = self.dateAndStatusNode.hitTest(self.view.convert(point, to: self.dateAndStatusNode.view), with: event) { + return result + } + if !self.dateAndStatusNode.frame.height.isZero { + if self.dateAndStatusNode.frame.contains(point) { + return nil + } + } } return super.hitTest(point, with: event) } diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift index b0066c044e..bf2ada8be6 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -132,7 +132,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { self.view.addGestureRecognizer(recognizer) } - func asyncLayout() -> (_ item: ChatMessageBubbleContentItem, _ width: CGFloat, _ displaySize: CGSize, _ maximumDisplaySize: CGSize, _ scaleProgress: CGFloat, _ statusType: ChatMessageInteractiveInstantVideoNodeStatusType, _ automaticDownload: Bool) -> (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ContainedViewLayoutTransition) -> Void) { + func asyncLayout() -> (_ item: ChatMessageBubbleContentItem, _ width: CGFloat, _ displaySize: CGSize, _ maximumDisplaySize: CGSize, _ scaleProgress: CGFloat, _ statusType: ChatMessageInteractiveInstantVideoNodeStatusType, _ automaticDownload: Bool) -> (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ListViewItemUpdateAnimation) -> Void) { let previousFile = self.media let currentItem = self.item @@ -296,7 +296,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .standalone, + layoutInput: .standalone(reactionSettings: shouldDisplayInlineDateReactions(message: item.message) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil), constrainedSize: CGSize(width: max(1.0, maxDateAndStatusWidth), height: CGFloat.greatestFiniteMagnitude), availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -321,7 +321,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { let result = ChatMessageInstantVideoItemLayoutResult(contentSize: contentSize, overflowLeft: 0.0, overflowRight: dateAndStatusOverflow ? 0.0 : (max(0.0, floorToScreenPixels(videoFrame.midX) + 55.0 + dateAndStatusSize.width - videoFrame.width))) - return (result, { [weak self] layoutData, transition in + return (result, { [weak self] layoutData, animation in if let strongSelf = self { strongSelf.item = item strongSelf.videoFrame = displayVideoFrame @@ -362,18 +362,18 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { } } - dateAndStatusApply(false) + dateAndStatusApply(animation) switch layoutData { - case let .unconstrained(width): - let dateAndStatusOrigin: CGPoint - if dateAndStatusOverflow { - dateAndStatusOrigin = CGPoint(x: displayVideoFrame.minX - 4.0, y: displayVideoFrame.maxY + 2.0) - } else { - dateAndStatusOrigin = CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, width - dateAndStatusSize.width - 4.0), y: displayVideoFrame.height - dateAndStatusSize.height) - } - strongSelf.dateAndStatusNode.frame = CGRect(origin: dateAndStatusOrigin, size: dateAndStatusSize) - case let .constrained(_, right): - strongSelf.dateAndStatusNode.frame = CGRect(origin: CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, displayVideoFrame.maxX + right - dateAndStatusSize.width - 4.0), y: displayVideoFrame.maxY - dateAndStatusSize.height), size: dateAndStatusSize) + case let .unconstrained(width): + let dateAndStatusOrigin: CGPoint + if dateAndStatusOverflow { + dateAndStatusOrigin = CGPoint(x: displayVideoFrame.minX - 4.0, y: displayVideoFrame.maxY + 2.0) + } else { + dateAndStatusOrigin = CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, width - dateAndStatusSize.width - 4.0), y: displayVideoFrame.height - dateAndStatusSize.height) + } + animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: CGRect(origin: dateAndStatusOrigin, size: dateAndStatusSize), completion: nil) + case let .constrained(_, right): + animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: CGRect(origin: CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, displayVideoFrame.maxX + right - dateAndStatusSize.width - 4.0), y: displayVideoFrame.maxY - dateAndStatusSize.height), size: dateAndStatusSize), completion: nil) } var updatedPlayerStatusSignal: Signal? @@ -847,11 +847,11 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { return nil } - static func asyncLayout(_ node: ChatMessageInteractiveInstantVideoNode?) -> (_ item: ChatMessageBubbleContentItem, _ width: CGFloat, _ displaySize: CGSize, _ maximumDisplaySize: CGSize, _ scaleProgress: CGFloat, _ statusType: ChatMessageInteractiveInstantVideoNodeStatusType, _ automaticDownload: Bool) -> (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ContainedViewLayoutTransition) -> ChatMessageInteractiveInstantVideoNode) { + static func asyncLayout(_ node: ChatMessageInteractiveInstantVideoNode?) -> (_ item: ChatMessageBubbleContentItem, _ width: CGFloat, _ displaySize: CGSize, _ maximumDisplaySize: CGSize, _ scaleProgress: CGFloat, _ statusType: ChatMessageInteractiveInstantVideoNodeStatusType, _ automaticDownload: Bool) -> (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ListViewItemUpdateAnimation) -> ChatMessageInteractiveInstantVideoNode) { let makeLayout = node?.asyncLayout() return { item, width, displaySize, maximumDisplaySize, scaleProgress, statusType, automaticDownload in var createdNode: ChatMessageInteractiveInstantVideoNode? - let sizeAndApplyLayout: (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ContainedViewLayoutTransition) -> Void) + let sizeAndApplyLayout: (ChatMessageInstantVideoItemLayoutResult, (ChatMessageInstantVideoItemLayoutData, ListViewItemUpdateAnimation) -> Void) if let makeLayout = makeLayout { sizeAndApplyLayout = makeLayout(item, width, displaySize, maximumDisplaySize, scaleProgress, statusType, automaticDownload) } else { diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift index 1c965bfcef..84318d1ec8 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift @@ -91,7 +91,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio } let dateAndStatusNode: ChatMessageDateAndStatusNode private var badgeNode: ChatMessageInteractiveMediaBadge? - private var tapRecognizer: UITapGestureRecognizer? + private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer? private var context: AccountContext? private var message: Message? @@ -150,6 +150,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio var activateLocalContent: (InteractiveMediaNodeActivateContent) -> Void = { _ in } var activatePinch: ((PinchSourceContainerNode) -> Void)? + var updateMessageReaction: ((Message, ChatControllerInteractionReaction) -> Void)? override init() { self.pinchContainerNode = PinchSourceContainerNode() @@ -267,9 +268,18 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio override func didLoad() { super.didLoad() - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.imageTap(_:))) - self.imageNode.view.addGestureRecognizer(tapRecognizer) - self.tapRecognizer = tapRecognizer + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.imageTap(_:))) + recognizer.tapActionAtPoint = { [weak self] point in + guard let strongSelf = self else { + return .fail + } + if !strongSelf.imageNode.bounds.contains(point) { + return .fail + } + return .waitForDoubleTap + } + self.imageNode.view.addGestureRecognizer(recognizer) + self.tapRecognizer = recognizer } private func progressPressed(canActivate: Bool) { @@ -320,32 +330,39 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio } } - @objc func imageTap(_ recognizer: UITapGestureRecognizer) { + @objc func imageTap(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { if case .ended = recognizer.state { - let point = recognizer.location(in: self.imageNode.view) - if let _ = self.attributes?.updatingMedia { - if let statusNode = self.statusNode, statusNode.frame.contains(point) { - self.progressPressed(canActivate: true) - } - } else if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { - var videoContentMatch = true - if let content = self.videoContent, case let .message(stableId, mediaId) = content.nativeId { - videoContentMatch = self.message?.stableId == stableId && self.media?.id == mediaId - } - self.activateLocalContent((self.automaticPlayback ?? false) && videoContentMatch ? .automaticPlayback : .default) - } else { - if let message = self.message, message.flags.isSending { - if let statusNode = self.statusNode, statusNode.frame.contains(point) { - self.progressPressed(canActivate: true) + if let (gesture, point) = recognizer.lastRecognizedGestureAndLocation, let message = self.message { + if case .doubleTap = gesture { + if canAddMessageReactions(message: message) { + self.updateMessageReaction?(message, .default) } } else { - self.progressPressed(canActivate: true) + if let _ = self.attributes?.updatingMedia { + if let statusNode = self.statusNode, statusNode.frame.contains(point) { + self.progressPressed(canActivate: true) + } + } else if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { + var videoContentMatch = true + if let content = self.videoContent, case let .message(stableId, mediaId) = content.nativeId { + videoContentMatch = self.message?.stableId == stableId && self.media?.id == mediaId + } + self.activateLocalContent((self.automaticPlayback ?? false) && videoContentMatch ? .automaticPlayback : .default) + } else { + if let message = self.message, message.flags.isSending { + if let statusNode = self.statusNode, statusNode.frame.contains(point) { + self.progressPressed(canActivate: true) + } + } else { + self.progressPressed(canActivate: true) + } + } } } } } - func asyncLayout() -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ dateTimeFormat: PresentationDateTimeFormat, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ attributes: ChatMessageEntryAttributes, _ media: Media, _ dateAndStatus: ChatMessageDateAndStatus?, _ automaticDownload: InteractiveMediaNodeAutodownloadMode, _ peerType: MediaAutoDownloadPeerType, _ sizeCalculation: InteractiveMediaNodeSizeCalculation, _ layoutConstants: ChatMessageItemLayoutConstants, _ contentMode: InteractiveMediaNodeContentMode) -> (CGSize, CGFloat, (CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ContainedViewLayoutTransition, Bool) -> Void))) { + func asyncLayout() -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ dateTimeFormat: PresentationDateTimeFormat, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ attributes: ChatMessageEntryAttributes, _ media: Media, _ dateAndStatus: ChatMessageDateAndStatus?, _ automaticDownload: InteractiveMediaNodeAutodownloadMode, _ peerType: MediaAutoDownloadPeerType, _ sizeCalculation: InteractiveMediaNodeSizeCalculation, _ layoutConstants: ChatMessageItemLayoutConstants, _ contentMode: InteractiveMediaNodeContentMode) -> (CGSize, CGFloat, (CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))) { let currentMessage = self.message let currentMedia = self.media let imageLayout = self.imageNode.asyncLayout() @@ -465,7 +482,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio } var statusSize = CGSize() - var statusApply: ((Bool) -> Void)? + var statusApply: ((ListViewItemUpdateAnimation) -> Void)? if let dateAndStatus = dateAndStatus { let statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments( @@ -475,7 +492,7 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio impressionCount: dateAndStatus.viewCount, dateText: dateAndStatus.dateText, type: dateAndStatus.type, - layoutInput: .standalone, + layoutInput: .standalone(reactionSettings: shouldDisplayInlineDateReactions(message: message) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil), constrainedSize: CGSize(width: nativeSize.width - 30.0, height: CGFloat.greatestFiniteMagnitude), availableReactions: associatedData.availableReactions, reactions: dateAndStatus.dateReactions, @@ -854,9 +871,9 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio strongSelf.pinchContainerNode.update(size: imageFrame.size, transition: .immediate) strongSelf.imageNode.frame = CGRect(origin: CGPoint(), size: imageFrame.size) } else { - transition.updateFrame(node: strongSelf.pinchContainerNode, frame: imageFrame) - transition.updateFrame(node: strongSelf.imageNode, frame: CGRect(origin: CGPoint(), size: imageFrame.size)) - strongSelf.pinchContainerNode.update(size: imageFrame.size, transition: transition) + transition.animator.updateFrame(layer: strongSelf.pinchContainerNode.layer, frame: imageFrame, completion: nil) + transition.animator.updateFrame(layer: strongSelf.imageNode.layer, frame: CGRect(origin: CGPoint(), size: imageFrame.size), completion: nil) + strongSelf.pinchContainerNode.update(size: imageFrame.size, transition: transition.transition) } } else { @@ -868,19 +885,15 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio imageApply() if let statusApply = statusApply { + let dateAndStatusFrame = CGRect(origin: CGPoint(x: cleanImageFrame.width - layoutConstants.image.statusInsets.right - statusSize.width, y: cleanImageFrame.height - layoutConstants.image.statusInsets.bottom - statusSize.height), size: statusSize) if strongSelf.dateAndStatusNode.supernode == nil { strongSelf.pinchContainerNode.contentNode.addSubnode(strongSelf.dateAndStatusNode) + statusApply(.None) + strongSelf.dateAndStatusNode.frame = dateAndStatusFrame + } else { + transition.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: dateAndStatusFrame, completion: nil) + statusApply(transition) } - var hasAnimation = true - if transition.isAnimated { - hasAnimation = false - } - statusApply(hasAnimation) - - let dateAndStatusFrame = CGRect(origin: CGPoint(x: cleanImageFrame.width - layoutConstants.image.statusInsets.right - statusSize.width, y: cleanImageFrame.height - layoutConstants.image.statusInsets.bottom - statusSize.height), size: statusSize) - - strongSelf.dateAndStatusNode.frame = dateAndStatusFrame - strongSelf.dateAndStatusNode.bounds = CGRect(origin: CGPoint(), size: dateAndStatusFrame.size) } else if strongSelf.dateAndStatusNode.supernode != nil { strongSelf.dateAndStatusNode.removeFromSupernode() } @@ -1501,12 +1514,12 @@ final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTransitio } } - static func asyncLayout(_ node: ChatMessageInteractiveMediaNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ dateTimeFormat: PresentationDateTimeFormat, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ attributes: ChatMessageEntryAttributes, _ media: Media, _ dateAndStatus: ChatMessageDateAndStatus?, _ automaticDownload: InteractiveMediaNodeAutodownloadMode, _ peerType: MediaAutoDownloadPeerType, _ sizeCalculation: InteractiveMediaNodeSizeCalculation, _ layoutConstants: ChatMessageItemLayoutConstants, _ contentMode: InteractiveMediaNodeContentMode) -> (CGSize, CGFloat, (CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ContainedViewLayoutTransition, Bool) -> ChatMessageInteractiveMediaNode))) { + static func asyncLayout(_ node: ChatMessageInteractiveMediaNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ dateTimeFormat: PresentationDateTimeFormat, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ attributes: ChatMessageEntryAttributes, _ media: Media, _ dateAndStatus: ChatMessageDateAndStatus?, _ automaticDownload: InteractiveMediaNodeAutodownloadMode, _ peerType: MediaAutoDownloadPeerType, _ sizeCalculation: InteractiveMediaNodeSizeCalculation, _ layoutConstants: ChatMessageItemLayoutConstants, _ contentMode: InteractiveMediaNodeContentMode) -> (CGSize, CGFloat, (CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> ChatMessageInteractiveMediaNode))) { let currentAsyncLayout = node?.asyncLayout() return { context, presentationData, dateTimeFormat, message, associatedData, attributes, media, dateAndStatus, automaticDownload, peerType, sizeCalculation, layoutConstants, contentMode in var imageNode: ChatMessageInteractiveMediaNode - var imageLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ dateTimeFormat: PresentationDateTimeFormat, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ attributes: ChatMessageEntryAttributes, _ media: Media, _ dateAndStatus: ChatMessageDateAndStatus?, _ automaticDownload: InteractiveMediaNodeAutodownloadMode, _ peerType: MediaAutoDownloadPeerType, _ sizeCalculation: InteractiveMediaNodeSizeCalculation, _ layoutConstants: ChatMessageItemLayoutConstants, _ contentMode: InteractiveMediaNodeContentMode) -> (CGSize, CGFloat, (CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ContainedViewLayoutTransition, Bool) -> Void))) + var imageLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ dateTimeFormat: PresentationDateTimeFormat, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ attributes: ChatMessageEntryAttributes, _ media: Media, _ dateAndStatus: ChatMessageDateAndStatus?, _ automaticDownload: InteractiveMediaNodeAutodownloadMode, _ peerType: MediaAutoDownloadPeerType, _ sizeCalculation: InteractiveMediaNodeSizeCalculation, _ layoutConstants: ChatMessageItemLayoutConstants, _ contentMode: InteractiveMediaNodeContentMode) -> (CGSize, CGFloat, (CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))) if let node = node, let currentAsyncLayout = currentAsyncLayout { imageNode = node diff --git a/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift index 16c8909b93..ee08023fe9 100644 --- a/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageMapBubbleContentNode.swift @@ -237,7 +237,7 @@ class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { } var statusSize = CGSize() - var statusApply: ((Bool) -> Void)? + var statusApply: ((ListViewItemUpdateAnimation) -> Void)? if let statusType = statusType { var isReplyThread = false @@ -252,7 +252,7 @@ class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .standalone, + layoutInput: .standalone(reactionSettings: shouldDisplayInlineDateReactions(message: item.message) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil), constrainedSize: CGSize(width: constrainedSize.width, height: CGFloat.greatestFiniteMagnitude), availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -308,7 +308,7 @@ class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.imageNode.frame = imageFrame var transition: ContainedViewLayoutTransition = .immediate - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { transition = .animated(duration: duration, curve: .spring) } @@ -336,11 +336,7 @@ class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { if strongSelf.dateAndStatusNode.supernode == nil { strongSelf.addSubnode(strongSelf.dateAndStatusNode) } - var hasAnimation = true - if case .None = animation { - hasAnimation = false - } - statusApply(hasAnimation) + statusApply(animation) strongSelf.dateAndStatusNode.frame = statusFrame.offsetBy(dx: imageFrame.minX, dy: imageFrame.minY) } else if strongSelf.dateAndStatusNode.supernode != nil { strongSelf.dateAndStatusNode.removeFromSupernode() diff --git a/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift index 94b5662f31..ec7aa4bac3 100644 --- a/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageMediaBubbleContentNode.swift @@ -51,6 +51,13 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { } } } + + self.interactiveImageNode.updateMessageReaction = { [weak self] message, value in + guard let strongSelf = self, let item = strongSelf.item else { + return + } + item.controllerInteraction.updateMessageReaction(message, value) + } self.interactiveImageNode.activatePinch = { [weak self] sourceNode in guard let strongSelf = self, let _ = strongSelf.item else { @@ -246,14 +253,10 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.automaticPlayback = automaticPlayback let imageFrame = CGRect(origin: CGPoint(x: bubbleInsets.left, y: bubbleInsets.top), size: imageSize) - var transition: ContainedViewLayoutTransition = .immediate - if case let .System(duration) = animation { - transition = .animated(duration: duration, curve: .spring) - } - transition.updateFrame(node: strongSelf.interactiveImageNode, frame: imageFrame) + animation.animator.updateFrame(layer: strongSelf.interactiveImageNode.layer, frame: imageFrame, completion: nil) - imageApply(transition, synchronousLoads) + imageApply(animation, synchronousLoads) if let selection = selection { if let selectionNode = strongSelf.selectionNode { diff --git a/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift index adf5291082..6c9c6c6238 100644 --- a/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift @@ -1057,7 +1057,7 @@ class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { statusType = nil } - var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))? + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if let statusType = statusType { var isReplyThread = false @@ -1072,7 +1072,7 @@ class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .trailingContent(contentWidth: 100.0, preferAdditionalInset: true), + layoutInput: .standalone(reactionSettings: shouldDisplayInlineDateReactions(message: message) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil), constrainedSize: textConstrainedSize, availableReactions: item.associatedData.availableReactions, reactions: dateReactions, diff --git a/submodules/TelegramUI/Sources/ChatMessageReactionsFooterContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageReactionsFooterContentNode.swift new file mode 100644 index 0000000000..95bc1fea51 --- /dev/null +++ b/submodules/TelegramUI/Sources/ChatMessageReactionsFooterContentNode.swift @@ -0,0 +1,424 @@ +import Foundation +import UIKit +import Postbox +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import RadialStatusNode +import AnimatedCountLabelNode +import AnimatedAvatarSetNode +import ReactionButtonListComponent +import AccountContext + +final class MessageReactionButtonsNode: ASDisplayNode { + enum DisplayType { + case incoming + case outgoing + case freeform + } + + enum DisplayAlignment { + case left + case right + } + + private let container: ReactionButtonsLayoutContainer + var reactionSelected: ((String) -> Void)? + + override init() { + self.container = ReactionButtonsLayoutContainer() + + super.init() + } + + func prepareUpdate( + context: AccountContext, + presentationData: ChatPresentationData, + availableReactions: AvailableReactions?, + reactions: ReactionsMessageAttribute, + alignment: DisplayAlignment, + constrainedWidth: CGFloat, + type: DisplayType + ) -> (proposedWidth: CGFloat, continueLayout: (CGFloat) -> (size: CGSize, apply: (ListViewItemUpdateAnimation) -> Void)) { + let reactionColors: ReactionButtonComponent.Colors + switch type { + case .incoming: + reactionColors = ReactionButtonComponent.Colors( + deselectedBackground: presentationData.theme.theme.chat.message.incoming.accentControlColor.withMultipliedAlpha(0.1).argb, + selectedBackground: presentationData.theme.theme.chat.message.incoming.accentControlColor.withMultipliedAlpha(1.0).argb, + deselectedForeground: presentationData.theme.theme.chat.message.incoming.accentTextColor.argb, + selectedForeground: presentationData.theme.theme.chat.message.incoming.bubble.withWallpaper.fill.last!.argb + ) + case .outgoing: + reactionColors = ReactionButtonComponent.Colors( + deselectedBackground: presentationData.theme.theme.chat.message.outgoing.accentControlColor.withMultipliedAlpha(0.1).argb, + selectedBackground: presentationData.theme.theme.chat.message.outgoing.accentControlColor.withMultipliedAlpha(1.0).argb, + deselectedForeground: presentationData.theme.theme.chat.message.outgoing.accentTextColor.argb, + selectedForeground: presentationData.theme.theme.chat.message.outgoing.bubble.withWallpaper.fill.last!.argb + ) + case .freeform: + reactionColors = ReactionButtonComponent.Colors( + deselectedBackground: selectDateFillStaticColor(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper).argb, + selectedBackground: selectDateFillStaticColor(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper).argb, + deselectedForeground: bubbleVariableColor(variableColor: presentationData.theme.theme.chat.message.incoming.actionButtonsTextColor, wallpaper: presentationData.theme.wallpaper).argb, + selectedForeground: bubbleVariableColor(variableColor: presentationData.theme.theme.chat.message.incoming.actionButtonsTextColor, wallpaper: presentationData.theme.wallpaper).argb + ) + } + + let reactionButtons = self.container.update( + context: context, + action: { [weak self] value in + guard let strongSelf = self else { + return + } + strongSelf.reactionSelected?(value) + }, + reactions: reactions.reactions.map { reaction in + var iconFile: TelegramMediaFile? + + if let availableReactions = availableReactions { + for availableReaction in availableReactions.reactions { + if availableReaction.value == reaction.value { + iconFile = availableReaction.staticIcon + break + } + } + } + + return ReactionButtonsLayoutContainer.Reaction( + reaction: ReactionButtonComponent.Reaction( + value: reaction.value, + iconFile: iconFile + ), + count: Int(reaction.count), + isSelected: reaction.isSelected + ) + }, + colors: reactionColors, + constrainedWidth: constrainedWidth, + transition: .immediate + ) + + var reactionButtonsSize = CGSize() + var currentRowWidth: CGFloat = 0.0 + for item in reactionButtons.items { + if currentRowWidth + item.size.width > constrainedWidth { + reactionButtonsSize.width = max(reactionButtonsSize.width, currentRowWidth) + if !reactionButtonsSize.height.isZero { + reactionButtonsSize.height += 6.0 + } + reactionButtonsSize.height += item.size.height + currentRowWidth = 0.0 + } + + if !currentRowWidth.isZero { + currentRowWidth += 6.0 + } + currentRowWidth += item.size.width + } + if !currentRowWidth.isZero && !reactionButtons.items.isEmpty { + reactionButtonsSize.width = max(reactionButtonsSize.width, currentRowWidth) + if !reactionButtonsSize.height.isZero { + reactionButtonsSize.height += 6.0 + } + reactionButtonsSize.height += reactionButtons.items[0].size.height + } + + let topInset: CGFloat = 0.0 + let bottomInset: CGFloat = 2.0 + + return (proposedWidth: reactionButtonsSize.width, continueLayout: { [weak self] boundingWidth in + let size = CGSize(width: boundingWidth, height: topInset + reactionButtonsSize.height + bottomInset) + return (size: size, apply: { animation in + guard let strongSelf = self else { + return + } + + var reactionButtonPosition: CGPoint + switch alignment { + case .left: + reactionButtonPosition = CGPoint(x: -1.0, y: topInset) + case .right: + reactionButtonPosition = CGPoint(x: size.width + 1.0, y: topInset) + } + for item in reactionButtons.items { + switch alignment { + case .left: + if reactionButtonPosition.x + item.size.width > boundingWidth { + reactionButtonPosition.x = 0.0 + reactionButtonPosition.y += item.size.height + 6.0 + } + case .right: + if reactionButtonPosition.x - item.size.width < -1.0 { + reactionButtonPosition.x = size.width + 1.0 + reactionButtonPosition.y += item.size.height + 6.0 + } + } + + let itemFrame: CGRect + switch alignment { + case .left: + itemFrame = CGRect(origin: reactionButtonPosition, size: item.size) + reactionButtonPosition.x += item.size.width + 6.0 + case .right: + itemFrame = CGRect(origin: CGPoint(x: reactionButtonPosition.x - item.size.width, y: reactionButtonPosition.y), size: item.size) + reactionButtonPosition.x -= item.size.width + 6.0 + } + + if item.view.superview == nil { + strongSelf.view.addSubview(item.view) + if animation.isAnimated { + item.view.layer.animateScale(from: 0.01, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + item.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + item.view.frame = itemFrame + } else { + animation.animator.updateFrame(layer: item.view.layer, frame: itemFrame, completion: nil) + } + } + + for view in reactionButtons.removedViews { + if animation.isAnimated { + view.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak view] _ in + view?.removeFromSuperview() + }) + } else { + view.removeFromSuperview() + } + } + }) + }) + } + + func reactionTargetView(value: String) -> UIView? { + for (_, button) in self.container.buttons { + if let result = button.findTaggedView(tag: ReactionButtonComponent.ViewTag(value: value)) as? ReactionButtonComponent.View { + return result.iconView + } + } + return nil + } + + func animateIn(animation: ListViewItemUpdateAnimation) { + for (_, button) in self.container.buttons { + animation.animator.animateScale(layer: button.layer, from: 0.01, to: 1.0, completion: nil) + } + } + + func animateOut(animation: ListViewItemUpdateAnimation) { + for (_, button) in self.container.buttons { + animation.animator.updateScale(layer: button.layer, scale: 0.01, completion: nil) + } + } +} + +final class ChatMessageReactionsFooterContentNode: ChatMessageBubbleContentNode { + private let buttonsNode: MessageReactionButtonsNode + + required init() { + self.buttonsNode = MessageReactionButtonsNode() + + super.init() + + self.addSubnode(self.buttonsNode) + + self.buttonsNode.reactionSelected = { [weak self] value in + guard let strongSelf = self, let item = strongSelf.item else { + return + } + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))) { + let buttonsNode = self.buttonsNode + + return { item, layoutConstants, preparePosition, _, constrainedSize in + let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none) + + //let displaySeparator: Bool + let topOffset: CGFloat + if case let .linear(top, _) = preparePosition, case .Neighbour(_, .media, _) = top { + //displaySeparator = false + topOffset = 4.0 + } else { + //displaySeparator = true + topOffset = 0.0 + } + + return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in + let reactionsAttribute = mergedMessageReactions(attributes: item.message.attributes) ?? ReactionsMessageAttribute(reactions: [], recentPeers: []) + let buttonsUpdate = buttonsNode.prepareUpdate( + context: item.context, + presentationData: item.presentationData, + availableReactions: item.associatedData.availableReactions, reactions: reactionsAttribute, alignment: .left, constrainedWidth: constrainedSize.width, type: item.message.effectivelyIncoming(item.context.account.peerId) ? .incoming : .outgoing) + + return (layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + buttonsUpdate.proposedWidth, { boundingWidth in + var boundingSize = CGSize() + + let buttonsSizeAndApply = buttonsUpdate.continueLayout(boundingWidth - (layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right)) + + boundingSize = buttonsSizeAndApply.size + + boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + boundingSize.height += topOffset + 2.0 + + return (boundingSize, { [weak self] animation, synchronousLoad in + if let strongSelf = self { + strongSelf.item = item + + animation.animator.updateFrame(layer: strongSelf.buttonsNode.layer, frame: CGRect(origin: CGPoint(x: layoutConstants.text.bubbleInsets.left, y: topOffset - 2.0), size: buttonsSizeAndApply.size), completion: nil) + buttonsSizeAndApply.apply(animation) + + let _ = synchronousLoad + } + }) + }) + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + + override func animateAdded(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) + self.buttonsNode.animateOut(animation: ListViewItemUpdateAnimation.System(duration: 0.25, transition: ControlledTransition(duration: 0.25, curve: .spring, interactive: false))) + } + + override func animateInsertionIntoBubble(_ duration: Double) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + self.layer.animatePosition(from: CGPoint(x: 0.0, y: -self.bounds.height / 2.0), to: CGPoint(), duration: duration, removeOnCompletion: true, additive: true) + } + + override func animateRemovalFromBubble(_ duration: Double, completion: @escaping () -> Void) { + self.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -self.bounds.height / 2.0), duration: duration, removeOnCompletion: false, additive: true) + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + completion() + }) + self.buttonsNode.animateOut(animation: ListViewItemUpdateAnimation.System(duration: 0.25, transition: ControlledTransition(duration: 0.25, curve: .spring, interactive: false))) + } + + override func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + if let result = self.buttonsNode.hitTest(self.view.convert(point, to: self.buttonsNode.view), with: nil), result !== self.buttonsNode.view { + return .ignore + } + return .none + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let result = self.buttonsNode.hitTest(self.view.convert(point, to: self.buttonsNode.view), with: event), result !== self.buttonsNode.view { + return result + } + return nil + } + + override func reactionTargetView(value: String) -> UIView? { + return self.buttonsNode.reactionTargetView(value: value) + } +} + +final class ChatMessageReactionButtonsNode: ASDisplayNode { + final class Arguments { + let context: AccountContext + let presentationData: ChatPresentationData + let availableReactions: AvailableReactions? + let reactions: ReactionsMessageAttribute + let isIncoming: Bool + let constrainedWidth: CGFloat + + init( + context: AccountContext, + presentationData: ChatPresentationData, + availableReactions: AvailableReactions?, + reactions: ReactionsMessageAttribute, + isIncoming: Bool, + constrainedWidth: CGFloat + ) { + self.context = context + self.presentationData = presentationData + self.availableReactions = availableReactions + self.reactions = reactions + self.isIncoming = isIncoming + self.constrainedWidth = constrainedWidth + } + } + + private let buttonsNode: MessageReactionButtonsNode + + var reactionSelected: ((String) -> Void)? + + override init() { + self.buttonsNode = MessageReactionButtonsNode() + + super.init() + + self.addSubnode(self.buttonsNode) + self.buttonsNode.reactionSelected = { [weak self] value in + self?.reactionSelected?(value) + } + } + + class func asyncLayout(_ maybeNode: ChatMessageReactionButtonsNode?) -> (_ arguments: ChatMessageReactionButtonsNode.Arguments) -> (minWidth: CGFloat, layout: (CGFloat) -> (size: CGSize, apply: (_ animation: ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)) { + return { arguments in + let node = maybeNode ?? ChatMessageReactionButtonsNode() + + let buttonsUpdate = node.buttonsNode.prepareUpdate( + context: arguments.context, + presentationData: arguments.presentationData, + availableReactions: arguments.availableReactions, + reactions: arguments.reactions, + alignment: arguments.isIncoming ? .left : .right, + constrainedWidth: arguments.constrainedWidth, + type: .freeform + ) + + return (buttonsUpdate.proposedWidth, { constrainedWidth in + let buttonsResult = buttonsUpdate.continueLayout(constrainedWidth) + + return (CGSize(width: constrainedWidth, height: buttonsResult.size.height), { animation in + node.buttonsNode.frame = CGRect(origin: CGPoint(), size: buttonsResult.size) + buttonsResult.apply(animation) + + return node + }) + }) + } + } + + func animateIn(animation: ListViewItemUpdateAnimation) { + self.buttonsNode.animateIn(animation: animation) + self.buttonsNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + func animateOut(animation: ListViewItemUpdateAnimation, completion: @escaping () -> Void) { + self.buttonsNode.animateOut(animation: animation) + animation.animator.updateAlpha(layer: self.buttonsNode.layer, alpha: 0.0, completion: { _ in + completion() + }) + animation.animator.updateFrame(layer: self.buttonsNode.layer, frame: self.buttonsNode.layer.frame.offsetBy(dx: 0.0, dy: -self.buttonsNode.layer.bounds.height / 2.0), completion: nil) + } + + func reactionTargetView(value: String) -> UIView? { + return self.buttonsNode.reactionTargetView(value: value) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let result = self.buttonsNode.hitTest(self.view.convert(point, to: self.buttonsNode.view), with: event), result !== self.buttonsNode.view { + return result + } + return nil + } +} diff --git a/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift index 804d67eb01..db3defa635 100644 --- a/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageRestrictedBubbleContentNode.swift @@ -106,7 +106,7 @@ class ChatMessageRestrictedBubbleContentNode: ChatMessageBubbleContentNode { textFrame = textFrame.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: layoutConstants.text.bubbleInsets.top) textFrameWithoutInsets = textFrameWithoutInsets.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: layoutConstants.text.bubbleInsets.top) - var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))? + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if let statusType = statusType { var isReplyThread = false if case .replyThread = item.chatLocation { @@ -120,7 +120,7 @@ class ChatMessageRestrictedBubbleContentNode: ChatMessageBubbleContentNode { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .trailingContent(contentWidth: textLayout.trailingLineWidth, preferAdditionalInset: false), + layoutInput: .trailingContent(contentWidth: textLayout.trailingLineWidth, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: message), preferAdditionalInset: false)), constrainedSize: textConstrainedSize, availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -182,9 +182,9 @@ class ChatMessageRestrictedBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: textFrameWithoutInsets.maxY), size: statusSizeAndApply.0) if strongSelf.statusNode.supernode == nil { strongSelf.addSubnode(strongSelf.statusNode) - statusSizeAndApply.1(false) + statusSizeAndApply.1(.None) } else { - statusSizeAndApply.1(animation.isAnimated) + statusSizeAndApply.1(animation) } } else if strongSelf.statusNode.supernode != nil { strongSelf.statusNode.removeFromSupernode() diff --git a/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift index 6413b9b4f3..0224ea8e4a 100644 --- a/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageStickerItemNode.swift @@ -45,6 +45,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { private var replyBackgroundNode: NavigationBackgroundNode? private var actionButtonsNode: ChatMessageActionButtonsNode? + private var reactionButtonsNode: ChatMessageReactionButtonsNode? private let messageAccessibilityArea: AccessibilityAreaNode @@ -178,7 +179,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { } } } - return .waitForSingleTap + return .waitForDoubleTap } recognizer.longTap = { [weak self] point, recognizer in guard let strongSelf = self else { @@ -299,6 +300,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { let imageLayout = self.imageNode.asyncLayout() let makeDateAndStatusLayout = self.dateAndStatusNode.asyncLayout() let actionButtonsLayout = ChatMessageActionButtonsNode.asyncLayout(self.actionButtonsNode) + let reactionButtonsLayout = ChatMessageReactionButtonsNode.asyncLayout(self.reactionButtonsNode) let textLayout = TextNode.asyncLayout(self.textNode) let makeForwardInfoLayout = ChatMessageForwardInfoNode.asyncLayout(self.forwardInfoNode) @@ -490,7 +492,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .standalone, + layoutInput: .standalone(reactionSettings: shouldDisplayInlineDateReactions(message: item.message) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil), constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -619,22 +621,54 @@ class ChatMessageStickerItemNode: ChatMessageItemView { } var maxContentWidth = imageSize.width - var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode))? + var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode))? if let replyMarkup = replyMarkup { let (minWidth, buttonsLayout) = actionButtonsLayout(item.context, item.presentationData.theme, item.presentationData.chatBubbleCorners, item.presentationData.strings, replyMarkup, item.message, maxContentWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout } - var actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)? + var actionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageActionButtonsNode)? if let actionButtonsFinalize = actionButtonsFinalize { actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) } + let reactions: ReactionsMessageAttribute + if shouldDisplayInlineDateReactions(message: item.message) { + reactions = ReactionsMessageAttribute(reactions: [], recentPeers: []) + } else { + reactions = mergedMessageReactions(attributes: item.message.attributes) ?? ReactionsMessageAttribute(reactions: [], recentPeers: []) + } + + var reactionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animation: ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode))? + if !reactions.reactions.isEmpty { + let totalInset = params.leftInset + layoutConstants.bubble.edgeInset * 2.0 + avatarInset + layoutConstants.bubble.contentInsets.left + params.rightInset + layoutConstants.bubble.contentInsets.right + + let maxReactionsWidth = params.width - totalInset + let (minWidth, buttonsLayout) = reactionButtonsLayout(ChatMessageReactionButtonsNode.Arguments( + context: item.context, + presentationData: item.presentationData, + availableReactions: item.associatedData.availableReactions, + reactions: reactions, + isIncoming: item.message.effectivelyIncoming(item.context.account.peerId), + constrainedWidth: maxReactionsWidth + )) + maxContentWidth = max(maxContentWidth, minWidth) + reactionButtonsFinalize = buttonsLayout + } + + var reactionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)? + if let reactionButtonsFinalize = reactionButtonsFinalize { + reactionButtonsSizeAndApply = reactionButtonsFinalize(maxContentWidth) + } + var layoutSize = CGSize(width: params.width, height: contentHeight) if isEmoji && !incoming { layoutSize.height += dateAndStatusSize.height } + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + layoutSize.height += reactionButtonsSizeAndApply.0.height + } if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { layoutSize.height += actionButtonsSizeAndApply.0.height } @@ -701,7 +735,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { return (ListViewItemNodeLayout(contentSize: layoutSize, insets: layoutInsets), { [weak self] animation, _, _ in if let strongSelf = self { var transition: ContainedViewLayoutTransition = .immediate - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { transition = .animated(duration: duration, curve: .spring) } @@ -740,9 +774,8 @@ class ChatMessageStickerItemNode: ChatMessageItemView { strongSelf.contextSourceNode.contentRect = strongSelf.imageNode.frame strongSelf.containerNode.targetNodeForActivationProgressContentRect = strongSelf.contextSourceNode.contentRect - dateAndStatusApply(false) - - transition.updateFrame(node: strongSelf.dateAndStatusNode, frame: dateAndStatusFrame) + animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: dateAndStatusFrame, completion: nil) + dateAndStatusApply(animation) if let updatedShareButtonNode = updatedShareButtonNode { if updatedShareButtonNode !== strongSelf.shareButtonNode { @@ -902,13 +935,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { } if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { - var animated = false - if let _ = strongSelf.actionButtonsNode { - if case .System = animation { - animated = true - } - } - let actionButtonsNode = actionButtonsSizeAndApply.1(animated) + let actionButtonsNode = actionButtonsSizeAndApply.1(animation) let previousFrame = actionButtonsNode.frame let actionButtonsFrame = CGRect(origin: CGPoint(x: imageFrame.minX, y: imageFrame.maxY - 10.0), size: actionButtonsSizeAndApply.0) actionButtonsNode.frame = actionButtonsFrame @@ -926,7 +953,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { } strongSelf.addSubnode(actionButtonsNode) } else { - if case let .System(duration) = animation { + if case let .System(duration, _) = animation { actionButtonsNode.layer.animateFrame(from: previousFrame, to: actionButtonsFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } } @@ -935,6 +962,36 @@ class ChatMessageStickerItemNode: ChatMessageItemView { strongSelf.actionButtonsNode = nil } + if let reactionButtonsSizeAndApply = reactionButtonsSizeAndApply { + let reactionButtonsNode = reactionButtonsSizeAndApply.1(animation) + let reactionButtonsFrame = CGRect(origin: CGPoint(x: imageFrame.minX, y: imageFrame.maxY - 10.0), size: reactionButtonsSizeAndApply.0) + if reactionButtonsNode !== strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = reactionButtonsNode + reactionButtonsNode.reactionSelected = { value in + guard let strongSelf = self, let item = strongSelf.item else { + return + } + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) + } + reactionButtonsNode.frame = reactionButtonsFrame + strongSelf.addSubnode(reactionButtonsNode) + if animation.isAnimated { + reactionButtonsNode.animateIn(animation: animation) + } + } else { + animation.animator.updateFrame(layer: reactionButtonsNode.layer, frame: reactionButtonsFrame, completion: nil) + } + } else if let reactionButtonsNode = strongSelf.reactionButtonsNode { + strongSelf.reactionButtonsNode = nil + if animation.isAnimated { + reactionButtonsNode.animateOut(animation: animation, completion: { [weak reactionButtonsNode] in + reactionButtonsNode?.removeFromSupernode() + }) + } else { + reactionButtonsNode.removeFromSupernode() + } + } + if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported) { strongSelf.dateAndStatusNode.pressed = { guard let strongSelf = self else { @@ -963,7 +1020,7 @@ class ChatMessageStickerItemNode: ChatMessageItemView { if case .doubleTap = gesture { self.containerNode.cancelGesture() } - if let action = self.gestureRecognized(gesture: gesture, location: location, recognizer: nil) { + if let item = self.item, let action = self.gestureRecognized(gesture: gesture, location: location, recognizer: nil) { if case .doubleTap = gesture { self.containerNode.cancelGesture() } @@ -973,7 +1030,11 @@ class ChatMessageStickerItemNode: ChatMessageItemView { case let .optionalAction(f): f() case let .openContextMenu(tapMessage, selectAll, subFrame): - self.item?.controllerInteraction.openMessageContextMenu(tapMessage, selectAll, self, subFrame, nil) + if canAddMessageReactions(message: item.message) { + item.controllerInteraction.updateMessageReaction(tapMessage, .default) + } else { + item.controllerInteraction.openMessageContextMenu(tapMessage, selectAll, self, subFrame, nil) + } } } else if case .tap = gesture { self.item?.controllerInteraction.clickThroughMessage() @@ -1189,6 +1250,12 @@ class ChatMessageStickerItemNode: ChatMessageItemView { return shareButtonNode.view } + if let reactionButtonsNode = self.reactionButtonsNode { + if let result = reactionButtonsNode.hitTest(self.view.convert(point, to: reactionButtonsNode.view), with: event) { + return result + } + } + return super.hitTest(point, with: event) } @@ -1496,6 +1563,9 @@ class ChatMessageStickerItemNode: ChatMessageItemView { } override func targetReactionView(value: String) -> UIView? { + if let result = self.reactionButtonsNode?.reactionTargetView(value: value) { + return result + } if !self.dateAndStatusNode.isHidden { return self.dateAndStatusNode.reactionView(value: value) } diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index d5bdadc345..25f9b9c690 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -74,7 +74,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { guard let strongSelf = self, let item = strongSelf.item else { return } - item.controllerInteraction.updateMessageReaction(item.message, value) + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) } } @@ -285,13 +285,16 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { spoilerTextLayoutAndApply = nil } - var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (Bool) -> Void))? + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if let statusType = statusType { var isReplyThread = false if case .replyThread = item.chatLocation { isReplyThread = true } + let dateLayoutInput: ChatMessageDateAndStatusNode.LayoutInput + dateLayoutInput = .trailingContent(contentWidth: textLayout.trailingLineWidth, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: item.message), preferAdditionalInset: false)) + statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments( context: item.context, presentationData: item.presentationData, @@ -299,7 +302,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { impressionCount: viewCount, dateText: dateText, type: statusType, - layoutInput: .trailingContent(contentWidth: textLayout.trailingLineWidth, preferAdditionalInset: false), + layoutInput: dateLayoutInput, constrainedSize: textConstrainedSize, availableReactions: item.associatedData.availableReactions, reactions: dateReactions, @@ -363,9 +366,9 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } - strongSelf.textNode.displaysAsynchronously = !item.presentationData.isPreview && !item.presentationData.theme.theme.forceSync let _ = textApply() - strongSelf.textNode.frame = textFrame + animation.animator.updateFrame(layer: strongSelf.textNode.layer, frame: textFrame, completion: nil) + //strongSelf.textNode.frame = textFrame if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { let spoilerTextNode = spoilerTextApply() @@ -410,12 +413,12 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.textAccessibilityOverlayNode.cachedLayout = textLayout if let statusSizeAndApply = statusSizeAndApply { - strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: textFrameWithoutInsets.maxY), size: statusSizeAndApply.0) + animation.animator.updateFrame(layer: strongSelf.statusNode.layer, frame: CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: textFrameWithoutInsets.maxY), size: statusSizeAndApply.0), completion: nil) if strongSelf.statusNode.supernode == nil { strongSelf.addSubnode(strongSelf.statusNode) - statusSizeAndApply.1(false) + statusSizeAndApply.1(.None) } else { - statusSizeAndApply.1(animation.isAnimated) + statusSizeAndApply.1(animation) } } else if strongSelf.statusNode.supernode != nil { strongSelf.statusNode.removeFromSupernode() diff --git a/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift index 8192563e04..365e263cf5 100644 --- a/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageWebpageBubbleContentNode.swift @@ -544,9 +544,6 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } override func reactionTargetView(value: String) -> UIView? { - if !self.contentNode.statusNode.isHidden { - return self.contentNode.statusNode.reactionView(value: value) - } - return nil + return self.contentNode.reactionTargetView(value: value) } } diff --git a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift index 6f6657d8fc..3975bf926c 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift @@ -1079,7 +1079,7 @@ private final class ItemView: UIView, SparseItemGridView { let messageItemNode: ListViewItemNode if let current = self.messageItemNode { messageItemNode = current - messageItem.updateNode(async: { f in f() }, node: { return current }, params: ListViewItemLayoutParams(width: size.width, leftInset: insets.left, rightInset: insets.right, availableHeight: 0.0), previousItem: nil, nextItem: nil, animation: .System(duration: 0.2), completion: { layout, apply in + messageItem.updateNode(async: { f in f() }, node: { return current }, params: ListViewItemLayoutParams(width: size.width, leftInset: insets.left, rightInset: insets.right, availableHeight: 0.0), previousItem: nil, nextItem: nil, animation: .System(duration: 0.2, transition: ControlledTransition(duration: 0.2, curve: .spring, interactive: false)), completion: { layout, apply in current.contentSize = layout.contentSize current.insets = layout.insets @@ -1110,7 +1110,7 @@ private final class ItemView: UIView, SparseItemGridView { func update(size: CGSize, insets: UIEdgeInsets) { if let messageItem = self.messageItem, let messageItemNode = self.messageItemNode { - messageItem.updateNode(async: { f in f() }, node: { return messageItemNode }, params: ListViewItemLayoutParams(width: size.width, leftInset: insets.left, rightInset: insets.right, availableHeight: 0.0), previousItem: nil, nextItem: nil, animation: .System(duration: 0.2), completion: { layout, apply in + messageItem.updateNode(async: { f in f() }, node: { return messageItemNode }, params: ListViewItemLayoutParams(width: size.width, leftInset: insets.left, rightInset: insets.right, availableHeight: 0.0), previousItem: nil, nextItem: nil, animation: .System(duration: 0.2, transition: ControlledTransition(duration: 0.2, curve: .spring, interactive: false)), completion: { layout, apply in messageItemNode.contentSize = layout.contentSize messageItemNode.insets = layout.insets