Swiftgram/submodules/StickerPeekUI/Sources/StickerPreviewPeekContent.swift
2024-04-11 22:56:57 +04:00

587 lines
24 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import StickerResources
import AnimatedStickerNode
import TelegramAnimatedStickerNode
import ContextUI
import SolidRoundedButtonNode
import TelegramPresentationData
import AccountContext
import AppBundle
import ReactionSelectionNode
import EntityKeyboard
public enum StickerPreviewPeekItem: Equatable {
public static func == (lhs: StickerPreviewPeekItem, rhs: StickerPreviewPeekItem) -> Bool {
switch lhs {
case let .pack(lhsPack):
if case let .pack(rhsPack) = rhs, lhsPack == rhsPack {
return true
} else {
return false
}
case let .found(lhsItem):
if case let .found(rhsItem) = rhs, lhsItem == rhsItem {
return true
} else {
return false
}
case let .portal(lhsPortal):
if case let .portal(rhsPortal) = rhs, lhsPortal === rhsPortal {
return true
} else {
return false
}
}
}
case pack(TelegramMediaFile)
case found(FoundStickerItem)
case portal(PortalView)
public var file: TelegramMediaFile? {
switch self {
case let .pack(file):
return file
case let .found(item):
return item.file
case .portal:
return nil
}
}
}
public final class StickerPreviewPeekContent: PeekControllerContent {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
public let item: StickerPreviewPeekItem
let isLocked: Bool
let isCreating: Bool
let selectedEmoji: [String]
let selectedEmojiUpdated: ([String]) -> Void
let recommendedEmoji: [String]
let menu: [ContextMenuItem]
let openPremiumIntro: () -> Void
public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, item: StickerPreviewPeekItem, isLocked: Bool = false, isCreating: Bool = false, selectedEmoji: [String] = [], selectedEmojiUpdated: @escaping ([String]) -> Void = { _ in }, recommendedEmoji: [String] = [], menu: [ContextMenuItem], openPremiumIntro: @escaping () -> Void) {
self.context = context
self.theme = theme
self.strings = strings
self.item = item
self.isLocked = isLocked
self.isCreating = isCreating
self.selectedEmoji = selectedEmoji
self.selectedEmojiUpdated = selectedEmojiUpdated
self.recommendedEmoji = recommendedEmoji
if isLocked {
self.menu = []
} else {
self.menu = menu
}
self.openPremiumIntro = openPremiumIntro
}
public func presentation() -> PeekControllerContentPresentation {
return .freeform
}
public func menuActivation() -> PeerControllerMenuActivation {
return .press
}
public func menuItems() -> [ContextMenuItem] {
return self.menu
}
public func node() -> PeekControllerContentNode & ASDisplayNode {
return StickerPreviewPeekContentNode(context: self.context, item: self.item, theme: self.theme)
}
public func topAccessoryNode() -> ASDisplayNode? {
return nil
}
public func fullScreenAccessoryNode(blurView: UIVisualEffectView) -> (PeekControllerAccessoryNode & ASDisplayNode)? {
if self.isCreating {
return EmojiStickerAccessoryNode(context: self.context, theme: self.theme, recommendedEmoji: self.recommendedEmoji, selectedEmoji: self.selectedEmoji, selectedEmojiUpdated: self.selectedEmojiUpdated)
}
if self.isLocked {
return PremiumStickerPackAccessoryNode(theme: self.theme, strings: self.strings, isEmoji: self.item.file?.isCustomEmoji ?? false, proceed: self.openPremiumIntro)
} else {
return nil
}
}
public func isEqual(to: PeekControllerContent) -> Bool {
if let to = to as? StickerPreviewPeekContent {
return self.item == to.item
} else {
return false
}
}
}
public final class StickerPreviewPeekContentNode: ASDisplayNode, PeekControllerContentNode {
private let context: AccountContext
private let item: StickerPreviewPeekItem
private var textNode: ASTextNode
public var imageNode: TransformImageNode
public var animationNode: AnimatedStickerNode?
public var additionalAnimationNode: AnimatedStickerNode?
private let portalWrapperNode: ASDisplayNode
private let effectDisposable = MetaDisposable()
private var containerLayout: (ContainerViewLayout, CGFloat)?
private let _ready = Promise<Bool>()
init(context: AccountContext, item: StickerPreviewPeekItem, theme: PresentationTheme) {
self.context = context
self.item = item
self.textNode = ASTextNode()
self.imageNode = TransformImageNode()
var isPremiumSticker = false
if let file = item.file {
for case let .Sticker(text, _, _) in file.attributes {
self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(32.0), textColor: .black)
break
}
isPremiumSticker = file.isPremiumSticker
if file.isAnimatedSticker || file.isVideoSticker {
let animationNode = DefaultAnimatedStickerNodeImpl()
animationNode.overrideVisibility = true
self.animationNode = animationNode
let dimensions = file.dimensions ?? PixelDimensions(width: 512, height: 512)
let fitSize: CGSize
if file.isCustomEmoji {
fitSize = CGSize(width: 200.0, height: 200.0)
} else {
fitSize = CGSize(width: 400.0, height: 400.0)
}
let fittedDimensions = dimensions.cgSize.aspectFitted(fitSize)
if file.isCustomTemplateEmoji {
animationNode.dynamicColor = theme.list.itemPrimaryTextColor
}
animationNode.setup(source: AnimatedStickerResourceSource(account: context.account, resource: file.resource, isVideo: file.isVideoSticker), width: Int(fittedDimensions.width), height: Int(fittedDimensions.height), playbackMode: isPremiumSticker ? .once : .loop, mode: .direct(cachePathPrefix: nil))
animationNode.visibility = true
animationNode.addSubnode(self.textNode)
if isPremiumSticker, let effect = file.videoThumbnails.first {
self.effectDisposable.set(freeMediaFileResourceInteractiveFetched(account: context.account, userLocation: .other, fileReference: .standalone(media: file), resource: effect.resource).start())
let source = AnimatedStickerResourceSource(account: context.account, resource: effect.resource, fitzModifier: nil)
let additionalAnimationNode = DefaultAnimatedStickerNodeImpl()
additionalAnimationNode.setup(source: source, width: Int(fittedDimensions.width * 2.0), height: Int(fittedDimensions.height * 2.0), playbackMode: .once, mode: .direct(cachePathPrefix: nil))
additionalAnimationNode.visibility = true
self.additionalAnimationNode = additionalAnimationNode
}
} else {
self.imageNode.addSubnode(self.textNode)
self.animationNode = nil
}
self.imageNode.setSignal(chatMessageSticker(account: context.account, userLocation: .other, file: file, small: false, fetched: true))
} else if case .portal = item {
self._ready.set(.single(true))
}
self.portalWrapperNode = ASDisplayNode()
self.portalWrapperNode.clipsToBounds = true
self.portalWrapperNode.isUserInteractionEnabled = false
super.init()
self.isUserInteractionEnabled = false
if let animationNode = self.animationNode {
self.addSubnode(animationNode)
if isPremiumSticker {
animationNode.completed = { [weak self] _ in
if let strongSelf = self, let animationNode = strongSelf.animationNode, let additionalAnimationNode = strongSelf.additionalAnimationNode {
Queue.mainQueue().async {
animationNode.play(firstFrame: false, fromIndex: nil)
additionalAnimationNode.play(firstFrame: false, fromIndex: nil)
}
}
}
}
} else {
self.addSubnode(self.imageNode)
}
if let additionalAnimationNode = self.additionalAnimationNode {
self.addSubnode(additionalAnimationNode)
}
self.addSubnode(self.portalWrapperNode)
if let animationNode = self.animationNode {
animationNode.started = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf._ready.set(.single(true))
}
} else {
self.imageNode.imageUpdated = { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf._ready.set(.single(true))
}
}
}
deinit {
self.effectDisposable.dispose()
}
public override func didLoad() {
super.didLoad()
if case let .portal(portalView) = self.item {
self.portalWrapperNode.view.addSubview(portalView.view)
}
}
public func ready() -> Signal<Bool, NoError> {
return self._ready.get()
}
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let boundingSize: CGSize
if self.item.file?.isCustomEmoji == true {
boundingSize = CGSize(width: 120.0, height: 120.0)
} else if let _ = self.additionalAnimationNode {
boundingSize = CGSize(width: 240.0, height: 240.0).fitted(size)
} else {
boundingSize = CGSize(width: 180.0, height: 180.0).fitted(size)
}
let dimensions: PixelDimensions
if let dimensionsValue = self.item.file?.dimensions {
dimensions = dimensionsValue
} else {
dimensions = PixelDimensions(width: 512, height: 512)
}
var topOffset: CGFloat = 0.0
var textSpacing: CGFloat = 50.0
if size.width == 292.0 {
topOffset = 60.0
textSpacing -= 10.0
} else if size.width == 347.0 && size.height == 577.0 {
topOffset = 60.0
textSpacing -= 10.0
}
let textSize = self.textNode.measure(CGSize(width: 100.0, height: 100.0))
if textSize.height.isZero {
topOffset = 0.0
textSpacing = 0.0
}
let imageSize = dimensions.cgSize.aspectFitted(boundingSize)
self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets()))()
var imageFrame = CGRect(origin: CGPoint(x: floor((boundingSize.width - imageSize.width) / 2.0), y: textSize.height + textSpacing - topOffset), size: imageSize)
var centerOffset: CGFloat = 0.0
if self.item.file?.isPremiumSticker == true {
let originalImageFrame = imageFrame
imageFrame.origin.x = min(imageFrame.minX + imageFrame.width * 0.1, size.width - imageFrame.width - 18.0)
centerOffset = imageFrame.minX - originalImageFrame.minX
}
self.imageNode.frame = imageFrame
if let animationNode = self.animationNode {
animationNode.frame = imageFrame
animationNode.updateLayout(size: imageSize)
if let additionalAnimationNode = self.additionalAnimationNode {
additionalAnimationNode.frame = imageFrame.offsetBy(dx: -imageFrame.width * 0.245 + 21.0, dy: -1.0).insetBy(dx: -imageFrame.width * 0.245, dy: -imageFrame.height * 0.245)
additionalAnimationNode.updateLayout(size: additionalAnimationNode.frame.size)
}
}
if case let .portal(portalView) = self.item {
self.portalWrapperNode.bounds = CGRect(origin: .zero, size: imageFrame.size)
self.portalWrapperNode.position = imageFrame.center
self.portalWrapperNode.cornerRadius = imageFrame.size.width / 8.0
portalView.view.center = CGPoint(x: imageFrame.size.width / 2.0, y: imageFrame.size.height / 2.0)
let scale = 180.0 / (size.width * 1.04)
portalView.view.transform = CGAffineTransformMakeScale(scale, scale)
}
self.textNode.frame = CGRect(origin: CGPoint(x: floor((imageFrame.size.width - textSize.width) / 2.0) - centerOffset, y: -textSize.height - textSpacing), size: textSize)
if self.item.file?.isCustomEmoji == true || textSize.height.isZero {
return CGSize(width: boundingSize.width, height: imageFrame.height)
} else {
return CGSize(width: boundingSize.width, height: imageFrame.height + textSize.height + textSpacing)
}
}
}
final class PremiumStickerPackAccessoryNode: SparseNode, PeekControllerAccessoryNode {
var dismiss: () -> Void = {}
let proceed: () -> Void
let textNode: ImmediateTextNode
let proceedButton: SolidRoundedButtonNode
let cancelButton: HighlightableButtonNode
init(theme: PresentationTheme, strings: PresentationStrings, isEmoji: Bool, proceed: @escaping () -> Void) {
self.proceed = proceed
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.textAlignment = .center
self.textNode.maximumNumberOfLines = 0
self.textNode.attributedText = NSAttributedString(string: isEmoji ? strings.Premium_Emoji_Description : strings.Premium_Stickers_Description, font: Font.regular(17.0), textColor: theme.actionSheet.secondaryTextColor)
self.textNode.lineSpacing = 0.1
self.proceedButton = SolidRoundedButtonNode(title: isEmoji ? strings.Premium_Emoji_Proceed: strings.Premium_Stickers_Proceed, theme: SolidRoundedButtonTheme(
backgroundColor: .white,
backgroundColors: [
UIColor(rgb: 0x0077ff),
UIColor(rgb: 0x6b93ff),
UIColor(rgb: 0x8878ff),
UIColor(rgb: 0xe46ace)
], foregroundColor: .white), height: 50.0, cornerRadius: 11.0, gloss: true)
self.proceedButton.iconPosition = .right
self.proceedButton.iconSpacing = 4.0
self.proceedButton.animation = "premium_unlock"
self.cancelButton = HighlightableButtonNode()
self.cancelButton.setTitle(strings.Common_Cancel, with: Font.regular(17.0), with: theme.list.itemAccentColor, for: .normal)
super.init()
self.addSubnode(self.textNode)
self.addSubnode(self.proceedButton)
self.addSubnode(self.cancelButton)
self.proceedButton.pressed = { [weak self] in
self?.dismiss()
self?.proceed()
}
self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside)
}
@objc func cancelPressed() {
self.dismiss()
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
let sideInset: CGFloat = 16.0
var bottomOffset: CGFloat = 0.0
if size.width == 320.0 {
bottomOffset = 30.0
} else if size.width == 375.0 && size.height == 667.0 {
bottomOffset = 30.0
}
let cancelSize = self.cancelButton.measure(size)
self.cancelButton.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - cancelSize.width) / 2.0), y: size.height - cancelSize.height - 49.0 + bottomOffset), size: cancelSize)
let buttonWidth = size.width - sideInset * 2.0
let buttonHeight = self.proceedButton.updateLayout(width: buttonWidth, transition: transition)
self.proceedButton.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - buttonWidth) / 2.0), y: size.height - cancelSize.height - 49.0 - buttonHeight - 23.0 + bottomOffset), size: CGSize(width: buttonWidth, height: buttonHeight))
let textSideInset = size.width == 320.0 ? sideInset : sideInset * 2.0
let textSize = self.textNode.updateLayout(CGSize(width: size.width - textSideInset * 2.0, height: CGFloat.greatestFiniteMagnitude))
self.textNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: size.height - cancelSize.height - 48.0 - buttonHeight - 20.0 - textSize.height - 31.0 + bottomOffset), size: textSize)
}
}
private func topItems(selectedEmoji: [String] = [], recommendedEmoji: [String], count: Int) -> [String] {
var defaultItems: [String] = [
"👍", "👎", "", "🔥", "🥰", "👏", "😁", "😎"
]
if !recommendedEmoji.isEmpty, let firstEmoji = recommendedEmoji.first {
defaultItems.remove(at: defaultItems.count - 2)
defaultItems.insert(firstEmoji, at: defaultItems.count - 1)
}
var result = selectedEmoji.filter { !defaultItems.contains($0) }
result.append(contentsOf: defaultItems)
return Array(result.prefix(count))
}
final class EmojiStickerAccessoryNode: SparseNode, PeekControllerAccessoryNode {
let context: AccountContext
let reactionContextNode: ReactionContextNode
var selectedItemsDisposable: Disposable?
var dismiss: () -> Void = {}
private var scheduledCollapse = false
init(context: AccountContext, theme: PresentationTheme, recommendedEmoji: [String], selectedEmoji: [String], selectedEmojiUpdated: @escaping ([String]) -> Void) {
self.context = context
var layoutImpl: ((ContainedViewLayoutTransition) -> Void)?
let items = topItems(selectedEmoji: selectedEmoji, recommendedEmoji: recommendedEmoji, count: 7)
let selectedItems = ValuePromise<[String]>(selectedEmoji)
let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkPresentationTheme)
let reactionContextNode = ReactionContextNode(
context: self.context,
animationCache: self.context.animationCache,
presentationData: presentationData,
items: items.map { .staticEmoji($0) },
selectedItems: Set(selectedEmoji),
title: presentationData.strings.MediaEditor_SetStickerEmoji,
reactionsLocked: false,
alwaysAllowPremiumReactions: true,
allPresetReactionsAreAvailable: true,
getEmojiContent: { animationCache, animationRenderer in
return selectedItems.get()
|> mapToSignal { selectedItems in
return EmojiPagerContentComponent.emojiInputData(
context: context,
animationCache: animationCache,
animationRenderer: animationRenderer,
isStandalone: false,
subject: .stickerAlt,
hasTrending: false,
topReactionItems: [],
topEmojiItems: topItems(selectedEmoji: selectedItems, recommendedEmoji: recommendedEmoji, count: 8),
areUnicodeEmojiEnabled: true,
areCustomEmojiEnabled: false,
chatPeerId: context.account.peerId,
selectedItems: Set(selectedItems),
hasRecent: false,
premiumIfSavedMessages: false
)
}
},
isExpandedUpdated: { transition in
layoutImpl?(transition)
},
requestLayout: { transition in
layoutImpl?(transition)
},
requestUpdateOverlayWantsToBeBelowKeyboard: { transition in
layoutImpl?(transition)
}
)
reactionContextNode.hideBackground = true
reactionContextNode.displayTail = true
reactionContextNode.forceTailToRight = true
reactionContextNode.forceDark = true
reactionContextNode.isEmojiOnly = true
self.reactionContextNode = reactionContextNode
super.init()
layoutImpl = { [weak self] transition in
self?.requestLayout(forceUpdate: true, transition: transition)
}
reactionContextNode.emojiSelected = { [weak self] emoji in
guard let self else {
return
}
let _ = (selectedItems.get()
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak self] items in
guard let self else {
return
}
var items = items
if let index = items.firstIndex(where: { $0 == emoji }) {
items.remove(at: index)
} else {
items.append(emoji)
}
selectedItems.set(items)
selectedEmojiUpdated(items)
self.reactionContextNode.collapse()
})
}
self.addSubnode(reactionContextNode)
self.selectedItemsDisposable = (selectedItems.get()
|> deliverOnMainQueue).start(next: { [weak self] items in
guard let self else {
return
}
self.reactionContextNode.selectedItems = Set(items)
self.reactionContextNode.items = topItems(selectedEmoji: items, recommendedEmoji: recommendedEmoji, count: 7).map { .staticEmoji($0) }
})
}
deinit {
self.selectedItemsDisposable?.dispose()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if let result, result.isDescendant(of: self.reactionContextNode.view) {
return result
} else if self.reactionContextNode.isExpanded {
if !self.scheduledCollapse {
self.scheduledCollapse = true
Queue.mainQueue().after(0.01, {
self.scheduledCollapse = false
self.reactionContextNode.collapse()
})
}
return self.reactionContextNode.view
}
return nil
}
func requestLayout(forceUpdate: Bool = false, transition: ContainedViewLayoutTransition) {
guard let size = self.currentLayout else {
return
}
self.updateLayout(size: size, forceUpdate: forceUpdate, transition: transition)
}
private var currentLayout: CGSize?
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
self.updateLayout(size: size, forceUpdate: false, transition: transition)
}
func updateLayout(size: CGSize, forceUpdate: Bool, transition: ContainedViewLayoutTransition) {
let isFirstTime = self.currentLayout == nil
self.currentLayout = size
let anchorRect = CGRect(x: size.width / 2.0, y: size.height / 3.0 - 50.0, width: 0.0, height: 0.0)
transition.updateFrame(view: self.reactionContextNode.view, frame: CGRect(origin: CGPoint(), size: size))
self.reactionContextNode.updateLayout(size: size, insets: UIEdgeInsets(top: 64.0, left: 0.0, bottom: 0.0, right: 0.0), anchorRect: anchorRect, centerAligned: true, isCoveredByInput: false, isAnimatingOut: false, forceUpdate: forceUpdate, transition: transition)
if isFirstTime {
Queue.mainQueue().justDispatch {
self.reactionContextNode.animateIn(from: anchorRect)
}
}
}
}