diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift index c66dec6529..35b4c1f46d 100644 --- a/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutControllerNode.swift @@ -1524,7 +1524,7 @@ final class BotCheckoutControllerNode: ItemListControllerNode, PKPaymentAuthoriz } switch result { - case let .done(receiptMessageId): + case let .done(receiptMessageId, _): proceedWithCompletion(true, receiptMessageId) case let .externalVerificationRequired(url): strongSelf.updateActionButton() diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 227a314ad1..aa595927fe 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -345,7 +345,7 @@ public struct ComponentTransition { } } - public func setPosition(view: UIView, position: CGPoint, completion: ((Bool) -> Void)? = nil) { + public func setPosition(view: UIView, position: CGPoint, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { if view.center == position { completion?(true) return @@ -364,7 +364,7 @@ public struct ComponentTransition { } view.center = position - self.animatePosition(view: view, from: previousPosition, to: view.center, completion: completion) + self.animatePosition(view: view, from: previousPosition, to: view.center, delay: delay, completion: completion) } } @@ -803,8 +803,8 @@ public struct ComponentTransition { } } - public func animatePosition(view: UIView, from fromValue: CGPoint, to toValue: CGPoint, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { - self.animatePosition(layer: view.layer, from: fromValue, to: toValue, additive: additive, completion: completion) + public func animatePosition(view: UIView, from fromValue: CGPoint, to toValue: CGPoint, delay: Double = 0.0, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { + self.animatePosition(layer: view.layer, from: fromValue, to: toValue, delay: delay, additive: additive, completion: completion) } public func animateBounds(view: UIView, from fromValue: CGRect, to toValue: CGRect, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { @@ -819,7 +819,7 @@ public struct ComponentTransition { self.animateBoundsSize(layer: view.layer, from: fromValue, to: toValue, additive: additive, completion: completion) } - public func animatePosition(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { + public func animatePosition(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, delay: Double = 0.0, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { switch self.animation { case .none: completion?(true) @@ -829,7 +829,7 @@ public struct ComponentTransition { to: NSValue(cgPoint: toValue), keyPath: "position", duration: duration, - delay: 0.0, + delay: delay, curve: curve, removeOnCompletion: true, additive: additive, diff --git a/submodules/Components/ReactionButtonListComponent/BUILD b/submodules/Components/ReactionButtonListComponent/BUILD index f8167464d8..19425cd17d 100644 --- a/submodules/Components/ReactionButtonListComponent/BUILD +++ b/submodules/Components/ReactionButtonListComponent/BUILD @@ -25,6 +25,7 @@ swift_library( "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", "//submodules/TextFormat:TextFormat", "//submodules/AppBundle", + "//submodules/TelegramUI/Components/AnimatedTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift index 3efc953198..b640158a24 100644 --- a/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift +++ b/submodules/Components/ReactionButtonListComponent/Sources/ReactionButtonListComponent.swift @@ -15,6 +15,7 @@ import MultiAnimationRenderer import EmojiTextAttachmentView import TextFormat import AppBundle +import AnimatedTextComponent private let tagImage: UIImage? = { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/ReactionTagBackground"), color: .white)?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 15) @@ -832,6 +833,12 @@ public final class ReactionButtonAsyncNode: ContextControllerSourceView { private var ignoreButtonTap: Bool = false + private var tapAnimationLink: SharedDisplayLinkDriver.Link? + private var tapAnimationValue: CGFloat = 0.0 + private var previousTapAnimationTimestamp: Double = 0.0 + private var previousTapTimestamp: Double = 0.0 + private var tapCounterView: StarsReactionCounterView? + public var activateAfterCompletion: Bool = false { didSet { if self.activateAfterCompletion { @@ -931,13 +938,101 @@ public final class ReactionButtonAsyncNode: ContextControllerSourceView { return } layout.spec.component.action(self, layout.spec.component.reaction.value, self.containerView) + + if case .stars = layout.spec.component.reaction.value { + self.addStarsTap() + } + } + + private func addStarsTap() { + let timestamp = CACurrentMediaTime() + + self.previousTapTimestamp = timestamp + + let deltaTime = timestamp - self.previousTapAnimationTimestamp + if deltaTime < 0.4 || self.tapCounterView != nil { + self.previousTapAnimationTimestamp = timestamp + + if let superview = self.superview { + for subview in superview.subviews { + if subview !== self { + subview.layer.zPosition = 0.0 + } + } + } + self.layer.zPosition = 1.0 + + if let tapCounterView = self.tapCounterView { + tapCounterView.add() + } else { + let tapCounterView = StarsReactionCounterView(count: 2) + self.tapCounterView = tapCounterView + self.addSubview(tapCounterView) + tapCounterView.animateIn() + if let layout = self.layout { + tapCounterView.frame = CGRect(origin: CGPoint(x: layout.size.width * 0.5, y: -70.0), size: CGSize()) + } + } + } + self.tapAnimationValue = min(1.0, self.tapAnimationValue) + + if self.tapAnimationLink == nil { + self.previousTapAnimationTimestamp = timestamp + self.updateTapAnimation() + + self.tapAnimationLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] _ in + guard let self else { + return + } + self.updateTapAnimation() + }) + } + } + + private func updateTapAnimation() { + let timestamp = CACurrentMediaTime() + let deltaTime = min(timestamp - self.previousTapAnimationTimestamp, 1.0 / 60.0) + self.previousTapAnimationTimestamp = timestamp + + let decelerationRate: CGFloat = 0.98 + let lastTapDeltaTime = max(0.0, timestamp - self.previousTapTimestamp) + let tapAnimationTargetValue: CGFloat + if self.tapCounterView != nil { + tapAnimationTargetValue = 1.0 * CGFloat(pow(Double(decelerationRate), 1200.0 * lastTapDeltaTime)) + } else { + tapAnimationTargetValue = 0.0 + } + + let advancementFraction = deltaTime * UIView.animationDurationFactor() * 120.0 / 60.0 + self.tapAnimationValue = self.tapAnimationValue * (1.0 - advancementFraction) + tapAnimationTargetValue * advancementFraction + + if self.tapAnimationValue <= 0.001 && self.previousTapTimestamp + 2.0 < timestamp { + self.tapAnimationValue = 0.0 + self.tapAnimationLink?.invalidate() + self.tapAnimationLink = nil + + if let tapCounterView = self.tapCounterView { + self.tapCounterView = nil + tapCounterView.alpha = 0.0 + tapCounterView.animateOut(completion: { [weak tapCounterView] in + tapCounterView?.removeFromSuperview() + }) + } + } + + let tapAnimationFactor = max(0.0, min(1.0, self.tapAnimationValue / 0.3)) + + let scaleValue: CGFloat = 1.0 + tapAnimationFactor * 0.5 + self.buttonNode.layer.transform = CATransform3DMakeScale(scaleValue, scaleValue, 1.0) } fileprivate func apply(layout: Layout, animation: ListViewItemUpdateAnimation, arguments: ReactionButtonsAsyncLayoutContainer.Arguments) { self.containerView.frame = CGRect(origin: CGPoint(), size: layout.size) self.containerView.contentView.frame = CGRect(origin: CGPoint(), size: layout.size) self.containerView.contentRect = CGRect(origin: CGPoint(), size: layout.size) - animation.animator.updateFrame(layer: self.buttonNode.layer, frame: CGRect(origin: CGPoint(), size: layout.size), completion: nil) + let buttonFrame = CGRect(origin: CGPoint(), size: layout.size) + animation.animator.updatePosition(layer: self.buttonNode.layer, position: buttonFrame.center, completion: nil) + animation.animator.updateBounds(layer: self.buttonNode.layer, bounds: CGRect(origin: CGPoint(), size: buttonFrame.size), completion: nil) if case .stars = layout.spec.component.reaction.value { let starsEffectLayer: StarsButtonEffectLayer @@ -1423,3 +1518,83 @@ public final class ReactionButtonsAsyncLayoutContainer { ) } } + +private final class StarsReactionCounterView: UIView { + private let portalSource: PortalSourceView + private let label = ComponentView() + + private var count: Int + + init(count: Int) { + self.count = count + + let portalSource = PortalSourceView() + portalSource.needsGlobalPortal = true + self.portalSource = portalSource + + super.init(frame: CGRect()) + + self.addSubview(portalSource) + + portalSource.frame = CGRect(origin: CGPoint(x: -200.0, y: -200.0), size: CGSize(width: 400.0, height: 400.0)) + + self.update(transition: .immediate) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func animateIn() { + if let labelView = self.label.view { + labelView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.15) + labelView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + + func animateOut(completion: @escaping () -> Void) { + if let labelView = self.label.view { + labelView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.15, removeOnCompletion: false) + labelView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { _ in + completion() + }) + } else { + completion() + } + } + + func add() { + self.count += 1 + self.update(transition: .easeInOut(duration: 0.15)) + } + + func update(transition: ComponentTransition) { + var items: [AnimatedTextComponent.Item] = [] + items.append(AnimatedTextComponent.Item(id: AnyHashable(0), content: .text("+"))) + items.append(AnimatedTextComponent.Item(id: AnyHashable(1), content: .number(self.count, minDigits: 1))) + + let labelSize = self.label.update( + transition: transition, + component: AnyComponent(AnimatedTextComponent( + font: Font.with(size: 40.0, design: .round, weight: .bold), + color: .white, + items: items + )), + environment: {}, + containerSize: CGSize(width: 200.0, height: 200.0) + ) + let labelFrame = CGRect(origin: CGPoint(x: floor((self.portalSource.bounds.width - labelSize.width) * 0.5), y: floor((self.portalSource.bounds.height - labelSize.height) * 0.5)), size: labelSize) + + if let labelView = self.label.view { + if labelView.superview == nil { + self.portalSource.addSubview(labelView) + labelView.layer.shadowColor = UIColor.black.cgColor + labelView.layer.shadowOffset = CGSize(width: 0.0, height: 1.0) + labelView.layer.shadowOpacity = 0.45 + labelView.layer.shadowRadius = 9.0 + } + + transition.setFrame(view: labelView, frame: labelFrame) + } + } +} diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index 1b7765b126..c8ae04d025 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -3329,6 +3329,7 @@ public final class StandaloneReactionAnimation: ASDisplayNode { } let switchToInlineImmediately: Bool + var playAnimationInline = false if let itemNode { if itemNode.item.listAnimation.isVideoEmoji || itemNode.item.listAnimation.isVideoSticker || itemNode.item.listAnimation.isAnimatedSticker || itemNode.item.listAnimation.isStaticEmoji { switch itemNode.item.reaction.rawValue { @@ -3337,7 +3338,8 @@ public final class StandaloneReactionAnimation: ASDisplayNode { case .custom: switchToInlineImmediately = true case .stars: - switchToInlineImmediately = false + switchToInlineImmediately = true + playAnimationInline = true } } else { switchToInlineImmediately = false @@ -3345,6 +3347,7 @@ public final class StandaloneReactionAnimation: ASDisplayNode { } else { switchToInlineImmediately = false } + let _ = playAnimationInline if let itemNode, !forceSmallEffectAnimation, !switchToInlineImmediately, !hideCenterAnimation { if let targetView = targetView as? ReactionIconView, !isLarge { @@ -3382,6 +3385,8 @@ public final class StandaloneReactionAnimation: ASDisplayNode { var expandedSize: CGSize = selfTargetRect.size if isLarge { expandedSize = CGSize(width: 120.0, height: 120.0) + } else if case .stars = reaction.reaction.rawValue { + expandedSize = CGSize(width: 120.0, height: 120.0) } let expandedFrame = CGRect(origin: CGPoint(x: selfTargetRect.midX - expandedSize.width / 2.0, y: selfTargetRect.midY - expandedSize.height / 2.0), size: expandedSize) @@ -3390,6 +3395,8 @@ public final class StandaloneReactionAnimation: ASDisplayNode { let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0 if isLarge && !forceSmallEffectAnimation { effectFrame = 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) + } else if case .stars = reaction.reaction.rawValue { + effectFrame = expandedFrame.insetBy(dx: -expandedFrame.width * 0.5, dy: -expandedFrame.height * 0.5) } else { effectFrame = expandedFrame.insetBy(dx: -expandedSize.width, dy: -expandedSize.height) } @@ -3419,6 +3426,8 @@ public final class StandaloneReactionAnimation: ASDisplayNode { var additionalAnimationResource: MediaResource? if isLarge && !forceSmallEffectAnimation { additionalAnimationResource = reaction.largeApplicationAnimation?.resource + } else if case .stars = reaction.reaction.rawValue { + additionalAnimationResource = reaction.largeApplicationAnimation?.resource ?? reaction.applicationAnimation?.resource } else { additionalAnimationResource = reaction.applicationAnimation?.resource } @@ -3949,7 +3958,8 @@ public final class StandaloneReactionAnimation: ASDisplayNode { let starSourceScale = sourceFrame.width / starSize.width let starDestinationScale = selfTargetRect.width / starSize.width - let keyframes = generateParabollicMotionKeyframes(from: selfSourceRect.center, to: expandedFrame.center, elevation: 40.0) + let elevation: CGFloat = min(selfSourceRect.center.y, expandedFrame.center.y) - selfSourceRect.center.y - 40.0 + let keyframes = generateParabollicMotionKeyframes(from: selfSourceRect.center, to: expandedFrame.center, elevation: -elevation) let scaleKeyframes = generateScaleKeyframes(from: starSourceScale, center: 1.0, to: starDestinationScale) starView.layer.transform = CATransform3DMakeScale(starDestinationScale, starDestinationScale, 1.0) transition.animateScaleWithKeyframes(layer: starView.layer, keyframes: scaleKeyframes) diff --git a/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift index 31f8bad84b..2da4a49198 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionSelectionNode.swift @@ -56,11 +56,13 @@ protocol ReactionItemNode: ASDisplayNode { private let lockedBackgroundImage: UIImage = generateFilledCircleImage(diameter: 16.0, color: .white)!.withRenderingMode(.alwaysTemplate) private let lockedBadgeIcon: UIImage? = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/PanelBadgeLock"), color: .white) -private final class StarsReactionEffectLayer: SimpleLayer { +private final class StarsButtonEffectLayer: SimpleLayer { + let emitterLayer = CAEmitterLayer() + override init() { super.init() - //self.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2).cgColor + self.addSublayer(self.emitterLayer) } override init(layer: Any) { @@ -71,7 +73,45 @@ private final class StarsReactionEffectLayer: SimpleLayer { fatalError("init(coder:) has not been implemented") } + private func setup() { + let color = UIColor(rgb: 0xffbe27) + + let emitter = CAEmitterCell() + emitter.name = "emitter" + emitter.contents = UIImage(bundleImageName: "Premium/Stars/Particle")?.cgImage + emitter.birthRate = 25.0 + emitter.lifetime = 2.0 + emitter.velocity = 12.0 + emitter.velocityRange = 3 + emitter.scale = 0.1 + emitter.scaleRange = 0.08 + emitter.alphaRange = 0.1 + emitter.emissionRange = .pi * 2.0 + emitter.setValue(3.0, forKey: "mass") + emitter.setValue(2.0, forKey: "massRange") + + let staticColors: [Any] = [ + color.withAlphaComponent(0.0).cgColor, + color.cgColor, + color.cgColor, + color.withAlphaComponent(0.0).cgColor + ] + let staticColorBehavior = CAEmitterCell.createEmitterBehavior(type: "colorOverLife") + staticColorBehavior.setValue(staticColors, forKey: "colors") + emitter.setValue([staticColorBehavior], forKey: "emitterBehaviors") + + self.emitterLayer.emitterCells = [emitter] + } + func update(size: CGSize) { + if self.emitterLayer.emitterCells == nil { + self.setup() + } + self.emitterLayer.emitterShape = .circle + self.emitterLayer.emitterSize = CGSize(width: size.width * 0.7, height: size.height * 0.7) + self.emitterLayer.emitterMode = .surface + self.emitterLayer.frame = CGRect(origin: .zero, size: size) + self.emitterLayer.emitterPosition = CGPoint(x: size.width / 2.0, y: size.height / 2.0) } } @@ -88,7 +128,7 @@ public final class ReactionNode: ASDisplayNode, ReactionItemNode { let selectionTintView: UIView? let selectionView: UIView? - private var starsEffectLayer: StarsReactionEffectLayer? + private var starsEffectLayer: StarsButtonEffectLayer? private var animateInAnimationNode: AnimatedStickerNode? private var staticAnimationPlaceholderView: UIImageView? @@ -151,7 +191,7 @@ public final class ReactionNode: ASDisplayNode, ReactionItemNode { super.init() if case .stars = item.reaction.rawValue { - let starsEffectLayer = StarsReactionEffectLayer() + let starsEffectLayer = StarsButtonEffectLayer() self.starsEffectLayer = starsEffectLayer self.layer.addSublayer(starsEffectLayer) } diff --git a/submodules/TelegramCore/Sources/State/AvailableReactions.swift b/submodules/TelegramCore/Sources/State/AvailableReactions.swift index 1fe767cb95..d0a4f149a0 100644 --- a/submodules/TelegramCore/Sources/State/AvailableReactions.swift +++ b/submodules/TelegramCore/Sources/State/AvailableReactions.swift @@ -4,7 +4,7 @@ import Postbox import SwiftSignalKit private func generateStarsReactionFile(kind: Int, isAnimatedSticker: Bool) -> TelegramMediaFile { - let baseId: Int64 = 52343278047832950 + let baseId: Int64 = 52343278047832950 + 10 let fileId = baseId + Int64(kind) var attributes: [TelegramMediaFileAttribute] = [] diff --git a/submodules/TelegramCore/Sources/State/MessageReactions.swift b/submodules/TelegramCore/Sources/State/MessageReactions.swift index f1703195a6..2ca1488b74 100644 --- a/submodules/TelegramCore/Sources/State/MessageReactions.swift +++ b/submodules/TelegramCore/Sources/State/MessageReactions.swift @@ -198,6 +198,28 @@ public func sendStarsReactionsInteractively(account: Account, messageId: Message |> ignoreValues } +func cancelPendingSendStarsReactionInteractively(account: Account, messageId: MessageId) -> Signal { + return account.postbox.transaction { transaction -> Void in + transaction.setPendingMessageAction(type: .sendStarsReaction, id: messageId, action: nil) + transaction.updateMessage(messageId, update: { currentMessage in + var storeForwardInfo: StoreMessageForwardInfo? + if let forwardInfo = currentMessage.forwardInfo { + storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) + } + var attributes = currentMessage.attributes + loop: for j in 0 ..< attributes.count { + if let _ = attributes[j] as? PendingStarsReactionsMessageAttribute { + attributes.remove(at: j) + break loop + } + } + + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + } + |> ignoreValues +} + private enum RequestUpdateMessageReactionError { case generic } @@ -356,7 +378,7 @@ private func requestSendStarsReaction(postbox: Postbox, network: Network, stateM } private final class ManagedApplyPendingMessageReactionsActionsHelper { - var operationDisposables: [MessageId: Disposable] = [:] + var operationDisposables: [MessageId: (PendingMessageActionData, Disposable)] = [:] func update(entries: [PendingMessageActionsEntry]) -> (disposeOperations: [Disposable], beginOperations: [(PendingMessageActionsEntry, MetaDisposable)]) { var disposeOperations: [Disposable] = [] @@ -365,23 +387,26 @@ private final class ManagedApplyPendingMessageReactionsActionsHelper { var hasRunningOperationForPeerId = Set() var validIds = Set() for entry in entries { + if let current = self.operationDisposables[entry.id], !current.0.isEqual(to: entry.action) { + self.operationDisposables.removeValue(forKey: entry.id) + disposeOperations.append(current.1) + } + if !hasRunningOperationForPeerId.contains(entry.id.peerId) { hasRunningOperationForPeerId.insert(entry.id.peerId) validIds.insert(entry.id) - if self.operationDisposables[entry.id] == nil { - let disposable = MetaDisposable() - beginOperations.append((entry, disposable)) - self.operationDisposables[entry.id] = disposable - } + let disposable = MetaDisposable() + beginOperations.append((entry, disposable)) + self.operationDisposables[entry.id] = (entry.action, disposable) } } var removeMergedIds: [MessageId] = [] - for (id, disposable) in self.operationDisposables { + for (id, actionAndDisposable) in self.operationDisposables { if !validIds.contains(id) { removeMergedIds.append(id) - disposeOperations.append(disposable) + disposeOperations.append(actionAndDisposable.1) } } @@ -393,7 +418,7 @@ private final class ManagedApplyPendingMessageReactionsActionsHelper { } func reset() -> [Disposable] { - let disposables = Array(self.operationDisposables.values) + let disposables = Array(self.operationDisposables.values.map(\.1)) self.operationDisposables.removeAll() return disposables } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift index b35b4569ae..d50eb68079 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift @@ -109,7 +109,13 @@ func _internal_peerSendAsAvailablePeers(accountPeerId: PeerId, network: Network, return .single([]) } - if let channel = peer as? TelegramChannel, case .group = channel.info { + if let channel = peer as? TelegramChannel { + if case .group = channel.info { + } else if case let .broadcast(info) = channel.info { + if !info.flags.contains(.messagesShouldHaveProfiles) { + return .single([]) + } + } } else { return .single([]) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index d70f325895..e4bf60422d 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -337,6 +337,10 @@ public extension TelegramEngine { public func sendStarsReaction(id: EngineMessage.Id, count: Int) { let _ = sendStarsReactionsInteractively(account: self.account, messageId: id, count: count).startStandalone() } + + public func cancelPendingSendStarsReaction(id: EngineMessage.Id) { + let _ = cancelPendingSendStarsReactionInteractively(account: self.account, messageId: id).startStandalone() + } public func requestChatContextResults(botId: PeerId, peerId: PeerId, query: String, location: Signal<(Double, Double)?, NoError> = .single(nil), offset: String, incompleteResults: Bool = false, staleCachedResults: Bool = false) -> Signal { return _internal_requestChatContextResults(account: self.account, botId: botId, peerId: peerId, query: query, location: location, offset: offset, incompleteResults: incompleteResults, staleCachedResults: staleCachedResults) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift index d8768a0297..5be3cc803b 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/BotPaymentForm.swift @@ -541,7 +541,7 @@ public enum SendBotPaymentFormError { } public enum SendBotPaymentResult { - case done(receiptMessageId: MessageId?) + case done(receiptMessageId: MessageId?, subscriptionPeerId: PeerId?) case externalVerificationRequired(url: String) } @@ -585,6 +585,17 @@ func _internal_sendBotPaymentForm(account: Account, formId: Int64, source: BotPa case let .paymentResult(updates): account.stateManager.addUpdates(updates) var receiptMessageId: MessageId? + + switch source { + case .starsChatSubscription: + let chats = updates.chats.compactMap { parseTelegramGroupOrChannel(chat: $0) } + if let first = chats.first { + return .done(receiptMessageId: nil, subscriptionPeerId: first.id) + } + default: + break + } + for apiMessage in updates.messages { if let message = StoreMessage(apiMessage: apiMessage, accountPeerId: account.peerId, peerIsForum: false) { for media in message.media { @@ -623,7 +634,7 @@ func _internal_sendBotPaymentForm(account: Account, formId: Int64, source: BotPa } } } - return .done(receiptMessageId: receiptMessageId) + return .done(receiptMessageId: receiptMessageId, subscriptionPeerId: nil) case let .paymentVerificationNeeded(url): return .externalVerificationRequired(url: url) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift index 5df0687e91..02f2d6b739 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Payments/Stars.swift @@ -1086,9 +1086,22 @@ func _internal_sendStarsPaymentForm(account: Account, formId: Int64, source: Bot return account.network.request(Api.functions.payments.sendStarsForm(flags: flags, formId: formId, invoice: invoice)) |> map { result -> SendBotPaymentResult in + + + switch result { case let .paymentResult(updates): account.stateManager.addUpdates(updates) + + switch source { + case .starsChatSubscription: + let chats = updates.chats.compactMap { parseTelegramGroupOrChannel(chat: $0) } + if let first = chats.first { + return .done(receiptMessageId: nil, subscriptionPeerId: first.id) + } + default: + break + } var receiptMessageId: MessageId? for apiMessage in updates.messages { if let message = StoreMessage(apiMessage: apiMessage, accountPeerId: account.peerId, peerIsForum: false) { @@ -1130,7 +1143,7 @@ func _internal_sendStarsPaymentForm(account: Account, formId: Int64, source: Bot } } } - return .done(receiptMessageId: receiptMessageId) + return .done(receiptMessageId: receiptMessageId, subscriptionPeerId: nil) case let .paymentVerificationNeeded(url): return .externalVerificationRequired(url: url) } diff --git a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift index 9cbd7eaa73..58903e0d0e 100644 --- a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift @@ -344,6 +344,11 @@ public extension Message { return false } } else if self.author?.id == accountPeerId { + if let channel = self.peers[self.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info { + if !info.flags.contains(.messagesShouldHaveProfiles) { + return true + } + } return false } else if self.flags.contains(.Incoming) { return true diff --git a/submodules/TelegramUI/Components/AnimatedTextComponent/Sources/AnimatedTextComponent.swift b/submodules/TelegramUI/Components/AnimatedTextComponent/Sources/AnimatedTextComponent.swift index 5c3a6f0546..bb26607690 100644 --- a/submodules/TelegramUI/Components/AnimatedTextComponent/Sources/AnimatedTextComponent.swift +++ b/submodules/TelegramUI/Components/AnimatedTextComponent/Sources/AnimatedTextComponent.swift @@ -76,6 +76,8 @@ public final class AnimatedTextComponent: Component { let delayNorm: CGFloat = 0.002 + var firstDelayWidth: CGFloat? + var validKeys: [CharacterKey] = [] for item in component.items { var itemText: [String] = [] @@ -138,20 +140,32 @@ public final class AnimatedTextComponent: Component { if characterTransition.animation.isImmediate { characterComponentView.frame = characterFrame } else { + var delayWidth: Double = 0.0 + if let firstDelayWidth { + delayWidth = size.width - firstDelayWidth + } else { + firstDelayWidth = size.width + } + characterComponentView.bounds = CGRect(origin: CGPoint(), size: characterFrame.size) let deltaPosition = CGPoint(x: characterFrame.midX - characterComponentView.frame.midX, y: characterFrame.midY - characterComponentView.frame.midY) characterComponentView.center = characterFrame.center - characterComponentView.layer.animatePosition(from: CGPoint(x: -deltaPosition.x, y: -deltaPosition.y), to: CGPoint(), duration: 0.4, delay: delayNorm * size.width, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + characterComponentView.layer.animatePosition(from: CGPoint(x: -deltaPosition.x, y: -deltaPosition.y), to: CGPoint(), duration: 0.4, delay: delayNorm * delayWidth, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } } characterTransition.setFrame(view: characterComponentView, frame: characterFrame) - if animateIn, !transition.animation.isImmediate { - characterComponentView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.4, delay: delayNorm * size.width, timingFunction: kCAMediaTimingFunctionSpring) - //characterComponentView.layer.animateSpring(from: (characterSize.height * 0.5) as NSNumber, to: 0.0 as NSNumber, keyPath: "position.y", duration: 0.5, additive: true) - characterComponentView.layer.animatePosition(from: CGPoint(x: 0.0, y: characterSize.height * 0.5), to: CGPoint(), duration: 0.4, delay: delayNorm * size.width, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - characterComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18, delay: delayNorm * size.width) + var delayWidth: Double = 0.0 + if let firstDelayWidth { + delayWidth = size.width - firstDelayWidth + } else { + firstDelayWidth = size.width + } + + characterComponentView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.4, delay: delayNorm * delayWidth, timingFunction: kCAMediaTimingFunctionSpring) + characterComponentView.layer.animatePosition(from: CGPoint(x: 0.0, y: characterSize.height * 0.5), to: CGPoint(), duration: 0.4, delay: delayNorm * delayWidth, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + characterComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18, delay: delayNorm * delayWidth) } } @@ -160,6 +174,11 @@ public final class AnimatedTextComponent: Component { } } + let outScaleTransition: ComponentTransition = .spring(duration: 0.4) + let outAlphaTransition: ComponentTransition = .easeInOut(duration: 0.18) + + var outFirstDelayWidth: CGFloat? + var removedKeys: [CharacterKey] = [] for (key, characterView) in self.characters { if !validKeys.contains(key) { @@ -167,9 +186,16 @@ public final class AnimatedTextComponent: Component { if let characterComponentView = characterView.view { if !transition.animation.isImmediate { - characterComponentView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.4, delay: delayNorm * characterComponentView.frame.minX, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) - characterComponentView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -characterComponentView.bounds.height * 0.4), duration: 0.4, delay: delayNorm * characterComponentView.frame.minX, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) - characterComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, delay: delayNorm * characterComponentView.frame.minX, removeOnCompletion: false, completion: { [weak characterComponentView] _ in + var delayWidth: Double = 0.0 + if let outFirstDelayWidth { + delayWidth = characterComponentView.frame.minX - outFirstDelayWidth + } else { + outFirstDelayWidth = characterComponentView.frame.minX + } + + outScaleTransition.setScale(view: characterComponentView, scale: 0.01, delay: delayNorm * delayWidth) + outScaleTransition.setPosition(view: characterComponentView, position: CGPoint(x: characterComponentView.center.x, y: characterComponentView.center.y - characterComponentView.bounds.height * 0.4), delay: delayNorm * delayWidth) + outAlphaTransition.setAlpha(view: characterComponentView, alpha: 0.0, delay: delayNorm * delayWidth, completion: { [weak characterComponentView] _ in characterComponentView?.removeFromSuperview() }) } else { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 5ed325f2a3..2f6e0d90d3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -1480,7 +1480,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } var effectiveAuthor: Peer? - let overrideEffectiveAuthor = false + var overrideEffectiveAuthor = false var ignoreForward = false var displayAuthorInfo: Bool var ignoreNameHiding = false @@ -1551,13 +1551,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } //TODO:release - /*if let channel = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, info.flags.contains(.messagesShouldHaveProfiles) { - hasAvatar = true - if let authorSignatureAttribute = firstMessage.authorSignatureAttribute { - effectiveAuthor = TelegramUser(id: PeerId(namespace: Namespaces.Peer.Empty, id: PeerId.Id._internalFromInt64Value(Int64(authorSignatureAttribute.signature.persistentHashValue % 32))), accessHash: nil, firstName: authorSignatureAttribute.signature, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil) - overrideEffectiveAuthor = true - + if let channel = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, firstMessage.author?.id != channel.id { + if info.flags.contains(.messagesShouldHaveProfiles) { var allowAuthor = incoming + overrideEffectiveAuthor = true if let author = firstMessage.author, author is TelegramChannel, !incoming || item.presentationData.isPreview { allowAuthor = true @@ -1573,7 +1570,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI displayAuthorInfo = false } } - }*/ + } if !peerId.isRepliesOrSavedMessages(accountPeerId: item.context.account.peerId) { if peerId.isGroupOrChannel && effectiveAuthor != nil { @@ -1591,6 +1588,8 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI hasAvatar = incoming } else if case .customChatContents = item.chatLocation { hasAvatar = false + } else if overrideEffectiveAuthor { + hasAvatar = true } } } else if incoming { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift index ef3ac7d6ff..107fa3575c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode/Sources/StringForMessageTimestampStatus.swift @@ -126,16 +126,24 @@ public func stringForMessageTimestampStatus(accountPeerId: PeerId, message: Mess var authorTitle: String? if let author = message.author as? TelegramUser { if let peer = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = peer.info { - authorTitle = EnginePeer(author).displayTitle(strings: strings, displayOrder: nameDisplayOrder) + if let channel = message.peers[message.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, message.author?.id != channel.id, info.flags.contains(.messagesShouldHaveProfiles) { + //TODO:release + } else { + authorTitle = EnginePeer(author).displayTitle(strings: strings, displayOrder: nameDisplayOrder) + } } else if let forwardInfo = message.forwardInfo, forwardInfo.sourceMessageId?.peerId.namespace == Namespaces.Peer.CloudChannel { authorTitle = forwardInfo.authorSignature } } else { if let peer = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = peer.info { - for attribute in message.attributes { - if let attribute = attribute as? AuthorSignatureMessageAttribute { - authorTitle = attribute.signature - break + if let channel = message.peers[message.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, message.author?.id != channel.id, info.flags.contains(.messagesShouldHaveProfiles) { + //TODO:release + } else { + for attribute in message.attributes { + if let attribute = attribute as? AuthorSignatureMessageAttribute { + authorTitle = attribute.signature + break + } } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift index 507bb11f17..9a4c368005 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift @@ -344,13 +344,13 @@ public final class ChatMessageItemImpl: ChatMessageItem, CustomStringConvertible if !hasActionMedia { if !isBroadcastChannel { hasAvatar = true - }/* else if let channel = message.peers[message.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, info.flags.contains(.messagesShouldHaveProfiles) { + } else if let channel = message.peers[message.id.peerId] as? TelegramChannel, case let .broadcast(info) = channel.info, message.author?.id != channel.id { //TODO:release - hasAvatar = true - if let authorSignatureAttribute = message.authorSignatureAttribute { - effectiveAuthor = TelegramUser(id: PeerId(namespace: Namespaces.Peer.Empty, id: PeerId.Id._internalFromInt64Value(Int64(authorSignatureAttribute.signature.persistentHashValue % 32))), accessHash: nil, firstName: authorSignatureAttribute.signature, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil) + if info.flags.contains(.messagesShouldHaveProfiles) { + hasAvatar = true + effectiveAuthor = message.author } - }*/ + } } if hasAvatar { diff --git a/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift b/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift index 37000034f2..fd122e068e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift @@ -56,7 +56,7 @@ private final class BalanceComponent: CombinedComponent { static var body: Body { let title = Child(MultilineTextComponent.self) let balance = Child(MultilineTextComponent.self) - let icon = Child(EmojiStatusComponent.self) + let icon = Child(BundleIconComponent.self) return { context in var size = CGSize(width: 0.0, height: 0.0) @@ -89,19 +89,9 @@ private final class BalanceComponent: CombinedComponent { let iconSize = CGSize(width: 18.0, height: 18.0) let icon = icon.update( - component: EmojiStatusComponent( - context: context.component.context, - animationCache: context.component.context.animationCache, - animationRenderer: context.component.context.animationRenderer, - content: .animation( - content: .customEmoji(fileId: MessageReaction.starsReactionId), //TODO:release - size: iconSize, - placeholderColor: .gray, - themeColor: nil, - loopMode: .count(0) - ), - isVisibleForAnimations: true, - action: nil + component: BundleIconComponent( + name: "Premium/Stars/StarLarge", + tintColor: nil ), availableSize: iconSize, transition: context.transition @@ -127,7 +117,7 @@ private final class BalanceComponent: CombinedComponent { ) context.add( icon.position( - icon.size.centered(in: CGRect(origin: CGPoint(x: 0.0, y: title.size.height + titleSpacing), size: icon.size)).center + icon.size.centered(in: CGRect(origin: CGPoint(x: -1.0, y: title.size.height + titleSpacing), size: icon.size)).center ) ) @@ -713,16 +703,10 @@ private final class SliderBackgroundComponent: Component { topForegroundTextView.bounds = CGRect(origin: CGPoint(), size: topTextFrame.size) topBackgroundTextView.bounds = CGRect(origin: CGPoint(), size: topTextFrame.size) - topForegroundTextView.isHidden = component.topCutoff == nil || topTextFrame.maxX >= availableSize.width - 4.0 - topBackgroundTextView.isHidden = component.topCutoff == nil || topTextFrame.maxX >= availableSize.width - 4.0 - } - - if component.topCutoff == nil { - self.topForegroundLine.isHidden = true - self.topBackgroundLine.isHidden = true - } else { - self.topForegroundLine.isHidden = false - self.topBackgroundLine.isHidden = false + topForegroundTextView.isHidden = component.topCutoff == nil || topTextFrame.minX <= 10.0 || topTextFrame.maxX >= availableSize.width - 4.0 + topBackgroundTextView.isHidden = topForegroundTextView.isHidden + self.topBackgroundLine.isHidden = topForegroundTextView.isHidden + self.topForegroundLine.isHidden = topForegroundTextView.isHidden } return availableSize @@ -743,20 +727,26 @@ private final class ChatSendStarsScreenComponent: Component { let context: AccountContext let peer: EnginePeer + let maxAmount: Int let balance: Int64? + let currentSentAmount: Int? let topPeers: [ChatSendStarsScreen.TopPeer] let completion: (Int64, Bool, ChatSendStarsScreen.TransitionOut) -> Void init( context: AccountContext, peer: EnginePeer, + maxAmount: Int, balance: Int64?, + currentSentAmount: Int?, topPeers: [ChatSendStarsScreen.TopPeer], completion: @escaping (Int64, Bool, ChatSendStarsScreen.TransitionOut) -> Void ) { self.context = context self.peer = peer + self.maxAmount = maxAmount self.balance = balance + self.currentSentAmount = currentSentAmount self.topPeers = topPeers self.completion = completion } @@ -768,9 +758,15 @@ private final class ChatSendStarsScreenComponent: Component { if lhs.peer != rhs.peer { return false } + if lhs.maxAmount != rhs.maxAmount { + return false + } if lhs.balance != rhs.balance { return false } + if lhs.currentSentAmount != rhs.currentSentAmount { + return false + } if lhs.topPeers != rhs.topPeers { return false } @@ -1005,7 +1001,7 @@ private final class ChatSendStarsScreenComponent: Component { let sideInset: CGFloat = 16.0 if self.component == nil { - self.amount = 1 + self.amount = 50 } self.component = component @@ -1034,21 +1030,21 @@ private final class ChatSendStarsScreenComponent: Component { let sliderSize = self.slider.update( transition: transition, component: AnyComponent(SliderComponent( - valueCount: 1000, - value: 0, + valueCount: component.maxAmount, + value: Int(self.amount), markPositions: false, trackBackgroundColor: .clear, trackForegroundColor: .clear, knobSize: 26.0, knobColor: .white, valueUpdated: { [weak self] value in - guard let self else { + guard let self, let component = self.component else { return } self.amount = 1 + Int64(value) self.state?.updated(transition: .immediate) - let sliderValue = Float(value) / 1000.0 + let sliderValue = Float(value) / Float(component.maxAmount) let currentTimestamp = CACurrentMediaTime() if let previousTimestamp { @@ -1102,13 +1098,13 @@ private final class ChatSendStarsScreenComponent: Component { let sliderFrame = CGRect(origin: CGPoint(x: sliderInset, y: contentHeight + 127.0), size: sliderSize) let sliderBackgroundFrame = CGRect(origin: CGPoint(x: sliderFrame.minX - 8.0, y: sliderFrame.minY + 7.0), size: CGSize(width: sliderFrame.width + 16.0, height: sliderFrame.height - 14.0)) - let progressFraction: CGFloat = CGFloat(self.amount) / CGFloat(1000 - 1) + let progressFraction: CGFloat = CGFloat(self.amount) / CGFloat(component.maxAmount - 1) let topCount = component.topPeers.max(by: { $0.count < $1.count })?.count var topCutoffFraction: CGFloat? if let topCount { - let topCutoffFractionValue = CGFloat(topCount) / CGFloat(1000 - 1) + let topCutoffFractionValue = CGFloat(topCount) / CGFloat(component.maxAmount - 1) topCutoffFraction = topCutoffFractionValue let isPastCutoff = progressFraction >= topCutoffFractionValue @@ -1271,8 +1267,13 @@ private final class ChatSendStarsScreenComponent: Component { contentHeight += 56.0 contentHeight += 8.0 - - let text = "Choose how many stars you want to send to **\(component.peer.debugDisplayTitle)** to support this post." + + let text: String + if let currentSentAmount = component.currentSentAmount { + text = "You sent **\(currentSentAmount)** stars to support this post." + } else { + text = "Choose how many stars you want to send to **\(component.peer.debugDisplayTitle)** to support this post." + } let body = MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor) let bold = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.itemPrimaryTextColor) @@ -1469,6 +1470,32 @@ private final class ChatSendStarsScreenComponent: Component { guard let self, let component = self.component else { return } + guard let balance = component.balance else { + return + } + + if balance < self.amount { + let _ = (component.context.engine.payments.starsTopUpOptions() + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self] options in + guard let self, let component = self.component else { + return + } + guard let starsContext = component.context.starsContext else { + return + } + + let purchaseScreen = component.context.sharedContext.makeStarsPurchaseScreen(context: component.context, starsContext: starsContext, options: options, purpose: .transfer(peerId: component.peer.id, requiredStars: self.amount), completion: { result in + let _ = result + //TODO:release + }) + self.environment?.controller()?.push(purchaseScreen) + self.environment?.controller()?.dismiss() + }) + + return + } + guard let badgeView = self.badge.view as? BadgeComponent.View else { return } @@ -1478,6 +1505,7 @@ private final class ChatSendStarsScreenComponent: Component { } else { isBecomingTop = true } + component.completion( self.amount, isBecomingTop, @@ -1579,15 +1607,18 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { public final class InitialData { fileprivate let peer: EnginePeer fileprivate let balance: Int64? + fileprivate let currentSentAmount: Int? fileprivate let topPeers: [ChatSendStarsScreen.TopPeer] fileprivate init( peer: EnginePeer, balance: Int64?, + currentSentAmount: Int?, topPeers: [ChatSendStarsScreen.TopPeer] ) { self.peer = peer self.balance = balance + self.currentSentAmount = currentSentAmount self.topPeers = topPeers } } @@ -1629,10 +1660,17 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { public init(context: AccountContext, initialData: InitialData, completion: @escaping (Int64, Bool, TransitionOut) -> Void) { self.context = context + var maxAmount = 2500 + if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["stars_paid_reaction_amount_max"] as? Double { + maxAmount = Int(value) + } + super.init(context: context, component: ChatSendStarsScreenComponent( context: context, peer: initialData.peer, + maxAmount: maxAmount, balance: initialData.balance, + currentSentAmount: initialData.currentSentAmount, topPeers: initialData.topPeers, completion: completion ), navigationBarAppearance: .none) @@ -1672,6 +1710,16 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { balance = .single(nil) } + var currentSentAmount: Int? + if let myPeer = topPeers.first(where: { $0.isMy }) { + currentSentAmount = Int(myPeer.count) + } + + var topPeers = topPeers.sorted(by: { $0.count < $1.count }) + if topPeers.count > 3 { + topPeers = Array(topPeers.prefix(3)) + } + return combineLatest( context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), @@ -1688,6 +1736,7 @@ public class ChatSendStarsScreen: ViewControllerComponentContainer { return InitialData( peer: peer, balance: balance, + currentSentAmount: currentSentAmount, topPeers: topPeers.compactMap { topPeer -> ChatSendStarsScreen.TopPeer? in guard let topPeerValue = topPeerMap[topPeer.peerId] else { return nil diff --git a/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift b/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift index 48f18d5f66..e36ad4b1fe 100644 --- a/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift +++ b/submodules/TelegramUI/Components/SpaceWarpView/Sources/SpaceWarpView.swift @@ -124,7 +124,7 @@ private func rippleOffset( } if distance <= 60.0 { - rippleAmount = 0.4 * rippleAmount + rippleAmount = 0.3 * rippleAmount } // A vector of length `amplitude` that points away from position. diff --git a/submodules/TelegramUI/Resources/Animations/star_up/star_reaction_effect.tgs b/submodules/TelegramUI/Resources/Animations/star_up/star_reaction_effect.tgs index f31897de51..0b87a225a4 100644 Binary files a/submodules/TelegramUI/Resources/Animations/star_up/star_reaction_effect.tgs and b/submodules/TelegramUI/Resources/Animations/star_up/star_reaction_effect.tgs differ diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift index 49797c3aac..bbce431bf6 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift @@ -373,7 +373,8 @@ extension ChatControllerImpl { } } - let _ = sendStarsReactionsInteractively(account: self.context.account, messageId: message.id, count: 1).startStandalone() + self.context.engine.messages.sendStarsReaction(id: message.id, count: 1) + self.displayOrUpdateSendStarsUndo(messageId: message.id, count: 1) } else { let chosenReaction: MessageReaction.Reaction = chosenUpdatedReaction.reaction diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index b594c88667..c371ebb090 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -613,6 +613,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var messageComposeController: MFMessageComposeViewController? + weak var currentSendStarsUndoController: UndoOverlayController? + var currentSendStarsUndoMessageId: EngineMessage.Id? + var currentSendStarsUndoCount: Int = 0 + public var alwaysShowSearchResultsAsList: Bool = false { didSet { self.presentationInterfaceState = self.presentationInterfaceState.updatedDisplayHistoryFilterAsList(self.alwaysShowSearchResultsAsList) @@ -1677,24 +1681,40 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) - let _ = sendStarsReactionsInteractively(account: strongSelf.context.account, messageId: message.id, count: 1).startStandalone() - - if !"".isEmpty { - let _ = (strongSelf.context.engine.stickers.resolveInlineStickers(fileIds: [MessageReaction.starsReactionId]) - |> deliverOnMainQueue).start(next: { [weak strongSelf, weak itemNode] files in - guard let strongSelf, let file = files[MessageReaction.starsReactionId] else { - return - } - //TODO:localize - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .starsSent(context: strongSelf.context, file: file, amount: 1, title: "Star Sent", text: "Long tap on {star} to select custom quantity of stars."), elevatedLayout: false, action: { _ in - return false - }), in: .current) - - if let itemNode = itemNode, let targetView = itemNode.targetReactionView(value: chosenReaction) { - strongSelf.chatDisplayNode.wrappingNode.triggerRipple(at: targetView.convert(targetView.bounds.center, to: strongSelf.chatDisplayNode.view)) - } - }) + guard let starsContext = strongSelf.context.starsContext else { + return } + let _ = (starsContext.state + |> take(1) + |> deliverOnMainQueue).start(next: { [weak strongSelf] state in + guard let strongSelf, let balance = state?.balance else { + return + } + + if balance < 1 { + let _ = (strongSelf.context.engine.payments.starsTopUpOptions() + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] options in + guard let strongSelf, let peerId = strongSelf.chatLocation.peerId else { + return + } + guard let starsContext = strongSelf.context.starsContext else { + return + } + + let purchaseScreen = strongSelf.context.sharedContext.makeStarsPurchaseScreen(context: strongSelf.context, starsContext: starsContext, options: options, purpose: .transfer(peerId: peerId, requiredStars: 1), completion: { result in + let _ = result + //TODO:release + }) + strongSelf.push(purchaseScreen) + }) + + return + } + + strongSelf.context.engine.messages.sendStarsReaction(id: message.id, count: 1) + strongSelf.displayOrUpdateSendStarsUndo(messageId: message.id, count: 1) + }) } else { var removedReaction: MessageReaction.Reaction? var messageAlreadyHasThisReaction = false diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift index 090fa6bdfc..1ad580542a 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift @@ -19,6 +19,7 @@ import ChatSendStarsScreen import ChatMessageItemCommon import ChatMessageItemView import ReactionSelectionNode +import AnimatedTextComponent extension ChatControllerImpl { func presentTagPremiumPaywall() { @@ -171,6 +172,7 @@ extension ChatControllerImpl { guard let self, let initialData else { return } + HapticFeedback().tap() self.push(ChatSendStarsScreen(context: self.context, initialData: initialData, completion: { [weak self] amount, isBecomingTop, transitionOut in guard let self, amount > 0 else { return @@ -256,26 +258,11 @@ extension ChatControllerImpl { } } - //let _ = self.context.engine.messages.sendStarsReaction(id: message.id, count: Int(amount)) + #if !DEBUG + let _ = self.context.engine.messages.sendStarsReaction(id: message.id, count: Int(amount)) + #endif - let _ = (self.context.engine.stickers.resolveInlineStickers(fileIds: [MessageReaction.starsReactionId]) - |> deliverOnMainQueue).start(next: { [weak self] files in - guard let self, let file = files[MessageReaction.starsReactionId] else { - return - } - - //TODO:localize - let title: String - if amount == 1 { - title = "Star Sent" - } else { - title = "\(amount) Stars Sent" - } - - self.present(UndoOverlayController(presentationData: self.presentationData, content: .starsSent(context: self.context, file: file, amount: amount, title: title, text: nil), elevatedLayout: false, action: { _ in - return false - }), in: .current) - }) + self.displayOrUpdateSendStarsUndo(messageId: message.id, count: Int(amount)) })) }) @@ -477,4 +464,50 @@ extension ChatControllerImpl { }) } } + + func displayOrUpdateSendStarsUndo(messageId: EngineMessage.Id, count: Int) { + if self.currentSendStarsUndoMessageId != messageId { + if let current = self.currentSendStarsUndoController { + self.currentSendStarsUndoController = nil + current.dismiss() + } + } + + if let _ = self.currentSendStarsUndoController { + self.currentSendStarsUndoCount += count + } else { + self.currentSendStarsUndoCount = count + } + + //TODO:localize + let title: String + if self.currentSendStarsUndoCount == 1 { + title = "Star sent!" + } else { + title = "Stars sent!" + } + + var textItems: [AnimatedTextComponent.Item] = [] + textItems.append(AnimatedTextComponent.Item(id: AnyHashable(0), isUnbreakable: true, content: .text("You have reacted with "))) + textItems.append(AnimatedTextComponent.Item(id: AnyHashable(1), content: .number(self.currentSendStarsUndoCount, minDigits: 1))) + textItems.append(AnimatedTextComponent.Item(id: AnyHashable(2), isUnbreakable: true, content: .text(self.currentSendStarsUndoCount == 1 ? " star." : " stars."))) + + self.currentSendStarsUndoMessageId = messageId + //TODO:localize + if let current = self.currentSendStarsUndoController { + current.content = .starsSent(context: self.context, title: title, text: textItems) + } else { + let controller = UndoOverlayController(presentationData: self.presentationData, content: .starsSent(context: self.context, title: title, text: textItems), elevatedLayout: false, position: .top, action: { [weak self] action in + guard let self else { + return false + } + if case .undo = action { + self.context.engine.messages.cancelPendingSendStarsReaction(id: messageId) + } + return false + }) + self.currentSendStarsUndoController = controller + self.present(controller, in: .current) + } + } } diff --git a/submodules/UndoUI/BUILD b/submodules/UndoUI/BUILD index ce70e46883..059920aa56 100644 --- a/submodules/UndoUI/BUILD +++ b/submodules/UndoUI/BUILD @@ -30,6 +30,9 @@ swift_library( "//submodules/AnimatedAvatarSetNode:AnimatedAvatarSetNode", "//submodules/TelegramUI/Components/EmojiStatusComponent", "//submodules/TelegramUI/Components/TextNodeWithEntities", + "//submodules/Components/BundleIconComponent", + "//submodules/TelegramUI/Components/AnimatedTextComponent", + "//submodules/Components/ComponentDisplayAdapters", ], visibility = [ "//visibility:public", diff --git a/submodules/UndoUI/Sources/UndoOverlayController.swift b/submodules/UndoUI/Sources/UndoOverlayController.swift index 1172c49219..3aa34a3405 100644 --- a/submodules/UndoUI/Sources/UndoOverlayController.swift +++ b/submodules/UndoUI/Sources/UndoOverlayController.swift @@ -5,6 +5,7 @@ import TelegramPresentationData import TelegramCore import AccountContext import ComponentFlow +import AnimatedTextComponent public enum UndoOverlayContent { case removedChat(title: String, text: String?) @@ -39,7 +40,7 @@ public enum UndoOverlayContent { case copy(text: String) case mediaSaved(text: String) case paymentSent(currencyValue: String, itemTitle: String) - case starsSent(context: AccountContext, file: TelegramMediaFile, amount: Int64, title: String, text: String?) + case starsSent(context: AccountContext, title: String, text: [AnimatedTextComponent.Item]) case inviteRequestSent(title: String, text: String) case image(image: UIImage, title: String?, text: String, round: Bool, undoText: String?) case notificationSoundAdded(title: String, text: String, action: (() -> Void)?) diff --git a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift index c20ed6ecee..75770ad4aa 100644 --- a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift +++ b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift @@ -20,6 +20,9 @@ import AnimatedAvatarSetNode import ComponentFlow import EmojiStatusComponent import TextNodeWithEntities +import BundleIconComponent +import AnimatedTextComponent +import ComponentDisplayAdapters final class UndoOverlayControllerNode: ViewControllerTracingNode { private let presentationData: PresentationData @@ -42,6 +45,8 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { private var emojiStatus: ComponentView? private let titleNode: ImmediateTextNode private let textNode: ImmediateTextNodeWithEntities + private var textComponent: ComponentView? + private var animatedTextItems: [AnimatedTextComponent.Item]? private let buttonNode: HighlightTrackingButtonNode private let undoButtonTextNode: ImmediateTextNode private let undoButtonNode: HighlightTrackingButtonNode @@ -84,6 +89,7 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { self.timerTextNode.displaysAsynchronously = false self.titleNode = ImmediateTextNode() + self.titleNode.layer.anchorPoint = CGPoint() self.titleNode.displaysAsynchronously = false self.titleNode.maximumNumberOfLines = 0 @@ -380,32 +386,23 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { self.textNode.attributedText = string displayUndo = false self.originalRemainingSeconds = 5 - case let .starsSent(context, file, _, title, text): + case let .starsSent(_, title, textItems): self.avatarNode = nil self.iconNode = nil self.iconCheckNode = nil self.animationNode = nil + self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(14.0), textColor: .white) + let imageBoundingSize = CGSize(width: 34.0, height: 34.0) let emojiStatus = ComponentView() self.emojiStatus = emojiStatus let _ = emojiStatus.update( transition: .immediate, - component: AnyComponent(EmojiStatusComponent( - context: context, - animationCache: context.animationCache, - animationRenderer: context.animationRenderer, - content: .animation( - content: .file(file: file), - size: imageBoundingSize, - placeholderColor: UIColor(white: 1.0, alpha: 0.1), - themeColor: .white, - loopMode: .count(1) - ), - isVisibleForAnimations: true, - useSharedAnimation: false, - action: nil + component: AnyComponent(BundleIconComponent( + name: "Premium/Stars/StarLarge", + tintColor: nil )), environment: {}, containerSize: imageBoundingSize @@ -413,34 +410,10 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { self.stickerImageSize = imageBoundingSize - if let text { - let formattedString = text - - let string = NSMutableAttributedString(attributedString: NSAttributedString(string: formattedString, font: Font.regular(14.0), textColor: .white)) - let starRange = (string.string as NSString).range(of: "{star}") - if starRange.location != NSNotFound { - string.replaceCharacters(in: starRange, with: "") - string.insert(NSAttributedString(string: ".", attributes: [ - .font: Font.regular(14.0), - ChatTextInputAttributes.customEmoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: MessageReaction.starsReactionId, file: file, custom: nil) - ]), at: starRange.location) - } - - self.textNode.attributedText = string - self.textNode.arguments = TextNodeWithEntities.Arguments( - context: context, - cache: context.animationCache, - renderer: context.animationRenderer, - placeholderColor: UIColor(white: 1.0, alpha: 0.1), - attemptSynchronous: false - ) - self.textNode.visibility = true - } + self.animatedTextItems = textItems - self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(14.0), textColor: .white) - - displayUndo = false - self.originalRemainingSeconds = 3 + displayUndo = true + self.originalRemainingSeconds = 4.5 isUserInteractionEnabled = true case let .messagesUnpinned(title, text, undo, isHidden): self.avatarNode = nil @@ -1485,6 +1458,8 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { var undoTextColor = self.presentationData.theme.list.itemAccentColor.withMultiplied(hue: 0.933, saturation: 0.61, brightness: 1.0) + var transition: ContainedViewLayoutTransition = .immediate + switch content { case let .info(title, text, _, _), let .universal(_, _, _, title, text, _, _): let body = MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white) @@ -1516,12 +1491,19 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(14.0), textColor: .white) } self.textNode.attributedText = attributedText + case let .starsSent(_, title, textItems): + self.animatedTextItems = textItems + + self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(14.0), textColor: .white) + + self.renewWithCurrentContent() + transition = .animated(duration: 0.1, curve: .easeInOut) default: break } if let validLayout = self.validLayout { - self.containerLayoutUpdated(layout: validLayout, transition: .immediate) + self.containerLayoutUpdated(layout: validLayout, transition: transition) } } @@ -1579,7 +1561,41 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { } let titleSize = self.titleNode.updateLayout(CGSize(width: buttonMinX - 8.0 - leftInset - layout.safeInsets.left - leftMargin, height: .greatestFiniteMagnitude)) - let textSize = self.textNode.updateLayout(CGSize(width: buttonMinX - 8.0 - leftInset - layout.safeInsets.left - leftMargin, height: .greatestFiniteMagnitude)) + + let maxTextSize = CGSize(width: buttonMinX - 8.0 - leftInset - layout.safeInsets.left - leftMargin, height: .greatestFiniteMagnitude) + + let textSize: CGSize + if let animatedTextItems = self.animatedTextItems { + let textComponent: ComponentView + if let current = self.textComponent { + textComponent = current + } else { + textComponent = ComponentView() + self.textComponent = textComponent + } + textSize = textComponent.update( + transition: ComponentTransition(transition), + component: AnyComponent(AnimatedTextComponent( + font: Font.regular(14.0), + color: .white, + items: animatedTextItems + )), + environment: {}, + containerSize: maxTextSize + ) + if let textComponentView = textComponent.view { + if textComponentView.superview == nil { + textComponentView.layer.anchorPoint = CGPoint() + self.panelWrapperNode.view.addSubview(textComponentView) + } + } + } else { + if let textComponentView = self.textComponent?.view { + self.textComponent = nil + textComponentView.removeFromSuperview() + } + textSize = self.textNode.updateLayout(maxTextSize) + } if !titleSize.width.isZero { contentHeight += titleSize.height + 1.0 @@ -1630,8 +1646,17 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { } let textContentOrigin = floor((contentHeight - textContentHeight) / 2.0) - transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset, y: textContentOrigin), size: titleSize)) - transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: leftInset, y: textContentOrigin + textOffset), size: textSize)) + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: textContentOrigin), size: titleSize) + transition.updatePosition(node: self.titleNode, position: titleFrame.origin) + self.titleNode.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + + let textFrame = CGRect(origin: CGPoint(x: leftInset, y: textContentOrigin + textOffset), size: textSize) + if let textComponentView = self.textComponent?.view { + transition.updatePosition(layer: textComponentView.layer, position: textFrame.origin) + textComponentView.bounds = CGRect(origin: CGPoint(), size: textFrame.size) + } else { + transition.updateFrame(node: self.textNode, frame: textFrame) + } if let iconNode = self.iconNode { let iconSize: CGSize