Telegram-iOS/submodules/TelegramUI/Components/ChatThemeScreen/Sources/ChatThemeScreen.swift
Ilya Laktyushin 622f9d970a Various fixes
2026-05-31 17:35:32 +02:00

1974 lines
91 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import ComponentDisplayAdapters
import ViewControllerComponent
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import TelegramUIPreferences
import TelegramNotices
import PresentationDataUtils
import MergeLists
import MediaResources
import StickerResources
import WallpaperResources
import TooltipUI
import AnimatedStickerNode
import TelegramAnimatedStickerNode
import ShimmerEffect
import AttachmentUI
import AvatarNode
import AlertComponent
import SheetComponent
import ButtonComponent
import GlassBarButtonComponent
import BundleIconComponent
import LottieComponent
import MultilineTextComponent
private struct ThemeSettingsThemeEntry: Comparable, Identifiable {
let index: Int
let chatTheme: ChatTheme?
let emojiFile: TelegramMediaFile?
let themeReference: PresentationThemeReference?
let peer: EnginePeer?
let nightMode: Bool
var selected: Bool
let theme: PresentationTheme
let strings: PresentationStrings
let wallpaper: TelegramWallpaper?
var stableId: String {
return self.chatTheme?.id ?? "\(self.index)"
}
static func ==(lhs: ThemeSettingsThemeEntry, rhs: ThemeSettingsThemeEntry) -> Bool {
if lhs.index != rhs.index {
return false
}
if lhs.chatTheme != rhs.chatTheme {
return false
}
if lhs.themeReference?.index != rhs.themeReference?.index {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.nightMode != rhs.nightMode {
return false
}
if lhs.selected != rhs.selected {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.wallpaper != rhs.wallpaper {
return false
}
return true
}
static func <(lhs: ThemeSettingsThemeEntry, rhs: ThemeSettingsThemeEntry) -> Bool {
return lhs.index < rhs.index
}
func item(context: AccountContext, action: @escaping (ChatTheme?) -> Void) -> ListViewItem {
return ThemeSettingsThemeIconItem(context: context, chatTheme: self.chatTheme, emojiFile: self.emojiFile, themeReference: self.themeReference, peer: self.peer, nightMode: self.nightMode, selected: self.selected, theme: self.theme, strings: self.strings, wallpaper: self.wallpaper, action: action)
}
}
private class ThemeSettingsThemeIconItem: ListViewItem {
let context: AccountContext
let chatTheme: ChatTheme?
let emojiFile: TelegramMediaFile?
let themeReference: PresentationThemeReference?
let peer: EnginePeer?
let nightMode: Bool
let selected: Bool
let theme: PresentationTheme
let strings: PresentationStrings
let wallpaper: TelegramWallpaper?
let action: (ChatTheme?) -> Void
public init(
context: AccountContext,
chatTheme: ChatTheme?,
emojiFile: TelegramMediaFile?,
themeReference: PresentationThemeReference?,
peer: EnginePeer?,
nightMode: Bool,
selected: Bool,
theme: PresentationTheme,
strings: PresentationStrings,
wallpaper: TelegramWallpaper?,
action: @escaping (ChatTheme?) -> Void
) {
self.context = context
self.chatTheme = chatTheme
self.emojiFile = emojiFile
self.themeReference = themeReference
self.peer = peer
self.nightMode = nightMode
self.selected = selected
self.theme = theme
self.strings = strings
self.wallpaper = wallpaper
self.action = action
}
public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ThemeSettingsThemeItemIconNode()
let (nodeLayout, apply) = node.asyncLayout()(self, params)
node.insets = nodeLayout.insets
node.contentSize = nodeLayout.contentSize
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in
apply(false)
})
})
}
}
}
public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
assert(node() is ThemeSettingsThemeItemIconNode)
if let nodeValue = node() as? ThemeSettingsThemeItemIconNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply(animation.isAnimated)
})
}
}
}
}
}
public var selectable = true
public func selected(listView: ListView) {
self.action(self.chatTheme)
}
}
private struct ThemeSettingsThemeItemNodeTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let crossfade: Bool
let entries: [ThemeSettingsThemeEntry]
}
private func ensureThemeVisible(listNode: ListView, themeId: String?, animated: Bool) -> Bool {
var resultNode: ThemeSettingsThemeItemIconNode?
var previousNode: ThemeSettingsThemeItemIconNode?
var nextNode: ThemeSettingsThemeItemIconNode?
listNode.forEachItemNode { node in
guard let node = node as? ThemeSettingsThemeItemIconNode else {
return
}
if resultNode == nil {
if node.item?.chatTheme?.id == themeId {
resultNode = node
} else {
previousNode = node
}
} else if nextNode == nil {
nextNode = node
}
}
if let resultNode = resultNode {
var nodeToEnsure = resultNode
if case let .visible(resultVisibility, _) = resultNode.visibility, resultVisibility == 1.0 {
if let previousNode = previousNode, case let .visible(previousVisibility, _) = previousNode.visibility, previousVisibility < 0.5 {
nodeToEnsure = previousNode
} else if let nextNode = nextNode, case let .visible(nextVisibility, _) = nextNode.visibility, nextVisibility < 0.5 {
nodeToEnsure = nextNode
}
}
listNode.ensureItemNodeVisible(nodeToEnsure, animated: animated, overflow: 57.0)
return true
} else {
return false
}
}
private func preparedTransition(context: AccountContext, action: @escaping (ChatTheme?) -> Void, from fromEntries: [ThemeSettingsThemeEntry], to toEntries: [ThemeSettingsThemeEntry], crossfade: Bool) -> ThemeSettingsThemeItemNodeTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, action: action), directionHint: .Down) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, action: action), directionHint: nil) }
return ThemeSettingsThemeItemNodeTransition(deletions: deletions, insertions: insertions, updates: updates, crossfade: crossfade, entries: toEntries)
}
private var cachedBorderImages: [String: UIImage] = [:]
private func generateBorderImage(theme: PresentationTheme, bordered: Bool, selected: Bool) -> UIImage? {
let key = "\(theme.list.itemBlocksBackgroundColor.hexString)_\(selected ? "s" + theme.list.itemAccentColor.hexString : theme.list.disclosureArrowColor.hexString)"
if let image = cachedBorderImages[key] {
return image
} else {
let image = generateImage(CGSize(width: 32.0, height: 32.0), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let lineWidth: CGFloat
if selected {
lineWidth = 2.0
context.setLineWidth(lineWidth)
context.setStrokeColor(theme.list.itemBlocksBackgroundColor.cgColor)
context.strokeEllipse(in: bounds.insetBy(dx: 3.0 + lineWidth / 2.0, dy: 3.0 + lineWidth / 2.0))
var accentColor = theme.list.itemAccentColor
if accentColor.rgb == 0xffffff {
accentColor = UIColor(rgb: 0x999999)
}
context.setStrokeColor(accentColor.cgColor)
} else {
context.setStrokeColor(theme.list.disclosureArrowColor.withAlphaComponent(0.4).cgColor)
lineWidth = 1.0
}
if bordered || selected {
context.setLineWidth(lineWidth)
context.strokeEllipse(in: bounds.insetBy(dx: 1.0 + lineWidth / 2.0, dy: 1.0 + lineWidth / 2.0))
}
})?.stretchableImage(withLeftCapWidth: 16, topCapHeight: 16)
cachedBorderImages[key] = image
return image
}
}
private final class ThemeSettingsThemeItemIconNode : ListViewItemNode {
private let containerNode: ASDisplayNode
private let emojiContainerNode: ASDisplayNode
private let imageNode: TransformImageNode
private let overlayNode: ASImageNode
private let textNode: TextNode
private let emojiNode: TextNode
private let emojiImageNode: TransformImageNode
private var animatedStickerNode: AnimatedStickerNode?
private var placeholderNode: StickerShimmerEffectNode
private var bubbleNode: ASImageNode?
private var avatarNode: AvatarNode?
private var replaceNode: ASImageNode?
var snapshotView: UIView?
var item: ThemeSettingsThemeIconItem?
override var visibility: ListViewItemNodeVisibility {
didSet {
self.visibilityStatus = self.visibility != .none
}
}
private var visibilityStatus: Bool = false {
didSet {
if self.visibilityStatus != oldValue {
self.animatedStickerNode?.visibility = self.visibilityStatus
}
}
}
private let stickerFetchedDisposable = MetaDisposable()
init() {
self.containerNode = ASDisplayNode()
self.emojiContainerNode = ASDisplayNode()
self.imageNode = TransformImageNode()
self.imageNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 82.0, height: 108.0))
self.imageNode.isLayerBacked = true
self.imageNode.cornerRadius = 16.0
self.imageNode.clipsToBounds = true
self.imageNode.contentAnimations = [.subsequentUpdates]
self.overlayNode = ASImageNode()
self.overlayNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 84.0, height: 110.0))
self.overlayNode.isLayerBacked = true
self.textNode = TextNode()
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
self.emojiNode = TextNode()
self.emojiNode.isUserInteractionEnabled = false
self.emojiNode.displaysAsynchronously = false
self.emojiImageNode = TransformImageNode()
self.placeholderNode = StickerShimmerEffectNode()
super.init(layerBacked: false, rotated: false, seeThrough: false)
self.addSubnode(self.containerNode)
self.containerNode.addSubnode(self.imageNode)
self.containerNode.addSubnode(self.overlayNode)
self.containerNode.addSubnode(self.textNode)
self.addSubnode(self.emojiContainerNode)
self.emojiContainerNode.addSubnode(self.emojiNode)
self.emojiContainerNode.addSubnode(self.emojiImageNode)
self.emojiContainerNode.addSubnode(self.placeholderNode)
var firstTime = true
self.emojiImageNode.imageUpdated = { [weak self] image in
guard let strongSelf = self else {
return
}
if image != nil {
strongSelf.removePlaceholder(animated: !firstTime)
if firstTime {
strongSelf.emojiImageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
firstTime = false
}
}
deinit {
self.stickerFetchedDisposable.dispose()
}
private func removePlaceholder(animated: Bool) {
if !animated {
self.placeholderNode.removeFromSupernode()
} else {
self.placeholderNode.alpha = 0.0
self.placeholderNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in
self?.placeholderNode.removeFromSupernode()
})
}
}
override func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
let emojiFrame = CGRect(origin: CGPoint(x: 33.0, y: 79.0), size: CGSize(width: 24.0, height: 24.0))
self.placeholderNode.updateAbsoluteRect(CGRect(origin: CGPoint(x: rect.minX + emojiFrame.minX, y: rect.minY + emojiFrame.minY), size: emojiFrame.size), within: containerSize)
}
override func selected() {
let wasSelected = self.item?.selected ?? false
super.selected()
if let animatedStickerNode = self.animatedStickerNode {
Queue.mainQueue().after(0.1) {
if !wasSelected {
animatedStickerNode.seekTo(.frameIndex(0))
animatedStickerNode.play(firstFrame: false, fromIndex: nil)
let scale: CGFloat = 2.6
animatedStickerNode.transform = CATransform3DMakeScale(scale, scale, 1.0)
animatedStickerNode.layer.animateSpring(from: 1.0 as NSNumber, to: scale as NSNumber, keyPath: "transform.scale", duration: 0.45)
animatedStickerNode.completed = { [weak animatedStickerNode, weak self] _ in
guard let item = self?.item, item.selected else {
return
}
animatedStickerNode?.transform = CATransform3DIdentity
animatedStickerNode?.layer.animateSpring(from: scale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
}
}
}
}
}
func asyncLayout() -> (ThemeSettingsThemeIconItem, ListViewItemLayoutParams) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeTextLayout = TextNode.asyncLayout(self.textNode)
let makeEmojiLayout = TextNode.asyncLayout(self.emojiNode)
let makeImageLayout = self.imageNode.asyncLayout()
let currentItem = self.item
return { [weak self] item, params in
var updatedEmoticon = false
var updatedThemeReference = false
var updatedTheme = false
var updatedWallpaper = false
var updatedSelected = false
var updatedNightMode = false
if currentItem?.chatTheme?.id != item.chatTheme?.id {
updatedEmoticon = true
}
if currentItem?.themeReference != item.themeReference {
updatedThemeReference = true
}
if currentItem?.wallpaper != item.wallpaper {
updatedWallpaper = true
}
if currentItem?.theme !== item.theme {
updatedTheme = true
}
if currentItem?.selected != item.selected {
updatedSelected = true
}
if currentItem?.nightMode != item.nightMode {
updatedNightMode = true
}
let text = NSAttributedString(string: item.strings.Conversation_Theme_NoTheme, font: Font.semibold(15.0), textColor: item.theme.actionSheet.controlAccentColor)
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: text, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets()))
let emoticon: String
if let _ = item.chatTheme {
emoticon = ""
} else {
emoticon = ""
}
let title = NSAttributedString(string: emoticon, font: Font.regular(22.0), textColor: .black)
let (_, emojiApply) = makeEmojiLayout(TextNodeLayoutArguments(attributedString: title, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets()))
let itemLayout = ListViewItemNodeLayout(contentSize: CGSize(width: 120.0, height: 90.0), insets: UIEdgeInsets())
return (itemLayout, { animated in
if let strongSelf = self {
strongSelf.item = item
if updatedThemeReference || updatedWallpaper || updatedNightMode {
if let themeReference = item.themeReference {
strongSelf.imageNode.setSignal(themeIconImage(account: item.context.account, accountManager: item.context.sharedContext.accountManager, theme: themeReference, color: nil, wallpaper: item.wallpaper, nightMode: item.nightMode, emoticon: true))
strongSelf.imageNode.backgroundColor = nil
}
}
if item.themeReference == nil {
if item.theme.overallDarkAppearance {
strongSelf.imageNode.backgroundColor = item.theme.list.plainBackgroundColor
} else {
strongSelf.imageNode.backgroundColor = item.theme.rootController.navigationBar.segmentedForegroundColor
}
}
if updatedTheme || updatedSelected {
strongSelf.overlayNode.image = generateBorderImage(theme: item.theme, bordered: false, selected: item.selected)
}
if !item.selected && currentItem?.selected == true, let animatedStickerNode = strongSelf.animatedStickerNode {
animatedStickerNode.transform = CATransform3DIdentity
let initialScale: CGFloat = CGFloat((animatedStickerNode.value(forKeyPath: "layer.presentationLayer.transform.scale.x") as? NSNumber)?.floatValue ?? 1.0)
animatedStickerNode.layer.animateSpring(from: initialScale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
}
strongSelf.textNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((90.0 - textLayout.size.width) / 2.0), y: 24.0), size: textLayout.size)
strongSelf.textNode.isHidden = emoticon.isEmpty
strongSelf.containerNode.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0)
strongSelf.containerNode.frame = CGRect(origin: CGPoint(x: 15.0, y: -15.0), size: CGSize(width: 90.0, height: 120.0))
strongSelf.emojiContainerNode.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0)
strongSelf.emojiContainerNode.frame = CGRect(origin: CGPoint(x: 15.0, y: -15.0), size: CGSize(width: 90.0, height: 120.0))
let _ = textApply()
let _ = emojiApply()
let imageSize = CGSize(width: 82.0, height: 108.0)
strongSelf.imageNode.frame = CGRect(origin: CGPoint(x: 4.0, y: 6.0), size: imageSize)
let applyLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: .clear))
applyLayout()
strongSelf.overlayNode.frame = strongSelf.imageNode.frame.insetBy(dx: -1.0, dy: -1.0)
strongSelf.emojiNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 79.0), size: CGSize(width: 90.0, height: 30.0))
let emojiFrame = CGRect(origin: CGPoint(x: 33.0, y: 79.0), size: CGSize(width: 24.0, height: 24.0))
if let file = item.emojiFile, updatedEmoticon {
let imageApply = strongSelf.emojiImageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: emojiFrame.size, boundingSize: emojiFrame.size, intrinsicInsets: UIEdgeInsets()))
imageApply()
strongSelf.emojiImageNode.setSignal(chatMessageStickerPackThumbnail(postbox: item.context.account.postbox, resource: file.resource, animated: true, nilIfEmpty: true))
strongSelf.emojiImageNode.frame = emojiFrame
let animatedStickerNode: AnimatedStickerNode
if let current = strongSelf.animatedStickerNode {
animatedStickerNode = current
} else {
animatedStickerNode = DefaultAnimatedStickerNodeImpl()
animatedStickerNode.started = { [weak self] in
self?.emojiImageNode.isHidden = true
}
strongSelf.animatedStickerNode = animatedStickerNode
strongSelf.emojiContainerNode.insertSubnode(animatedStickerNode, belowSubnode: strongSelf.placeholderNode)
let pathPrefix = item.context.engine.resources.shortLivedResourceCachePathPrefix(id: EngineMediaResource.Id(file.resource.id))
animatedStickerNode.setup(source: AnimatedStickerResourceSource(account: item.context.account, resource: file.resource), width: 128, height: 128, playbackMode: .still(.start), mode: .direct(cachePathPrefix: pathPrefix))
animatedStickerNode.anchorPoint = CGPoint(x: 0.5, y: 1.0)
}
animatedStickerNode.autoplay = true
animatedStickerNode.visibility = strongSelf.visibilityStatus
strongSelf.stickerFetchedDisposable.set(item.context.engine.resources.fetch(reference: MediaResourceReference.media(media: .standalone(media: file), resource: file.resource), userLocation: .other, userContentType: .sticker).startStrict())
let thumbnailDimensions = PixelDimensions(width: 512, height: 512)
strongSelf.placeholderNode.update(backgroundColor: nil, foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.2), shimmeringColor: UIColor(rgb: 0xffffff, alpha: 0.3), data: file.immediateThumbnailData, size: emojiFrame.size, enableEffect: item.context.sharedContext.energyUsageSettings.fullTranslucency, imageSize: thumbnailDimensions.cgSize)
strongSelf.placeholderNode.frame = emojiFrame
}
if let animatedStickerNode = strongSelf.animatedStickerNode {
animatedStickerNode.frame = emojiFrame
animatedStickerNode.updateLayout(size: emojiFrame.size)
}
if let _ = item.peer {
let bubbleNode: ASImageNode
if let current = strongSelf.bubbleNode {
bubbleNode = current
} else {
bubbleNode = ASImageNode()
strongSelf.insertSubnode(bubbleNode, belowSubnode: strongSelf.emojiContainerNode)
strongSelf.bubbleNode = bubbleNode
var bubbleColor: UIColor?
if let theme = item.chatTheme, case let .gift(_, themeSettings) = theme {
if item.nightMode {
if let theme = themeSettings.first(where: { $0.baseTheme == .night || $0.baseTheme == .tinted }) {
let color = theme.wallpaper?.settings?.colors.first ?? theme.accentColor
bubbleColor = UIColor(rgb: UInt32(bitPattern: color))
}
} else {
if let theme = themeSettings.first(where: { $0.baseTheme == .classic || $0.baseTheme == .day }) {
let color = theme.wallpaper?.settings?.colors.first ?? theme.accentColor
bubbleColor = UIColor(rgb: UInt32(bitPattern: color))
}
}
}
if let bubbleColor {
bubbleNode.image = generateFilledRoundedRectImage(size: CGSize(width: 24.0, height: 48.0), cornerRadius: 12.0, color: bubbleColor)
}
}
bubbleNode.frame = CGRect(origin: CGPoint(x: 50.0, y: 12.0), size: CGSize(width: 24.0, height: 48.0))
} else if let bubbleNode = strongSelf.bubbleNode {
strongSelf.bubbleNode = nil
bubbleNode.removeFromSupernode()
}
if let peer = item.peer {
let avatarNode: AvatarNode
if let current = strongSelf.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 8.0))
strongSelf.insertSubnode(avatarNode, belowSubnode: strongSelf.emojiContainerNode)
strongSelf.avatarNode = avatarNode
avatarNode.setPeer(context: item.context, theme: item.theme, peer: peer, displayDimensions: CGSize(width: 20.0, height: 20.0))
}
avatarNode.transform = CATransform3DMakeRotation(.pi / 2.0, 0.0, 0.0, 1.0)
avatarNode.frame = CGRect(origin: CGPoint(x: 52.0, y: 14.0), size: CGSize(width: 20.0, height: 20.0))
} else if let avatarNode = strongSelf.avatarNode {
strongSelf.avatarNode = nil
avatarNode.removeFromSupernode()
}
if let _ = item.peer {
let replaceNode: ASImageNode
if let current = strongSelf.replaceNode {
replaceNode = current
} else {
replaceNode = ASImageNode()
strongSelf.insertSubnode(replaceNode, belowSubnode: strongSelf.emojiContainerNode)
strongSelf.replaceNode = replaceNode
replaceNode.image = generateTintedImage(image: UIImage(bundleImageName: "Settings/Refresh"), color: .white)
}
replaceNode.transform = CATransform3DMakeRotation(.pi / 2.0, 0.0, 0.0, 1.0)
if let image = replaceNode.image {
replaceNode.frame = CGRect(origin: CGPoint(x: 53.0, y: 37.0), size: image.size)
}
} else if let replaceNode = strongSelf.replaceNode {
strongSelf.replaceNode = nil
replaceNode.removeFromSupernode()
}
}
})
}
}
func crossfade() {
if let snapshotView = self.containerNode.view.snapshotView(afterScreenUpdates: false) {
snapshotView.transform = self.containerNode.view.transform
snapshotView.frame = self.containerNode.view.frame
self.view.insertSubview(snapshotView, aboveSubview: self.containerNode.view)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatThemeScreen.themeCrossfadeDuration, delay: ChatThemeScreen.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
super.animateInsertion(currentTimestamp, duration: duration, options: options)
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
super.animateRemoved(currentTimestamp, duration: duration)
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
}
override func animateAdded(_ currentTimestamp: Double, duration: Double) {
super.animateAdded(currentTimestamp, duration: duration)
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
public final class ChatThemeScreen: ViewController {
public static let themeCrossfadeDuration: Double = 0.3
public static let themeCrossfadeDelay: Double = 0.25
private var controllerNode: ChatThemeSheetScreenNode {
return self.displayNode as! ChatThemeSheetScreenNode
}
private var animatedIn = false
private let context: AccountContext
private let animatedEmojiStickers: [String: [StickerPackItem]]
private let initiallySelectedTheme: ChatTheme?
private let peerName: String
fileprivate let canResetWallpaper: Bool
private let previewTheme: (ChatTheme?, Bool?) -> Void
fileprivate let changeWallpaper: () -> Void
fileprivate let resetWallpaper: () -> Void
private let completion: (ChatTheme?) -> Void
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
public var dismissed: (() -> Void)?
public var passthroughHitTestImpl: ((CGPoint) -> UIView?)? {
didSet {
if self.isNodeLoaded {
self.controllerNode.passthroughHitTestImpl = self.passthroughHitTestImpl
}
}
}
public init(
context: AccountContext,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>),
animatedEmojiStickers: [String: [StickerPackItem]],
initiallySelectedTheme: ChatTheme?,
peerName: String,
canResetWallpaper: Bool,
previewTheme: @escaping (ChatTheme?, Bool?) -> Void,
changeWallpaper: @escaping () -> Void,
resetWallpaper: @escaping () -> Void,
completion: @escaping (ChatTheme?) -> Void
) {
self.context = context
self.presentationData = updatedPresentationData.initial
self.animatedEmojiStickers = animatedEmojiStickers
self.initiallySelectedTheme = initiallySelectedTheme
self.peerName = peerName
self.canResetWallpaper = canResetWallpaper
self.previewTheme = previewTheme
self.changeWallpaper = changeWallpaper
self.resetWallpaper = resetWallpaper
self.completion = completion
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
self.blocksBackgroundWhenInOverlay = true
self.presentationDataDisposable = (updatedPresentationData.signal
|> deliverOnMainQueue).startStrict(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.presentationData = presentationData
strongSelf.controllerNode.updatePresentationData(presentationData)
}
})
self.statusBar.statusBarStyle = .Ignore
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = ChatThemeSheetScreenNode(context: self.context, presentationData: self.presentationData, controller: self, animatedEmojiStickers: self.animatedEmojiStickers, initiallySelectedTheme: self.initiallySelectedTheme, peerName: self.peerName)
self.controllerNode.passthroughHitTestImpl = self.passthroughHitTestImpl
self.controllerNode.previewTheme = { [weak self] chatTheme, dark in
guard let strongSelf = self else {
return
}
strongSelf.previewTheme((chatTheme ?? .emoticon("")), dark)
}
self.controllerNode.present = { [weak self] c in
self?.present(c, in: .current)
}
self.controllerNode.completion = { [weak self] chatTheme in
guard let strongSelf = self else {
return
}
strongSelf.dismiss(animated: true)
if strongSelf.initiallySelectedTheme == nil && chatTheme == nil {
} else {
strongSelf.completion(chatTheme)
}
}
self.controllerNode.dismiss = { [weak self] in
self?.dismiss(animated: false)
}
self.controllerNode.cancel = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.dismiss(animated: true)
strongSelf.previewTheme(nil, nil)
}
}
override public func loadView() {
super.loadView()
self.view.disablesInteractiveTransitionGestureRecognizer = true
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.animatedIn {
self.animatedIn = true
self.controllerNode.animateIn()
}
}
override public func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
self.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss()
}
return true
})
if flag {
self.controllerNode.animateOut(completion: {
super.dismiss(animated: flag, completion: completion)
completion?()
})
} else {
super.dismiss(animated: flag, completion: completion)
}
self.dismissed?()
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
public func dimTapped() {
self.controllerNode.dimTapped()
}
}
private func iconColors(theme: PresentationTheme) -> [String: UIColor] {
let accentColor = theme.actionSheet.controlAccentColor
var colors: [String: UIColor] = [:]
colors["Sunny.Path 14.Path.Stroke 1"] = accentColor
colors["Sunny.Path 15.Path.Stroke 1"] = accentColor
colors["Path.Path.Stroke 1"] = accentColor
colors["Sunny.Path 39.Path.Stroke 1"] = accentColor
colors["Sunny.Path 24.Path.Stroke 1"] = accentColor
colors["Sunny.Path 25.Path.Stroke 1"] = accentColor
colors["Sunny.Path 18.Path.Stroke 1"] = accentColor
colors["Sunny.Path 41.Path.Stroke 1"] = accentColor
colors["Sunny.Path 43.Path.Stroke 1"] = accentColor
colors["Path 10.Path.Fill 1"] = accentColor
colors["Path 11.Path.Fill 1"] = accentColor
return colors
}
private func interpolateColors(from: [String: UIColor], to: [String: UIColor], fraction: CGFloat) -> [String: UIColor] {
var colors: [String: UIColor] = [:]
for (key, fromValue) in from {
if let toValue = to[key] {
colors[key] = fromValue.interpolateTo(toValue, fraction: fraction)
}
}
return colors
}
private final class ChatThemeScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let presentationData: PresentationData
let animatedEmojiStickers: [String: [StickerPackItem]]
let initiallySelectedTheme: ChatTheme?
let peerName: String
let canResetWallpaper: Bool
let present: (ViewController) -> Void
let presentInRoot: (ViewController) -> Void
let previewTheme: (ChatTheme?, Bool?) -> Void
let changeWallpaper: () -> Void
let resetWallpaper: () -> Void
let completion: (ChatTheme?) -> Void
let cancel: () -> Void
init(
context: AccountContext,
presentationData: PresentationData,
animatedEmojiStickers: [String: [StickerPackItem]],
initiallySelectedTheme: ChatTheme?,
peerName: String,
canResetWallpaper: Bool,
present: @escaping (ViewController) -> Void,
presentInRoot: @escaping (ViewController) -> Void,
previewTheme: @escaping (ChatTheme?, Bool?) -> Void,
changeWallpaper: @escaping () -> Void,
resetWallpaper: @escaping () -> Void,
completion: @escaping (ChatTheme?) -> Void,
cancel: @escaping () -> Void
) {
self.context = context
self.presentationData = presentationData
self.animatedEmojiStickers = animatedEmojiStickers
self.initiallySelectedTheme = initiallySelectedTheme
self.peerName = peerName
self.canResetWallpaper = canResetWallpaper
self.present = present
self.presentInRoot = presentInRoot
self.previewTheme = previewTheme
self.changeWallpaper = changeWallpaper
self.resetWallpaper = resetWallpaper
self.completion = completion
self.cancel = cancel
}
static func ==(lhs: ChatThemeScreenComponent, rhs: ChatThemeScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.presentationData.theme !== rhs.presentationData.theme {
return false
}
if lhs.presentationData.strings !== rhs.presentationData.strings {
return false
}
if lhs.initiallySelectedTheme != rhs.initiallySelectedTheme {
return false
}
if lhs.peerName != rhs.peerName {
return false
}
if lhs.canResetWallpaper != rhs.canResetWallpaper {
return false
}
return true
}
final class View: UIView {
private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, SheetComponentEnvironment)>()
private let sheetAnimateOut = ActionSlot<Action<Void>>()
private var component: ChatThemeScreenComponent?
private var environment: ViewControllerComponentContainer.Environment?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ChatThemeScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.component = component
let environmentValue = environment[ViewControllerComponentContainer.Environment.self].value
self.environment = environmentValue
let sheetEnvironment = SheetComponentEnvironment(
metrics: environmentValue.metrics,
deviceMetrics: environmentValue.deviceMetrics,
isDisplaying: environmentValue.isVisible,
isCentered: false,
hasInputHeight: !environmentValue.inputHeight.isZero,
regularMetricsSize: nil,
dismiss: { _ in
component.cancel()
}
)
let sheetSize = self.sheet.update(
transition: transition,
component: AnyComponent(SheetComponent(
content: AnyComponent(ChatThemeSheetContentComponent(
context: component.context,
presentationData: component.presentationData,
animatedEmojiStickers: component.animatedEmojiStickers,
initiallySelectedTheme: component.initiallySelectedTheme,
peerName: component.peerName,
canResetWallpaper: component.canResetWallpaper,
present: component.present,
presentInRoot: component.presentInRoot,
previewTheme: component.previewTheme,
changeWallpaper: component.changeWallpaper,
resetWallpaper: component.resetWallpaper,
completion: component.completion,
cancel: component.cancel
)),
style: .glass,
backgroundColor: .color(environmentValue.theme.actionSheet.opaqueItemBackgroundColor),
clipsContent: true,
isScrollEnabled: false,
hasDimView: false,
animateOut: self.sheetAnimateOut
)),
environment: {
environmentValue
sheetEnvironment
},
containerSize: availableSize
)
if let sheetView = self.sheet.view {
if sheetView.superview == nil {
self.addSubview(sheetView)
}
transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: sheetSize))
}
return availableSize
}
private func contentView() -> ChatThemeSheetContentComponent.View? {
guard let sheetView = self.sheet.view else {
return nil
}
return findTaggedComponentViewImpl(view: sheetView, tag: ChatThemeSheetContentComponent.Tag()) as? ChatThemeSheetContentComponent.View
}
func containsContent(point: CGPoint) -> Bool {
guard let contentView = self.contentView() else {
return false
}
return contentView.bounds.contains(self.convert(point, to: contentView))
}
func dimTapped() {
self.contentView()?.dimTapped()
}
func animateOut(completion: @escaping () -> Void) {
self.contentView()?.setAnimatedOut()
self.sheetAnimateOut.invoke(Action { _ in
completion()
})
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class ChatThemeSheetContentComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
final class Tag {
}
let context: AccountContext
let presentationData: PresentationData
let animatedEmojiStickers: [String: [StickerPackItem]]
let initiallySelectedTheme: ChatTheme?
let peerName: String
let canResetWallpaper: Bool
let present: (ViewController) -> Void
let presentInRoot: (ViewController) -> Void
let previewTheme: (ChatTheme?, Bool?) -> Void
let changeWallpaper: () -> Void
let resetWallpaper: () -> Void
let completion: (ChatTheme?) -> Void
let cancel: () -> Void
init(
context: AccountContext,
presentationData: PresentationData,
animatedEmojiStickers: [String: [StickerPackItem]],
initiallySelectedTheme: ChatTheme?,
peerName: String,
canResetWallpaper: Bool,
present: @escaping (ViewController) -> Void,
presentInRoot: @escaping (ViewController) -> Void,
previewTheme: @escaping (ChatTheme?, Bool?) -> Void,
changeWallpaper: @escaping () -> Void,
resetWallpaper: @escaping () -> Void,
completion: @escaping (ChatTheme?) -> Void,
cancel: @escaping () -> Void
) {
self.context = context
self.presentationData = presentationData
self.animatedEmojiStickers = animatedEmojiStickers
self.initiallySelectedTheme = initiallySelectedTheme
self.peerName = peerName
self.canResetWallpaper = canResetWallpaper
self.present = present
self.presentInRoot = presentInRoot
self.previewTheme = previewTheme
self.changeWallpaper = changeWallpaper
self.resetWallpaper = resetWallpaper
self.completion = completion
self.cancel = cancel
}
static func ==(lhs: ChatThemeSheetContentComponent, rhs: ChatThemeSheetContentComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.presentationData.theme !== rhs.presentationData.theme {
return false
}
if lhs.presentationData.strings !== rhs.presentationData.strings {
return false
}
if lhs.initiallySelectedTheme != rhs.initiallySelectedTheme {
return false
}
if lhs.peerName != rhs.peerName {
return false
}
if lhs.canResetWallpaper != rhs.canResetWallpaper {
return false
}
return true
}
final class View: UIView, ComponentTaggedView {
private enum PrimaryAction: Equatable {
case chooseWallpaper
case resetTheme
case apply
}
func matches(tag: Any) -> Bool {
return tag is Tag
}
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private let leftButton = ComponentView<Empty>()
private let switchThemeButton = ComponentView<Empty>()
private let primaryButton = ComponentView<Empty>()
private var resetWallpaperButton: ComponentView<Empty>?
private let switchThemePlayOnce = ActionSlot<Void>()
private let listNode: ListView
private var entries: [ThemeSettingsThemeEntry]?
private var enqueuedTransitions: [ThemeSettingsThemeItemNodeTransition] = []
private var initialized = false
private var component: ChatThemeSheetContentComponent?
private weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private var selectedTheme: ChatTheme?
private var isDarkAppearance: Bool = false
private var isSwitchThemeEnabled = true
private var isCompleting = false
private var themes: [TelegramTheme] = []
private var uniqueGiftChatThemesContext: UniqueGiftChatThemesContext?
private var currentUniqueGiftChatThemesState: UniqueGiftChatThemesContext.State?
private var uniqueGiftPeers: [EnginePeer.Id: EnginePeer] = [:]
private let disposable = MetaDisposable()
private var themeSelectionsCount = 0
private var displayedPreviewTooltip = false
private var animatedOut = false
override init(frame: CGRect) {
self.listNode = ListViewImpl()
self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0)
super.init(frame: frame)
self.addSubview(self.listNode.view)
self.listNode.view.disablesInteractiveTransitionGestureRecognizer = true
self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in
guard let self, let state = self.currentUniqueGiftChatThemesState, case .ready(true) = state.dataState else {
return
}
if case let .known(value) = offset, value < 100.0 {
self.uniqueGiftChatThemesContext?.loadMore()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable.dispose()
}
func setAnimatedOut() {
self.animatedOut = true
}
private func setupIfNeeded(component: ChatThemeSheetContentComponent) {
guard self.uniqueGiftChatThemesContext == nil else {
return
}
self.selectedTheme = component.initiallySelectedTheme
self.isDarkAppearance = component.presentationData.theme.overallDarkAppearance
let uniqueGiftChatThemesContext = UniqueGiftChatThemesContext(account: component.context.account)
self.uniqueGiftChatThemesContext = uniqueGiftChatThemesContext
let context = component.context
self.disposable.set(combineLatest(
queue: Queue.mainQueue(),
context.engine.themes.getChatThemes(accountManager: context.sharedContext.accountManager),
uniqueGiftChatThemesContext.state
|> mapToSignal { state -> Signal<(UniqueGiftChatThemesContext.State, [EnginePeer.Id: EnginePeer]), NoError> in
var peerIds: [EnginePeer.Id] = []
for theme in state.themes {
if case let .gift(gift, _) = theme, case let .unique(uniqueGift) = gift, let themePeerId = uniqueGift.themePeerId {
peerIds.append(themePeerId)
}
}
return combineLatest(
.single(state),
context.engine.data.get(
EngineDataMap(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init))
)
|> map { peers in
var result: [EnginePeer.Id: EnginePeer] = [:]
for peerId in peerIds {
if let maybePeer = peers[peerId], let peer = maybePeer {
result[peerId] = peer
}
}
return result
}
)
}
).startStrict(next: { [weak self] themes, uniqueGiftChatThemesStateAndPeers in
guard let self else {
return
}
self.themes = themes
self.currentUniqueGiftChatThemesState = uniqueGiftChatThemesStateAndPeers.0
self.uniqueGiftPeers = uniqueGiftChatThemesStateAndPeers.1
self.rebuildEntries(crossfade: false)
}))
}
private func hasChanges() -> Bool {
return self.selectedTheme?.id != self.component?.initiallySelectedTheme?.id
}
private func primaryAction() -> PrimaryAction {
if self.selectedTheme?.id == self.component?.initiallySelectedTheme?.id {
return .chooseWallpaper
} else if self.selectedTheme == nil && self.component?.initiallySelectedTheme != nil {
return .resetTheme
} else {
return .apply
}
}
private func primaryButtonTitle(strings: PresentationStrings) -> String {
switch self.primaryAction() {
case .chooseWallpaper:
if self.component?.canResetWallpaper == true {
return strings.Conversation_Theme_SetNewPhotoWallpaper
} else {
return strings.Conversation_Theme_SetPhotoWallpaper
}
case .resetTheme:
return strings.Conversation_Theme_Reset
case .apply:
return strings.Conversation_Theme_Apply
}
}
private func rebuildEntries(crossfade: Bool) {
guard let component = self.component else {
return
}
let presentationData = component.presentationData
let selectedTheme = self.selectedTheme
let isDarkAppearance = self.isDarkAppearance
var entries: [ThemeSettingsThemeEntry] = []
entries.append(ThemeSettingsThemeEntry(
index: 0,
chatTheme: nil,
emojiFile: nil,
themeReference: nil,
peer: nil,
nightMode: false,
selected: selectedTheme == nil,
theme: presentationData.theme,
strings: presentationData.strings,
wallpaper: nil
))
var giftThemes = self.currentUniqueGiftChatThemesState?.themes ?? []
var existingIds = Set<String>()
if let initiallySelectedTheme = component.initiallySelectedTheme, case .gift = initiallySelectedTheme {
let initialThemeIndex = giftThemes.firstIndex(where: { $0.id == initiallySelectedTheme.id })
if initialThemeIndex == nil || initialThemeIndex! > 50 {
giftThemes.insert(initiallySelectedTheme, at: 0)
}
}
for theme in giftThemes {
guard case let .gift(gift, themeSettings) = theme, !existingIds.contains(theme.id) else {
continue
}
var emojiFile: TelegramMediaFile?
var peer: EnginePeer?
if case let .unique(uniqueGift) = gift {
for attribute in uniqueGift.attributes {
if case let .model(_, file, _, _) = attribute {
emojiFile = file
}
}
if let themePeerId = uniqueGift.themePeerId, theme.id != component.initiallySelectedTheme?.id {
peer = self.uniqueGiftPeers[themePeerId]
}
}
let themeReference: PresentationThemeReference
let wallpaper: TelegramWallpaper?
if isDarkAppearance {
wallpaper = themeSettings.first(where: { $0.baseTheme == .night || $0.baseTheme == .tinted })?.wallpaper
themeReference = .builtin(.night)
} else {
wallpaper = themeSettings.first(where: { $0.baseTheme == .classic || $0.baseTheme == .day })?.wallpaper
themeReference = .builtin(.dayClassic)
}
entries.append(ThemeSettingsThemeEntry(
index: entries.count,
chatTheme: theme,
emojiFile: emojiFile,
themeReference: themeReference,
peer: peer,
nightMode: isDarkAppearance,
selected: selectedTheme?.id == theme.id,
theme: presentationData.theme,
strings: presentationData.strings,
wallpaper: wallpaper
))
existingIds.insert(theme.id)
}
let uniqueGiftThemesState = self.currentUniqueGiftChatThemesState
if uniqueGiftThemesState?.themes.count == 0 || uniqueGiftThemesState?.dataState == .ready(canLoadMore: false) {
for theme in self.themes {
guard let emoticon = theme.emoticon else {
continue
}
entries.append(ThemeSettingsThemeEntry(
index: entries.count,
chatTheme: .emoticon(emoticon),
emojiFile: component.animatedEmojiStickers[emoticon]?.first?.file._parse(),
themeReference: .cloud(PresentationCloudTheme(theme: theme, resolvedWallpaper: nil, creatorAccountId: nil)),
peer: nil,
nightMode: isDarkAppearance,
selected: selectedTheme?.id == ChatTheme.emoticon(emoticon).id,
theme: presentationData.theme,
strings: presentationData.strings,
wallpaper: nil
))
}
}
let action: (ChatTheme?) -> Void = { [weak self] chatTheme in
guard let self, self.selectedTheme != chatTheme else {
return
}
self.setChatTheme(chatTheme)
}
let previousEntries = self.entries ?? []
let transition = preparedTransition(context: component.context, action: action, from: previousEntries, to: entries, crossfade: crossfade)
self.enqueueTransition(transition)
let isFirstTime = self.entries == nil
self.entries = entries
if isFirstTime {
self.preloadWallpaperResources(themes: self.themes, context: component.context)
}
}
private func preloadWallpaperResources(themes: [TelegramTheme], context: AccountContext) {
for theme in themes {
if let wallpaper = theme.settings?.first?.wallpaper, case let .file(file) = wallpaper {
let account = context.account
let accountManager = context.sharedContext.accountManager
let path = accountManager.mediaBox.cachedRepresentationCompletePath(file.file.resource.id, representation: CachedPreparedPatternWallpaperRepresentation())
if !FileManager.default.fileExists(atPath: path) {
let accountFullSizeData = Signal<(Data?, Bool), NoError> { subscriber in
let accountResource = account.postbox.mediaBox.cachedResourceRepresentation(file.file.resource, representation: CachedPreparedPatternWallpaperRepresentation(), complete: false, fetch: true)
let fetchedFullSize = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: .other, userContentType: MediaResourceUserContentType(file: file.file), reference: .media(media: .standalone(media: file.file), resource: file.file.resource))
let fetchedFullSizeDisposable = fetchedFullSize.start()
let fullSizeDisposable = accountResource.start(next: { next in
subscriber.putNext((next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: []), next.complete))
if next.complete, let data = try? Data(contentsOf: URL(fileURLWithPath: next.path), options: .mappedRead) {
accountManager.mediaBox.storeCachedResourceRepresentation(file.file.resource, representation: CachedPreparedPatternWallpaperRepresentation(), data: data)
}
}, error: subscriber.putError, completed: subscriber.putCompletion)
return ActionDisposable {
fetchedFullSizeDisposable.dispose()
fullSizeDisposable.dispose()
}
}
let _ = accountFullSizeData.start()
}
}
}
}
private func enqueueTransition(_ transition: ThemeSettingsThemeItemNodeTransition) {
self.enqueuedTransitions.append(transition)
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
private func dequeueTransition() {
guard let transition = self.enqueuedTransitions.first else {
return
}
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
if self.initialized && transition.crossfade {
options.insert(.AnimateCrossfade)
}
options.insert(.Synchronous)
var scrollToItem: ListViewScrollToItem?
if !self.initialized {
if let index = transition.entries.firstIndex(where: { entry in
return entry.chatTheme?.id == self.component?.initiallySelectedTheme?.id
}) {
scrollToItem = ListViewScrollToItem(index: index, position: .bottom(-57.0), animated: false, curve: .Default(duration: 0.0), directionHint: .Down)
self.initialized = true
}
}
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: scrollToItem, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in
})
}
private func setChatTheme(_ chatTheme: ChatTheme?) {
guard let component = self.component else {
return
}
self.previewThemeChanged(chatTheme: chatTheme, dark: self.isDarkAppearance)
self.selectedTheme = chatTheme
self.rebuildEntries(crossfade: false)
let _ = ensureThemeVisible(listNode: self.listNode, themeId: chatTheme?.id, animated: true)
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: ChatThemeScreen.themeCrossfadeDuration, curve: .easeInOut)))
self.themeSelectionsCount += 1
if self.themeSelectionsCount == 2 {
self.maybePresentPreviewTooltip(component: component)
}
}
private func previewThemeChanged(chatTheme: ChatTheme?, dark: Bool?) {
self.component?.previewTheme(chatTheme, dark)
self.listNode.forEachVisibleItemNode { node in
if let node = node as? ThemeSettingsThemeItemIconNode {
node.crossfade()
}
}
}
private func closeOrBackPressed() {
if self.hasChanges() {
self.setChatTheme(self.component?.initiallySelectedTheme)
} else {
self.component?.cancel()
}
}
private func resetWallpaperPressed() {
self.component?.resetWallpaper()
self.closeOrBackPressed()
}
private func primaryPressed() {
switch self.primaryAction() {
case .chooseWallpaper:
self.component?.changeWallpaper()
case .resetTheme, .apply:
self.complete()
}
}
private func complete() {
guard let component = self.component else {
return
}
let proceed = { [weak self] in
guard let self else {
return
}
self.isCompleting = true
self.state?.updated(transition: .immediate)
component.completion(self.selectedTheme)
}
if case let .gift(gift, _) = self.selectedTheme, case let .unique(uniqueGift) = gift, let themePeerId = uniqueGift.themePeerId {
let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: themePeerId))
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let peer else {
return
}
let controller = giftThemeTransferAlertController(
context: component.context,
gift: uniqueGift,
previousPeer: peer,
commit: {
proceed()
}
)
self.component?.presentInRoot(controller)
})
} else {
proceed()
}
}
func dimTapped() {
guard let component = self.component else {
return
}
if !self.hasChanges() {
self.closeOrBackPressed()
} else {
let alertController = textAlertController(context: component.context, updatedPresentationData: (component.presentationData, .single(component.presentationData)), title: nil, text: component.presentationData.strings.Conversation_Theme_DismissAlert, actions: [TextAlertAction(type: .genericAction, title: component.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: component.presentationData.strings.Conversation_Theme_DismissAlertApply, action: { [weak self] in
self?.complete()
})], actionLayout: .horizontal, dismissOnOutsideTap: true)
component.present(alertController)
}
}
private func switchThemePressed() {
guard self.isSwitchThemeEnabled, let component = self.component else {
return
}
self.isSwitchThemeEnabled = false
Queue.mainQueue().after(0.5) { [weak self] in
guard let self else {
return
}
self.isSwitchThemeEnabled = true
self.state?.updated(transition: .immediate)
}
let isDarkAppearance = !self.isDarkAppearance
self.isDarkAppearance = isDarkAppearance
component.previewTheme(self.selectedTheme, isDarkAppearance)
self.rebuildEntries(crossfade: false)
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: ChatThemeScreen.themeCrossfadeDuration, curve: .easeInOut)))
Queue.mainQueue().justDispatch { [weak self] in
self?.switchThemePlayOnce.invoke(Void())
}
if isDarkAppearance {
let _ = ApplicationSpecificNotice.incrementChatSpecificThemeDarkPreviewTip(accountManager: component.context.sharedContext.accountManager, count: 3, timestamp: Int32(Date().timeIntervalSince1970)).startStandalone()
} else {
let _ = ApplicationSpecificNotice.incrementChatSpecificThemeLightPreviewTip(accountManager: component.context.sharedContext.accountManager, count: 3, timestamp: Int32(Date().timeIntervalSince1970)).startStandalone()
}
}
private func maybePresentPreviewTooltip(component: ChatThemeSheetContentComponent) {
guard !self.displayedPreviewTooltip, !self.animatedOut, let switchThemeButtonView = self.switchThemeButton.view else {
return
}
let frame = switchThemeButtonView.convert(switchThemeButtonView.bounds, to: self)
let currentTimestamp = Int32(Date().timeIntervalSince1970)
let isDark = component.presentationData.theme.overallDarkAppearance
let signal: Signal<(Int32, Int32), NoError>
if isDark {
signal = ApplicationSpecificNotice.getChatSpecificThemeLightPreviewTip(accountManager: component.context.sharedContext.accountManager)
} else {
signal = ApplicationSpecificNotice.getChatSpecificThemeDarkPreviewTip(accountManager: component.context.sharedContext.accountManager)
}
let _ = (signal
|> deliverOnMainQueue).startStandalone(next: { [weak self] count, timestamp in
guard let self, count < 2 && currentTimestamp > timestamp + 24 * 60 * 60 else {
return
}
self.displayedPreviewTooltip = true
component.present(TooltipScreen(account: component.context.account, sharedContext: component.context.sharedContext, text: .plain(text: isDark ? component.presentationData.strings.Conversation_Theme_PreviewLightShort : component.presentationData.strings.Conversation_Theme_PreviewDarkShort), style: .default, icon: nil, location: .point(frame.offsetBy(dx: 3.0, dy: 6.0), .bottom), displayDuration: .custom(3.0), inset: 3.0, shouldDismissOnTouch: { _, _ in
return .dismiss(consume: false)
}))
if isDark {
let _ = ApplicationSpecificNotice.incrementChatSpecificThemeLightPreviewTip(accountManager: component.context.sharedContext.accountManager, timestamp: currentTimestamp).startStandalone()
} else {
let _ = ApplicationSpecificNotice.incrementChatSpecificThemeDarkPreviewTip(accountManager: component.context.sharedContext.accountManager, timestamp: currentTimestamp).startStandalone()
}
})
}
func update(component: ChatThemeSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
let environmentValue = environment[EnvironmentType.self].value
self.environment = environmentValue
self.setupIfNeeded(component: component)
if let previousComponent, previousComponent.presentationData.theme !== component.presentationData.theme || previousComponent.presentationData.strings !== component.presentationData.strings {
self.rebuildEntries(crossfade: false)
}
let width = availableSize.width
let topInset: CGFloat = 16.0
let titleBarHeight: CGFloat = 44.0
let sideInset: CGFloat = 16.0
let buttonHeight: CGFloat = 52.0
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environmentValue.safeInsets.bottom, innerDiameter: buttonHeight, sideInset: 30.0)
let bottomInset: CGFloat = environmentValue.safeInsets.bottom.isZero ? 24.0 : environmentValue.safeInsets.bottom + 10.0
var contentHeight = topInset
let leftButtonIconName = self.hasChanges() ? "Navigation/Back" : "Navigation/Close"
let leftButtonSize = self.leftButton.update(
transition: transition,
component: AnyComponent(GlassBarButtonComponent(
size: CGSize(width: 44.0, height: 44.0),
backgroundColor: nil,
isDark: component.presentationData.theme.overallDarkAppearance,
state: .glass,
component: AnyComponentWithIdentity(id: leftButtonIconName, component: AnyComponent(
BundleIconComponent(
name: leftButtonIconName,
tintColor: component.presentationData.theme.chat.inputPanel.panelControlColor
)
)),
action: { [weak self] _ in
self?.closeOrBackPressed()
}
)),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
if let leftButtonView = self.leftButton.view {
if leftButtonView.superview == nil {
self.addSubview(leftButtonView)
}
transition.setFrame(view: leftButtonView, frame: CGRect(origin: CGPoint(x: sideInset, y: topInset), size: leftButtonSize))
}
let switchButtonSize = self.switchThemeButton.update(
transition: transition,
component: AnyComponent(GlassBarButtonComponent(
size: CGSize(width: 44.0, height: 44.0),
backgroundColor: nil,
isDark: component.presentationData.theme.overallDarkAppearance,
state: .glass,
isEnabled: self.isSwitchThemeEnabled,
component: AnyComponentWithIdentity(id: self.isDarkAppearance ? "night" : "day", component: AnyComponent(
LottieComponent(
content: LottieComponent.AppBundleContent(name: self.isDarkAppearance ? "anim_sun_reverse" : "anim_sun"),
color: component.presentationData.theme.chat.inputPanel.panelControlColor,
startingPosition: .end,
size: CGSize(width: 28.0, height: 28.0),
playOnce: self.switchThemePlayOnce
)
)),
action: { [weak self] _ in
self?.switchThemePressed()
}
)),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
if let switchButtonView = self.switchThemeButton.view {
if switchButtonView.superview == nil {
self.addSubview(switchButtonView)
}
transition.setFrame(view: switchButtonView, frame: CGRect(origin: CGPoint(x: width - sideInset - switchButtonSize.width, y: topInset), size: switchButtonSize))
}
let titleSize = self.title.update(
transition: transition,
component: AnyComponent(Text(
text: component.presentationData.strings.Conversation_Theme_Title,
font: Font.semibold(17.0),
color: component.presentationData.theme.actionSheet.primaryTextColor
)),
environment: {},
containerSize: CGSize(width: width - 120.0, height: titleBarHeight)
)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((width - titleSize.width) * 0.5), y: topInset + floorToScreenPixels((titleBarHeight - titleSize.height) * 0.5)), size: titleSize))
}
contentHeight += titleBarHeight + 8.0
let listHeight: CGFloat = 120.0
let listFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: CGSize(width: width, height: listHeight))
self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: listHeight, height: width)
self.listNode.position = CGPoint(x: listFrame.midX, y: listFrame.midY)
var listInsets = UIEdgeInsets()
listInsets.top += environmentValue.safeInsets.left + 12.0
listInsets.bottom += environmentValue.safeInsets.right + 12.0
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: CGSize(width: listHeight, height: width), insets: listInsets, duration: 0.0, curve: .Default(duration: nil)), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
contentHeight += listHeight + 12.0
let showSubtitle = self.primaryAction() != .chooseWallpaper && component.canResetWallpaper
if showSubtitle {
let subtitleSize = self.subtitle.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.presentationData.strings.Conversation_Theme_Subtitle(component.peerName).string, font: Font.regular(15.0), textColor: component.presentationData.theme.actionSheet.secondaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: width - 90.0, height: 100.0)
)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
self.addSubview(subtitleView)
}
transition.setAlpha(view: subtitleView, alpha: 1.0)
transition.setFrame(view: subtitleView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize))
}
contentHeight += subtitleSize.height + 12.0
} else if let subtitleView = self.subtitle.view {
transition.setAlpha(view: subtitleView, alpha: 0.0)
}
let primaryAction = self.primaryAction()
let accentColor = component.presentationData.theme.actionSheet.controlAccentColor
let primaryTextColor: UIColor
let primaryBackground: ButtonComponent.Background
switch primaryAction {
case .chooseWallpaper:
primaryTextColor = accentColor
primaryBackground = ButtonComponent.Background(
style: .glass,
color: accentColor.withMultipliedAlpha(0.1),
foreground: accentColor,
pressedColor: accentColor.withMultipliedAlpha(0.2),
cornerRadius: 26.0
)
case .resetTheme, .apply:
primaryTextColor = component.presentationData.theme.list.itemCheckColors.foregroundColor
primaryBackground = ButtonComponent.Background(
style: .glass,
color: component.presentationData.theme.list.itemCheckColors.fillColor,
foreground: component.presentationData.theme.list.itemCheckColors.foregroundColor,
pressedColor: component.presentationData.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 26.0
)
}
let primaryButtonSize = self.primaryButton.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: primaryBackground,
content: AnyComponentWithIdentity(
id: AnyHashable(self.primaryButtonTitle(strings: component.presentationData.strings)),
component: AnyComponent(ButtonTextContentComponent(
text: self.primaryButtonTitle(strings: component.presentationData.strings),
badge: 0,
textColor: primaryTextColor,
badgeBackground: primaryTextColor,
badgeForeground: component.presentationData.theme.list.itemCheckColors.fillColor
))
),
isEnabled: !self.isCompleting,
displaysProgress: self.isCompleting,
action: { [weak self] in
self?.primaryPressed()
}
)),
environment: {},
containerSize: CGSize(width: width - buttonInsets.left - buttonInsets.right, height: buttonHeight)
)
if let primaryButtonView = self.primaryButton.view {
if primaryButtonView.superview == nil {
self.addSubview(primaryButtonView)
}
transition.setFrame(view: primaryButtonView, frame: CGRect(origin: CGPoint(x: buttonInsets.left, y: contentHeight), size: primaryButtonSize))
}
contentHeight += primaryButtonSize.height
let showResetWallpaper = component.canResetWallpaper && primaryAction == .chooseWallpaper
if showResetWallpaper {
contentHeight += 8.0
let resetWallpaperButton: ComponentView<Empty>
if let current = self.resetWallpaperButton {
resetWallpaperButton = current
} else {
resetWallpaperButton = ComponentView()
self.resetWallpaperButton = resetWallpaperButton
}
let destructiveColor = component.presentationData.theme.actionSheet.destructiveActionTextColor
let resetButtonSize = resetWallpaperButton.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: destructiveColor.withMultipliedAlpha(0.1),
foreground: destructiveColor,
pressedColor: destructiveColor.withMultipliedAlpha(0.2)
),
content: AnyComponentWithIdentity(
id: AnyHashable("resetWallpaper"),
component: AnyComponent(ButtonTextContentComponent(
text: component.presentationData.strings.Conversation_Theme_ResetWallpaper,
badge: 0,
textColor: destructiveColor,
badgeBackground: destructiveColor,
badgeForeground: component.presentationData.theme.actionSheet.itemBackgroundColor
))
),
action: { [weak self] in
self?.resetWallpaperPressed()
}
)),
environment: {},
containerSize: CGSize(width: width - buttonInsets.left - buttonInsets.right, height: buttonHeight)
)
if let resetButtonView = resetWallpaperButton.view {
if resetButtonView.superview == nil {
self.addSubview(resetButtonView)
}
transition.setAlpha(view: resetButtonView, alpha: 1.0)
transition.setFrame(view: resetButtonView, frame: CGRect(origin: CGPoint(x: buttonInsets.left, y: contentHeight), size: resetButtonSize))
}
contentHeight += resetButtonSize.height
} else if let resetWallpaperButton = self.resetWallpaperButton {
self.resetWallpaperButton = nil
if let resetButtonView = resetWallpaperButton.view {
transition.setAlpha(view: resetButtonView, alpha: 0.0, completion: { _ in
resetButtonView.removeFromSuperview()
})
}
}
contentHeight += bottomInset
return CGSize(width: width, height: contentHeight)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class ChatThemeSheetScreenNode: ViewControllerTracingNode {
private let context: AccountContext
private var presentationData: PresentationData
private weak var controller: ChatThemeScreen?
private let animatedEmojiStickers: [String: [StickerPackItem]]
private let initiallySelectedTheme: ChatTheme?
private let peerName: String
private let hostView: ComponentHostView<ViewControllerComponentContainer.Environment>
private var containerLayout: (ContainerViewLayout, CGFloat)?
private var isDisplaying = false
private var animatedOut = false
var present: ((ViewController) -> Void)?
var previewTheme: ((ChatTheme?, Bool?) -> Void)?
var completion: ((ChatTheme?) -> Void)?
var dismiss: (() -> Void)?
var cancel: (() -> Void)?
var passthroughHitTestImpl: ((CGPoint) -> UIView?)?
init(context: AccountContext, presentationData: PresentationData, controller: ChatThemeScreen, animatedEmojiStickers: [String: [StickerPackItem]], initiallySelectedTheme: ChatTheme?, peerName: String) {
self.context = context
self.presentationData = presentationData
self.controller = controller
self.animatedEmojiStickers = animatedEmojiStickers
self.initiallySelectedTheme = initiallySelectedTheme
self.peerName = peerName
self.hostView = ComponentHostView<ViewControllerComponentContainer.Environment>()
super.init()
self.backgroundColor = nil
self.isOpaque = false
self.view.addSubview(self.hostView)
}
private func update(transition: ComponentTransition) {
guard let (layout, navigationBarHeight) = self.containerLayout else {
return
}
let environment = ViewControllerComponentContainer.Environment(
statusBarHeight: layout.statusBarHeight ?? 0.0,
navigationHeight: navigationBarHeight,
safeInsets: UIEdgeInsets(top: layout.intrinsicInsets.top + layout.safeInsets.top, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom + layout.safeInsets.bottom, right: layout.safeInsets.right),
additionalInsets: layout.additionalInsets,
inputHeight: layout.inputHeight ?? 0.0,
metrics: layout.metrics,
deviceMetrics: layout.deviceMetrics,
orientation: layout.metrics.orientation,
isVisible: self.isDisplaying,
theme: self.presentationData.theme,
strings: self.presentationData.strings,
dateTimeFormat: self.presentationData.dateTimeFormat,
controller: { [weak self] in
return self?.controller
}
)
let component = ChatThemeScreenComponent(
context: self.context,
presentationData: self.presentationData,
animatedEmojiStickers: self.animatedEmojiStickers,
initiallySelectedTheme: self.initiallySelectedTheme,
peerName: self.peerName,
canResetWallpaper: self.controller?.canResetWallpaper == true,
present: { [weak self] controller in
self?.present?(controller)
},
presentInRoot: { [weak self] controller in
self?.controller?.present(controller, in: .window(.root))
},
previewTheme: { [weak self] chatTheme, dark in
self?.previewTheme?(chatTheme, dark)
},
changeWallpaper: { [weak self] in
self?.controller?.changeWallpaper()
},
resetWallpaper: { [weak self] in
self?.controller?.resetWallpaper()
},
completion: { [weak self] chatTheme in
self?.completion?(chatTheme)
},
cancel: { [weak self] in
self?.cancel?()
}
)
let _ = self.hostView.update(
transition: transition,
component: AnyComponent(component),
environment: {
environment
},
containerSize: layout.size
)
transition.setFrame(view: self.hostView, frame: CGRect(origin: CGPoint(), size: layout.size))
}
func updatePresentationData(_ presentationData: PresentationData) {
guard !self.animatedOut else {
return
}
self.presentationData = presentationData
self.update(transition: .immediate)
}
func animateIn() {
self.isDisplaying = true
self.update(transition: ComponentTransition(animation: .none).withUserData(ViewControllerComponentContainer.AnimateInTransition()))
}
func animateOut(completion: (() -> Void)? = nil) {
self.animatedOut = true
if let rootView = self.hostView.componentView as? ChatThemeScreenComponent.View {
rootView.animateOut { [weak self] in
self?.dismiss?()
completion?()
}
} else {
self.dismiss?()
completion?()
}
}
func dimTapped() {
if let rootView = self.hostView.componentView as? ChatThemeScreenComponent.View {
rootView.dimTapped()
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (layout, navigationBarHeight)
self.update(transition: ComponentTransition(transition))
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
var presentingAlertController = false
self.controller?.forEachController({ c in
if c is AlertScreen {
presentingAlertController = true
}
return true
})
if !presentingAlertController && self.bounds.contains(point), let rootView = self.hostView.componentView as? ChatThemeScreenComponent.View, !rootView.containsContent(point: point) {
if let result = self.passthroughHitTestImpl?(point) {
return result
} else {
return nil
}
}
return super.hitTest(point, with: event)
}
}