diff --git a/Random.txt b/Random.txt index 6eb2db792a..2f0f4f794b 100644 --- a/Random.txt +++ b/Random.txt @@ -1 +1 @@ -61c0e29ede9b63175583b4609216b9c6083192c87d0e6ee0a42a5ff263b627dc +61c0e29ede9b63175583b4609216b9c6083192c87d0e6ee0a42a5ff263b627dd diff --git a/Telegram/NotificationService/BUILD b/Telegram/NotificationService/BUILD index cd676b58bd..b3f95c3da8 100644 --- a/Telegram/NotificationService/BUILD +++ b/Telegram/NotificationService/BUILD @@ -16,7 +16,10 @@ swift_library( "//submodules/AppLockState:AppLockState", "//submodules/NotificationsPresentationData:NotificationsPresentationData", "//submodules/TelegramUIPreferences:TelegramUIPreferences", - "//submodules/OpenSSLEncryptionProvider:OpenSSLEncryptionProvider" + "//submodules/OpenSSLEncryptionProvider:OpenSSLEncryptionProvider", + "//submodules/WebPBinding:WebPBinding", + "//submodules/rlottie:RLottieBinding", + "//submodules/GZip:GZip", ], visibility = [ "//visibility:public", diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index 4b3e477fa1..363c227f6c 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -6,6 +6,10 @@ import TelegramCore import BuildConfig import OpenSSLEncryptionProvider import TelegramUIPreferences +import WebPBinding +import RLottieBinding +import GZip +import UIKit private let queue = Queue() @@ -30,6 +34,249 @@ private func rootPathForBasePath(_ appGroupPath: String) -> String { return appGroupPath + "/telegram-data" } +private let deviceColorSpace: CGColorSpace = { + if #available(iOSApplicationExtension 9.3, iOS 9.3, *) { + if let colorSpace = CGColorSpace(name: CGColorSpace.displayP3) { + return colorSpace + } else { + return CGColorSpaceCreateDeviceRGB() + } + } else { + return CGColorSpaceCreateDeviceRGB() + } +}() + +private func getSharedDevideGraphicsContextSettings() -> DeviceGraphicsContextSettings { + struct OpaqueSettings { + let rowAlignment: Int + let bitsPerPixel: Int + let bitsPerComponent: Int + let opaqueBitmapInfo: CGBitmapInfo + let colorSpace: CGColorSpace + + init(context: CGContext) { + self.rowAlignment = context.bytesPerRow + self.bitsPerPixel = context.bitsPerPixel + self.bitsPerComponent = context.bitsPerComponent + self.opaqueBitmapInfo = context.bitmapInfo + if #available(iOS 10.0, *) { + if UIScreen.main.traitCollection.displayGamut == .P3 { + self.colorSpace = CGColorSpace(name: CGColorSpace.displayP3) ?? context.colorSpace! + } else { + self.colorSpace = context.colorSpace! + } + } else { + self.colorSpace = context.colorSpace! + } + assert(self.rowAlignment == 32) + assert(self.bitsPerPixel == 32) + assert(self.bitsPerComponent == 8) + } + } + + struct TransparentSettings { + let transparentBitmapInfo: CGBitmapInfo + + init(context: CGContext) { + self.transparentBitmapInfo = context.bitmapInfo + } + } + + var opaqueSettings: OpaqueSettings? + var transparentSettings: TransparentSettings? + + if #available(iOS 10.0, *) { + let opaqueFormat = UIGraphicsImageRendererFormat() + let transparentFormat = UIGraphicsImageRendererFormat() + if #available(iOS 12.0, *) { + opaqueFormat.preferredRange = .standard + transparentFormat.preferredRange = .standard + } + opaqueFormat.opaque = true + transparentFormat.opaque = false + + let opaqueRenderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)), format: opaqueFormat) + let _ = opaqueRenderer.image(actions: { context in + opaqueSettings = OpaqueSettings(context: context.cgContext) + }) + + let transparentRenderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)), format: transparentFormat) + let _ = transparentRenderer.image(actions: { context in + transparentSettings = TransparentSettings(context: context.cgContext) + }) + } else { + UIGraphicsBeginImageContextWithOptions(CGSize(width: 1.0, height: 1.0), true, 1.0) + let refContext = UIGraphicsGetCurrentContext()! + opaqueSettings = OpaqueSettings(context: refContext) + UIGraphicsEndImageContext() + + UIGraphicsBeginImageContextWithOptions(CGSize(width: 1.0, height: 1.0), false, 1.0) + let refCtxTransparent = UIGraphicsGetCurrentContext()! + transparentSettings = TransparentSettings(context: refCtxTransparent) + UIGraphicsEndImageContext() + } + + return DeviceGraphicsContextSettings( + rowAlignment: opaqueSettings!.rowAlignment, + bitsPerPixel: opaqueSettings!.bitsPerPixel, + bitsPerComponent: opaqueSettings!.bitsPerComponent, + opaqueBitmapInfo: opaqueSettings!.opaqueBitmapInfo, + transparentBitmapInfo: transparentSettings!.transparentBitmapInfo, + colorSpace: opaqueSettings!.colorSpace + ) +} + +public struct DeviceGraphicsContextSettings { + public static let shared: DeviceGraphicsContextSettings = getSharedDevideGraphicsContextSettings() + + public let rowAlignment: Int + public let bitsPerPixel: Int + public let bitsPerComponent: Int + public let opaqueBitmapInfo: CGBitmapInfo + public let transparentBitmapInfo: CGBitmapInfo + public let colorSpace: CGColorSpace + + public func bytesPerRow(forWidth width: Int) -> Int { + let baseValue = self.bitsPerPixel * width / 8 + return (baseValue + 31) & ~0x1F + } +} + +private final class DrawingContext { + let size: CGSize + let scale: CGFloat + let scaledSize: CGSize + let bytesPerRow: Int + private let bitmapInfo: CGBitmapInfo + let length: Int + let bytes: UnsafeMutableRawPointer + private let data: Data + private let context: CGContext + + private var hasGeneratedImage = false + + func withContext(_ f: (CGContext) -> ()) { + let context = self.context + + context.translateBy(x: self.size.width / 2.0, y: self.size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -self.size.width / 2.0, y: -self.size.height / 2.0) + + f(context) + + context.translateBy(x: self.size.width / 2.0, y: self.size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -self.size.width / 2.0, y: -self.size.height / 2.0) + } + + func withFlippedContext(_ f: (CGContext) -> ()) { + f(self.context) + } + + init(size: CGSize, scale: CGFloat = 1.0, opaque: Bool = false, clear: Bool = false) { + assert(!size.width.isZero && !size.height.isZero) + let size: CGSize = CGSize(width: max(1.0, size.width), height: max(1.0, size.height)) + + let actualScale: CGFloat + if scale.isZero { + actualScale = 1.0 + } else { + actualScale = scale + } + self.size = size + self.scale = actualScale + self.scaledSize = CGSize(width: size.width * actualScale, height: size.height * actualScale) + + self.bytesPerRow = DeviceGraphicsContextSettings.shared.bytesPerRow(forWidth: Int(self.scaledSize.width)) + + self.length = self.bytesPerRow * Int(self.scaledSize.height) + + self.bytes = malloc(self.length) + self.data = Data(bytesNoCopy: self.bytes, count: self.length, deallocator: .custom({ bytes, _ in + free(bytes) + })) + + if opaque { + self.bitmapInfo = DeviceGraphicsContextSettings.shared.opaqueBitmapInfo + } else { + self.bitmapInfo = DeviceGraphicsContextSettings.shared.transparentBitmapInfo + } + + self.context = CGContext( + data: self.bytes, + width: Int(self.scaledSize.width), + height: Int(self.scaledSize.height), + bitsPerComponent: 8, + bytesPerRow: self.bytesPerRow, + space: deviceColorSpace, + bitmapInfo: self.bitmapInfo.rawValue, + releaseCallback: nil, + releaseInfo: nil + )! + self.context.scaleBy(x: self.scale, y: self.scale) + + if clear { + memset(self.bytes, 0, self.length) + } + } + + func generateImage() -> UIImage? { + if self.scaledSize.width.isZero || self.scaledSize.height.isZero { + return nil + } + if self.hasGeneratedImage { + preconditionFailure() + } + self.hasGeneratedImage = true + + guard let dataProvider = CGDataProvider(data: self.data as CFData) else { + return nil + } + + if let image = CGImage( + width: Int(self.scaledSize.width), + height: Int(self.scaledSize.height), + bitsPerComponent: self.context.bitsPerComponent, + bitsPerPixel: self.context.bitsPerPixel, + bytesPerRow: self.context.bytesPerRow, + space: DeviceGraphicsContextSettings.shared.colorSpace, + bitmapInfo: self.context.bitmapInfo, + provider: dataProvider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) { + return UIImage(cgImage: image, scale: self.scale, orientation: .up) + } else { + return nil + } + } +} + +private extension CGSize { + func fitted(_ size: CGSize) -> CGSize { + var fittedSize = self + if fittedSize.width > size.width { + fittedSize = CGSize(width: size.width, height: floor((fittedSize.height * size.width / max(fittedSize.width, 1.0)))) + } + if fittedSize.height > size.height { + fittedSize = CGSize(width: floor((fittedSize.width * size.height / max(fittedSize.height, 1.0))), height: size.height) + } + return fittedSize + } +} + +private func convertLottieImage(data: Data) -> UIImage? { + let decompressedData = TGGUnzipData(data, 512 * 1024) ?? data + guard let animation = LottieInstance(data: decompressedData, cacheKey: "") else { + return nil + } + let size = animation.dimensions.fitted(CGSize(width: 200.0, height: 200.0)) + let context = DrawingContext(size: size, scale: 1.0, opaque: false, clear: true) + animation.renderFrame(with: 0, into: context.bytes.assumingMemoryBound(to: UInt8.self), width: Int32(context.scaledSize.width), height: Int32(context.scaledSize.height), bytesPerRow: Int32(context.bytesPerRow)) + return context.generateImage() +} + @available(iOSApplicationExtension 10.0, iOS 10.0, *) private struct NotificationContent { var title: String? @@ -40,31 +287,38 @@ private struct NotificationContent { var badge: Int? var category: String? var userInfo: [AnyHashable: Any] = [:] + var attachments: [UNNotificationAttachment] = [] func asNotificationContent() -> UNNotificationContent { let content = UNMutableNotificationContent() - content.title = self.title ?? "" - content.subtitle = self.subtitle ?? "" - content.body = self.body ?? "" - + if let title = self.title { + content.title = title + } + if let subtitle = self.subtitle { + content.subtitle = subtitle + } + if let body = self.body { + content.body = body + } if let threadId = self.threadId { content.threadIdentifier = threadId } - if let sound = self.sound { content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: sound)) } - if let badge = self.badge { content.badge = badge as NSNumber } - if let category = self.category { content.categoryIdentifier = category } - - content.userInfo = self.userInfo + if !self.userInfo.isEmpty { + content.userInfo = self.userInfo + } + if !self.attachments.isEmpty { + content.attachments = self.attachments + } return content } @@ -175,146 +429,441 @@ private final class NotificationServiceHandler { completed() return } - guard let aps = payloadJson["aps"] as? [String: Any] else { - completed() - return - } - - var content: NotificationContent = NotificationContent() - if let alert = aps["alert"] as? [String: Any] { - content.title = alert["title"] as? String - content.subtitle = alert["subtitle"] as? String - content.body = alert["body"] as? String - } else if let alert = aps["alert"] as? String { - content.body = alert - } else { - completed() - return - } var peerId: PeerId? var messageId: MessageId.Id? + var mediaAttachment: Media? if let messageIdString = payloadJson["msg_id"] as? String { - content.userInfo["msg_id"] = messageIdString messageId = Int32(messageIdString) } if let fromIdString = payloadJson["from_id"] as? String { - content.userInfo["from_id"] = fromIdString if let userIdValue = Int64(fromIdString) { peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(userIdValue)) } } else if let chatIdString = payloadJson["chat_id"] as? String { - content.userInfo["chat_id"] = chatIdString if let chatIdValue = Int64(chatIdString) { peerId = PeerId(namespace: Namespaces.Peer.CloudGroup, id: PeerId.Id._internalFromInt64Value(chatIdValue)) } } else if let channelIdString = payloadJson["channel_id"] as? String { - content.userInfo["channel_id"] = channelIdString if let channelIdValue = Int64(channelIdString) { peerId = PeerId(namespace: Namespaces.Peer.CloudChannel, id: PeerId.Id._internalFromInt64Value(channelIdValue)) } } - if let silentString = payloadJson["silent"] as? String { - if let silentValue = Int(silentString), silentValue != 0 { - if let title = content.title { - content.title = "\(title) 🔕" + enum Action { + case logout + case poll(peerId: PeerId, content: NotificationContent) + case deleteMessage([MessageId]) + case readMessage(MessageId) + } + + var action: Action? + + if let locKey = payloadJson["loc-key"] as? String { + switch locKey { + case "SESSION_REVOKE": + action = .logout + case "MESSAGE_MUTED": + if let peerId = peerId { + action = .poll(peerId: peerId, content: NotificationContent()) } - } - } - - if let threadId = aps["thread-id"] as? String { - content.threadId = threadId - } - - if let sound = aps["sound"] as? String { - content.sound = sound - } - - if let category = aps["category"] as? String { - content.category = category - - let _ = messageId - - /*if (peerId != 0 && messageId != 0 && parsedAttachment != nil && attachmentData != nil) { - userInfo[@"peerId"] = @(peerId); - userInfo[@"messageId.namespace"] = @(0); - userInfo[@"messageId.id"] = @(messageId); - - userInfo[@"media"] = [attachmentData base64EncodedStringWithOptions:0]; - - if (isExpandableMedia) { - if ([categoryString isEqualToString:@"r"]) { - _bestAttemptContent.categoryIdentifier = @"withReplyMedia"; - } else if ([categoryString isEqualToString:@"m"]) { - _bestAttemptContent.categoryIdentifier = @"withMuteMedia"; + case "MESSAGE_DELETED": + if let peerId = peerId { + if let messageId = messageId { + action = .deleteMessage([MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageId)]) + } else if let messageIds = payloadJson["messages"] as? String { + var messagesDeleted: [MessageId] = [] + for messageId in messageIds.split(separator: ",") { + if let messageIdValue = Int32(messageId) { + messagesDeleted.append(MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageIdValue)) + } + } + action = .deleteMessage(messagesDeleted) } } - }*/ - } - - /*if (accountInfos.accounts.count > 1) { - if (_bestAttemptContent.title.length != 0 && account.peerName.length != 0) { - _bestAttemptContent.title = [NSString stringWithFormat:@"%@ → %@", _bestAttemptContent.title, account.peerName]; - } - }*/ - - updateCurrentContent(content.asNotificationContent()) - - if let stateManager = strongSelf.stateManager, let peerId = peerId { - let pollCompletion: () -> Void = { - queue.async { - guard let strongSelf = self, let stateManager = strongSelf.stateManager else { - completed() - return - } - - let _ = (renderedTotalUnreadCount( - accountManager: strongSelf.accountManager, - postbox: stateManager.postbox - ) - |> deliverOn(strongSelf.queue)).start(next: { value in - content.badge = Int(value.0) - - updateCurrentContent(content.asNotificationContent()) - - completed() - }) - } - } - - stateManager.network.shouldKeepConnection.set(.single(true)) - if peerId.namespace == Namespaces.Peer.CloudChannel { - strongSelf.pollDisposable.set(standalonePollChannelOnce( - postbox: stateManager.postbox, - network: stateManager.network, - peerId: peerId, - stateManager: stateManager - ).start(completed: { - pollCompletion() - })) - } else { - enum ControlError { - case restart - } - let signal = stateManager.standalonePollDifference() - |> castError(ControlError.self) - |> mapToSignal { result -> Signal in - if result { - return .complete() - } else { - return .fail(.restart) + case "READ_HISTORY": + if let peerId = peerId { + if let messageIdString = payloadJson["max_id"] as? String { + if let maxId = Int32(messageIdString) { + action = .readMessage(MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: maxId)) + } } } - |> restartIfError - - strongSelf.pollDisposable.set(signal.start(completed: { - pollCompletion() - })) + default: + break } } else { + if let aps = payloadJson["aps"] as? [String: Any], let peerId = peerId { + var content: NotificationContent = NotificationContent() + if let alert = aps["alert"] as? [String: Any] { + content.title = alert["title"] as? String + content.subtitle = alert["subtitle"] as? String + content.body = alert["body"] as? String + } else if let alert = aps["alert"] as? String { + content.body = alert + } else { + completed() + return + } + + if let messageId = messageId { + content.userInfo["msg_id"] = "\(messageId)" + } + + if peerId.namespace == Namespaces.Peer.CloudUser { + content.userInfo["from_id"] = "\(peerId.id._internalGetInt64Value())" + } else if peerId.namespace == Namespaces.Peer.CloudGroup { + content.userInfo["chat_id"] = "\(peerId.id._internalGetInt64Value())" + } else if peerId.namespace == Namespaces.Peer.CloudChannel { + content.userInfo["channel_id"] = "\(peerId.id._internalGetInt64Value())" + } + + content.userInfo["peerId"] = "\(peerId.toInt64())" + content.userInfo["accountId"] = "\(record.0.int64)" + + if let silentString = payloadJson["silent"] as? String { + if let silentValue = Int(silentString), silentValue != 0 { + if let title = content.title { + content.title = "\(title) 🔕" + } + } + } + if var attachmentDataString = payloadJson["attachb64"] as? String { + attachmentDataString = attachmentDataString.replacingOccurrences(of: "-", with: "+") + attachmentDataString = attachmentDataString.replacingOccurrences(of: "_", with: "/") + while attachmentDataString.count % 4 != 0 { + attachmentDataString.append("=") + } + if let attachmentData = Data(base64Encoded: attachmentDataString) { + mediaAttachment = _internal_parseMediaAttachment(data: attachmentData) + } + } + + if let threadId = aps["thread-id"] as? String { + content.threadId = threadId + } + + if let sound = aps["sound"] as? String { + content.sound = sound + } + + if let category = aps["category"] as? String { + content.category = category + + let _ = messageId + + /*if (peerId != 0 && messageId != 0 && parsedAttachment != nil && attachmentData != nil) { + userInfo[@"peerId"] = @(peerId); + userInfo[@"messageId.namespace"] = @(0); + userInfo[@"messageId.id"] = @(messageId); + + userInfo[@"media"] = [attachmentData base64EncodedStringWithOptions:0]; + + if (isExpandableMedia) { + if ([categoryString isEqualToString:@"r"]) { + _bestAttemptContent.categoryIdentifier = @"withReplyMedia"; + } else if ([categoryString isEqualToString:@"m"]) { + _bestAttemptContent.categoryIdentifier = @"withMuteMedia"; + } + } + }*/ + } + + /*if (accountInfos.accounts.count > 1) { + if (_bestAttemptContent.title.length != 0 && account.peerName.length != 0) { + _bestAttemptContent.title = [NSString stringWithFormat:@"%@ → %@", _bestAttemptContent.title, account.peerName]; + } + }*/ + + action = .poll(peerId: peerId, content: content) + + updateCurrentContent(content.asNotificationContent()) + } + } + + if let action = action { + switch action { + case .logout: + completed() + case .poll(let peerId, var content): + if let stateManager = strongSelf.stateManager { + let pollCompletion: () -> Void = { + queue.async { + guard let strongSelf = self, let stateManager = strongSelf.stateManager else { + completed() + return + } + + var fetchMediaSignal: Signal = .single(nil) + if let mediaAttachment = mediaAttachment { + var fetchResource: TelegramMultipartFetchableResource? + if let image = mediaAttachment as? TelegramMediaImage, let representation = largestImageRepresentation(image.representations), let resource = representation.resource as? TelegramMultipartFetchableResource { + fetchResource = resource + } else if let file = mediaAttachment as? TelegramMediaFile { + if file.isSticker { + fetchResource = file.resource as? TelegramMultipartFetchableResource + } else if file.isVideo { + fetchResource = file.previewRepresentations.first?.resource as? TelegramMultipartFetchableResource + } + } + + if let resource = fetchResource { + if let _ = strongSelf.stateManager?.postbox.mediaBox.completedResourcePath(resource) { + } else { + let intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError> = .single([(0 ..< Int(Int32.max), MediaBoxFetchPriority.maximum)]) + fetchMediaSignal = Signal { subscriber in + let collectedData = Atomic(value: Data()) + return standaloneMultipartFetch( + postbox: stateManager.postbox, + network: stateManager.network, + resource: resource, + datacenterId: resource.datacenterId, + size: nil, + intervals: intervals, + parameters: MediaResourceFetchParameters( + tag: nil, + info: resourceFetchInfo(resource: resource), + isRandomAccessAllowed: true + ), + encryptionKey: nil, + decryptedSize: nil, + continueInBackground: false, + useMainConnection: true + ).start(next: { result in + switch result { + case let .dataPart(_, data, _, _): + let _ = collectedData.modify { current in + var current = current + current.append(data) + return current + } + default: + break + } + }, error: { _ in + subscriber.putNext(nil) + subscriber.putCompletion() + }, completed: { + subscriber.putNext(collectedData.with({ $0 })) + subscriber.putCompletion() + }) + } + } + } + } + + let _ = (fetchMediaSignal + |> timeout(10.0, queue: queue, alternate: .single(nil)) + |> deliverOn(queue)).start(next: { mediaData in + guard let strongSelf = self, let stateManager = strongSelf.stateManager else { + completed() + return + } + + let _ = (renderedTotalUnreadCount( + accountManager: strongSelf.accountManager, + postbox: stateManager.postbox + ) + |> deliverOn(strongSelf.queue)).start(next: { value in + guard let strongSelf = self, let stateManager = strongSelf.stateManager else { + completed() + return + } + + content.badge = Int(value.0) + + if let image = mediaAttachment as? TelegramMediaImage, let resource = largestImageRepresentation(image.representations)?.resource { + if let mediaData = mediaData { + stateManager.postbox.mediaBox.storeResourceData(resource.id, data: mediaData, synchronous: true) + } + if let storedPath = stateManager.postbox.mediaBox.completedResourcePath(resource, pathExtension: "jpg") { + if let attachment = try? UNNotificationAttachment(identifier: "image", url: URL(fileURLWithPath: storedPath), options: nil) { + content.attachments.append(attachment) + } + } + } else if let file = mediaAttachment as? TelegramMediaFile { + if file.isStaticSticker { + let resource = file.resource + + if let mediaData = mediaData { + stateManager.postbox.mediaBox.storeResourceData(resource.id, data: mediaData, synchronous: true) + } + if let storedPath = stateManager.postbox.mediaBox.completedResourcePath(resource) { + if let data = try? Data(contentsOf: URL(fileURLWithPath: storedPath)), let image = WebP.convert(fromWebP: data) { + let tempFile = TempBox.shared.tempFile(fileName: "image.png") + let _ = try? image.pngData()?.write(to: URL(fileURLWithPath: tempFile.path)) + if let attachment = try? UNNotificationAttachment(identifier: "image", url: URL(fileURLWithPath: tempFile.path), options: nil) { + content.attachments.append(attachment) + } + } + } + } else if file.isAnimatedSticker { + let resource = file.resource + + if let mediaData = mediaData { + stateManager.postbox.mediaBox.storeResourceData(resource.id, data: mediaData, synchronous: true) + } + if let storedPath = stateManager.postbox.mediaBox.completedResourcePath(resource) { + if let data = try? Data(contentsOf: URL(fileURLWithPath: storedPath)), let image = convertLottieImage(data: data) { + let tempFile = TempBox.shared.tempFile(fileName: "image.png") + let _ = try? image.pngData()?.write(to: URL(fileURLWithPath: tempFile.path)) + if let attachment = try? UNNotificationAttachment(identifier: "image", url: URL(fileURLWithPath: tempFile.path), options: nil) { + content.attachments.append(attachment) + } + } + } + } else if file.isVideo, let representation = file.previewRepresentations.first { + let resource = representation.resource + + if let mediaData = mediaData { + stateManager.postbox.mediaBox.storeResourceData(resource.id, data: mediaData, synchronous: true) + } + if let storedPath = stateManager.postbox.mediaBox.completedResourcePath(resource, pathExtension: "jpg") { + if let attachment = try? UNNotificationAttachment(identifier: "image", url: URL(fileURLWithPath: storedPath), options: nil) { + content.attachments.append(attachment) + } + } + } + } + + updateCurrentContent(content.asNotificationContent()) + + completed() + }) + }) + } + } + + let pollSignal: Signal + + stateManager.network.shouldKeepConnection.set(.single(true)) + if peerId.namespace == Namespaces.Peer.CloudChannel { + pollSignal = standalonePollChannelOnce( + postbox: stateManager.postbox, + network: stateManager.network, + peerId: peerId, + stateManager: stateManager + ) + } else { + enum ControlError { + case restart + } + let signal = stateManager.standalonePollDifference() + |> castError(ControlError.self) + |> mapToSignal { result -> Signal in + if result { + return .complete() + } else { + return .fail(.restart) + } + } + |> restartIfError + + pollSignal = signal + } + + strongSelf.pollDisposable.set(pollSignal.start(completed: { + pollCompletion() + })) + } else { + completed() + } + case let .deleteMessage(ids): + let mediaBox = stateManager.postbox.mediaBox + let _ = (stateManager.postbox.transaction { transaction -> Void in + _internal_deleteMessages(transaction: transaction, mediaBox: mediaBox, ids: ids, deleteMedia: true) + } + |> deliverOn(strongSelf.queue)).start(completed: { + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in + var removeIdentifiers: [String] = [] + for notification in notifications { + if let peerIdString = notification.request.content.userInfo["peerId"] as? String, let peerIdValue = Int64(peerIdString), let messageIdString = notification.request.content.userInfo["msg_id"] as? String, let messageIdValue = Int32(messageIdString) { + for id in ids { + if PeerId(peerIdValue) == id.peerId && messageIdValue == id.id { + removeIdentifiers.append(notification.request.identifier) + } + } + } + } + + let completeRemoval: () -> Void = { + guard let strongSelf = self else { + return + } + let _ = (renderedTotalUnreadCount( + accountManager: strongSelf.accountManager, + postbox: stateManager.postbox + ) + |> deliverOn(strongSelf.queue)).start(next: { value in + var content = NotificationContent() + content.badge = Int(value.0) + + updateCurrentContent(content.asNotificationContent()) + + completed() + }) + } + + if !removeIdentifiers.isEmpty { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: removeIdentifiers) + queue.after(1.0, { + completeRemoval() + }) + } else { + completeRemoval() + } + }) + }) + case let .readMessage(id): + let _ = (stateManager.postbox.transaction { transaction -> Void in + transaction.applyIncomingReadMaxId(id) + } + |> deliverOn(strongSelf.queue)).start(completed: { + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in + var removeIdentifiers: [String] = [] + for notification in notifications { + if let peerIdString = notification.request.content.userInfo["peerId"] as? String, let peerIdValue = Int64(peerIdString), let messageIdString = notification.request.content.userInfo["msg_id"] as? String, let messageIdValue = Int32(messageIdString) { + if PeerId(peerIdValue) == id.peerId && messageIdValue <= id.id { + removeIdentifiers.append(notification.request.identifier) + } + } + } + + let completeRemoval: () -> Void = { + guard let strongSelf = self else { + return + } + let _ = (renderedTotalUnreadCount( + accountManager: strongSelf.accountManager, + postbox: stateManager.postbox + ) + |> deliverOn(strongSelf.queue)).start(next: { value in + var content = NotificationContent() + content.badge = Int(value.0) + + updateCurrentContent(content.asNotificationContent()) + + completed() + }) + } + + if !removeIdentifiers.isEmpty { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: removeIdentifiers) + queue.after(1.0, { + completeRemoval() + }) + } else { + completeRemoval() + } + }) + }) + } + } else { + let content = NotificationContent() + updateCurrentContent(content.asNotificationContent()) + completed() } })) diff --git a/buildbox/guest-build-telegram.sh b/buildbox/guest-build-telegram.sh index bda9a5930d..07f65327d1 100644 --- a/buildbox/guest-build-telegram.sh +++ b/buildbox/guest-build-telegram.sh @@ -95,6 +95,7 @@ python3 build-system/Make/Make.py \ build \ --configurationPath="$HOME/telegram-configuration" \ --buildNumber="$BUILD_NUMBER" \ + --disableParallelSwiftmoduleGeneration \ --configuration="$APP_CONFIGURATION" OUTPUT_PATH="build/artifacts" diff --git a/submodules/GradientBackground/Sources/SoftwareGradientBackground.swift b/submodules/GradientBackground/Sources/SoftwareGradientBackground.swift index 4b4eb1ae2c..d9fa846ecf 100644 --- a/submodules/GradientBackground/Sources/SoftwareGradientBackground.swift +++ b/submodules/GradientBackground/Sources/SoftwareGradientBackground.swift @@ -191,6 +191,8 @@ public final class GradientBackgroundNode: ASDisplayNode { self.parentNode = parentNode super.init() + + self.displaysAsynchronously = false self.index = parentNode.cloneNodes.add(Weak(self)) self.image = parentNode.dimmedImage diff --git a/submodules/TelegramCore/Sources/ApiUtils/CloudFileMediaResource.swift b/submodules/TelegramCore/Sources/ApiUtils/CloudFileMediaResource.swift index 11c8209b9c..6552324071 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/CloudFileMediaResource.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/CloudFileMediaResource.swift @@ -7,7 +7,7 @@ protocol TelegramCloudMediaResource: TelegramMediaResource { func apiInputLocation(fileReference: Data?) -> Api.InputFileLocation? } -protocol TelegramMultipartFetchableResource: TelegramMediaResource { +public protocol TelegramMultipartFetchableResource: TelegramMediaResource { var datacenterId: Int { get } } diff --git a/submodules/TelegramCore/Sources/Network/MultipartFetch.swift b/submodules/TelegramCore/Sources/Network/MultipartFetch.swift index 993fb735d7..833ccdb748 100644 --- a/submodules/TelegramCore/Sources/Network/MultipartFetch.swift +++ b/submodules/TelegramCore/Sources/Network/MultipartFetch.swift @@ -83,6 +83,7 @@ private struct DownloadWrapper { let datacenterId: Int32 let isCdn: Bool let network: Network + let useMainConnection: Bool func request(_ data: (FunctionDescription, Buffer, DeserializeFunctionResponse), tag: MediaResourceFetchTag?, continueInBackground: Bool) -> Signal { let target: MultiplexedRequestTarget @@ -568,11 +569,12 @@ private final class MultipartFetchManager { let postbox: Postbox let network: Network - let revalidationContext: MediaReferenceRevalidationContext + let revalidationContext: MediaReferenceRevalidationContext? let continueInBackground: Bool let partReady: (Int, Data) -> Void let reportCompleteSize: (Int) -> Void - + + private let useMainConnection: Bool private var source: MultipartFetchSource var fetchingParts: [Int: (Int, Disposable)] = [:] @@ -591,10 +593,11 @@ private final class MultipartFetchManager { var rangesDisposable: Disposable? - init(resource: TelegramMediaResource, parameters: MediaResourceFetchParameters?, size: Int?, intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError>, encryptionKey: SecretFileEncryptionKey?, decryptedSize: Int32?, location: MultipartFetchMasterLocation, postbox: Postbox, network: Network, revalidationContext: MediaReferenceRevalidationContext, partReady: @escaping (Int, Data) -> Void, reportCompleteSize: @escaping (Int) -> Void) { + init(resource: TelegramMediaResource, parameters: MediaResourceFetchParameters?, size: Int?, intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError>, encryptionKey: SecretFileEncryptionKey?, decryptedSize: Int32?, location: MultipartFetchMasterLocation, postbox: Postbox, network: Network, revalidationContext: MediaReferenceRevalidationContext?, partReady: @escaping (Int, Data) -> Void, reportCompleteSize: @escaping (Int) -> Void, useMainConnection: Bool) { self.resource = resource self.parameters = parameters self.consumerId = Int64.random(in: Int64.min ... Int64.max) + self.useMainConnection = useMainConnection self.completeSize = size if let size = size { @@ -643,7 +646,7 @@ private final class MultipartFetchManager { self.postbox = postbox self.network = network self.revalidationContext = revalidationContext - self.source = .master(location: location, download: DownloadWrapper(consumerId: self.consumerId, datacenterId: location.datacenterId, isCdn: false, network: network)) + self.source = .master(location: location, download: DownloadWrapper(consumerId: self.consumerId, datacenterId: location.datacenterId, isCdn: false, network: network, useMainConnection: self.useMainConnection)) self.partReady = partReady self.reportCompleteSize = reportCompleteSize @@ -820,8 +823,8 @@ private final class MultipartFetchManager { case .revalidateMediaReference: if !strongSelf.revalidatingMediaReference && !strongSelf.revalidatedMediaReference { strongSelf.revalidatingMediaReference = true - if let info = strongSelf.parameters?.info as? TelegramCloudMediaResourceFetchInfo { - strongSelf.revalidateMediaReferenceDisposable.set((revalidateMediaResourceReference(postbox: strongSelf.postbox, network: strongSelf.network, revalidationContext: strongSelf.revalidationContext, info: info, resource: strongSelf.resource) + if let info = strongSelf.parameters?.info as? TelegramCloudMediaResourceFetchInfo, let revalidationContext = strongSelf.revalidationContext { + strongSelf.revalidateMediaReferenceDisposable.set((revalidateMediaResourceReference(postbox: strongSelf.postbox, network: strongSelf.network, revalidationContext: revalidationContext, info: info, resource: strongSelf.resource) |> deliverOn(strongSelf.queue)).start(next: { validationResult in if let strongSelf = self { strongSelf.revalidatingMediaReference = false @@ -847,7 +850,7 @@ private final class MultipartFetchManager { switch strongSelf.source { case let .master(location, download): strongSelf.partAlignment = Int(dataHashLength) - strongSelf.source = .cdn(masterDatacenterId: location.datacenterId, fileToken: token, key: key, iv: iv, download: DownloadWrapper(consumerId: strongSelf.consumerId, datacenterId: id, isCdn: true, network: strongSelf.network), masterDownload: download, hashSource: MultipartCdnHashSource(queue: strongSelf.queue, fileToken: token, hashes: partHashes, masterDownload: download, continueInBackground: strongSelf.continueInBackground)) + strongSelf.source = .cdn(masterDatacenterId: location.datacenterId, fileToken: token, key: key, iv: iv, download: DownloadWrapper(consumerId: strongSelf.consumerId, datacenterId: id, isCdn: true, network: strongSelf.network, useMainConnection: strongSelf.useMainConnection), masterDownload: download, hashSource: MultipartCdnHashSource(queue: strongSelf.queue, fileToken: token, hashes: partHashes, masterDownload: download, continueInBackground: strongSelf.continueInBackground)) strongSelf.checkState() case .cdn, .none: break @@ -879,7 +882,29 @@ private final class MultipartFetchManager { } } -func multipartFetch(postbox: Postbox, network: Network, mediaReferenceRevalidationContext: MediaReferenceRevalidationContext, resource: TelegramMediaResource, datacenterId: Int, size: Int?, intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError>, parameters: MediaResourceFetchParameters?, encryptionKey: SecretFileEncryptionKey? = nil, decryptedSize: Int32? = nil, continueInBackground: Bool = false) -> Signal { +public func standaloneMultipartFetch(postbox: Postbox, network: Network, resource: TelegramMediaResource, datacenterId: Int, size: Int?, intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError>, parameters: MediaResourceFetchParameters?, encryptionKey: SecretFileEncryptionKey? = nil, decryptedSize: Int32? = nil, continueInBackground: Bool = false, useMainConnection: Bool = false) -> Signal { + return multipartFetch( + postbox: postbox, + network: network, + mediaReferenceRevalidationContext: nil, + resource: resource, + datacenterId: datacenterId, + size: size, + intervals: intervals, + parameters: parameters, + useMainConnection: useMainConnection + ) +} + +public func resourceFetchInfo(resource: TelegramMediaResource) -> MediaResourceFetchInfo? { + return TelegramCloudMediaResourceFetchInfo( + reference: MediaResourceReference.standalone(resource: resource), + preferBackgroundReferenceRevalidation: false, + continueInBackground: false + ) +} + +func multipartFetch(postbox: Postbox, network: Network, mediaReferenceRevalidationContext: MediaReferenceRevalidationContext?, resource: TelegramMediaResource, datacenterId: Int, size: Int?, intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError>, parameters: MediaResourceFetchParameters?, encryptionKey: SecretFileEncryptionKey? = nil, decryptedSize: Int32? = nil, continueInBackground: Bool = false, useMainConnection: Bool = false) -> Signal { return Signal { subscriber in let location: MultipartFetchMasterLocation if let resource = resource as? WebFileReferenceMediaResource { @@ -937,7 +962,7 @@ func multipartFetch(postbox: Postbox, network: Network, mediaReferenceRevalidati }, reportCompleteSize: { size in subscriber.putNext(.resourceSizeUpdated(size)) subscriber.putCompletion() - }) + }, useMainConnection: useMainConnection) manager.start() diff --git a/submodules/TelegramCore/Sources/State/AccountStateManager.swift b/submodules/TelegramCore/Sources/State/AccountStateManager.swift index f7d93256f4..0624637366 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManager.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManager.swift @@ -195,7 +195,7 @@ public final class AccountStateManager { self.appliedQtsDisposable.dispose() var postbox: Postbox? = self.postbox - postbox?.queue.async { + postbox?.queue.after(0.5) { postbox = nil } } diff --git a/submodules/TelegramCore/Sources/State/Fetch.swift b/submodules/TelegramCore/Sources/State/Fetch.swift index 7b5bceb377..ff345f269f 100644 --- a/submodules/TelegramCore/Sources/State/Fetch.swift +++ b/submodules/TelegramCore/Sources/State/Fetch.swift @@ -22,7 +22,7 @@ private final class MediaResourceDataCopyFile : MediaResourceDataFetchCopyLocalI } } -private func fetchCloudMediaLocation(account: Account, resource: TelegramMediaResource, datacenterId: Int, size: Int?, intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError>, parameters: MediaResourceFetchParameters?) -> Signal { +public func fetchCloudMediaLocation(account: Account, resource: TelegramMediaResource, datacenterId: Int, size: Int?, intervals: Signal<[(Range, MediaBoxFetchPriority)], NoError>, parameters: MediaResourceFetchParameters?) -> Signal { return multipartFetch(postbox: account.postbox, network: account.network, mediaReferenceRevalidationContext: account.mediaReferenceRevalidationContext, resource: resource, datacenterId: datacenterId, size: size, intervals: intervals, parameters: parameters) } diff --git a/submodules/TelegramCore/Sources/State/PeerInputActivityManager.swift b/submodules/TelegramCore/Sources/State/PeerInputActivityManager.swift index 892b6f21c6..06ddddb1fc 100644 --- a/submodules/TelegramCore/Sources/State/PeerInputActivityManager.swift +++ b/submodules/TelegramCore/Sources/State/PeerInputActivityManager.swift @@ -336,6 +336,10 @@ final class PeerInputActivityManager { timeout = 8.0 } + if activity == .choosingSticker { + context.removeActivity(peerId: peerId, activity: .typingText, episodeId: nil) + } + context.addActivity(peerId: peerId, activity: activity, timeout: timeout, episodeId: episodeId, nextUpdateId: &self.nextUpdateId) if let globalContext = self.globalContext { diff --git a/submodules/TelegramCore/Sources/State/Serialization.swift b/submodules/TelegramCore/Sources/State/Serialization.swift index 06a364da91..7e955c3ef5 100644 --- a/submodules/TelegramCore/Sources/State/Serialization.swift +++ b/submodules/TelegramCore/Sources/State/Serialization.swift @@ -210,7 +210,7 @@ public class BoxedMessage: NSObject { public class Serialization: NSObject, MTSerialization { public func currentLayer() -> UInt { - return 133 + return 134 } public func parseMessage(_ data: Data!) -> Any! { diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_LoggingSettings.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_LoggingSettings.swift index 94d0711800..7b26a6324c 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_LoggingSettings.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_LoggingSettings.swift @@ -28,9 +28,9 @@ public final class LoggingSettings: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: StringCodingKey.self) - try container.encode(self.logToFile ? 1 : 0, forKey: "logToFile") - try container.encode(self.logToConsole ? 1 : 0, forKey: "logToConsole") - try container.encode(self.redactSensitiveData ? 1 : 0, forKey: "redactSensitiveData") + try container.encode((self.logToFile ? 1 : 0) as Int32, forKey: "logToFile") + try container.encode((self.logToConsole ? 1 : 0) as Int32, forKey: "logToConsole") + try container.encode((self.redactSensitiveData ? 1 : 0) as Int32, forKey: "redactSensitiveData") } public func withUpdatedLogToFile(_ logToFile: Bool) -> LoggingSettings { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift index b7416b81bd..a2883c8696 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift @@ -22,7 +22,7 @@ func addMessageMediaResourceIdsToRemove(message: Message, resourceIds: inout [Wr } } -func _internal_deleteMessages(transaction: Transaction, mediaBox: MediaBox, ids: [MessageId], deleteMedia: Bool = true, manualAddMessageThreadStatsDifference: ((MessageId, Int, Int) -> Void)? = nil) { +public func _internal_deleteMessages(transaction: Transaction, mediaBox: MediaBox, ids: [MessageId], deleteMedia: Bool = true, manualAddMessageThreadStatsDifference: ((MessageId, Int, Int) -> Void)? = nil) { var resourceIds: [WrappedMediaResourceId] = [] if deleteMedia { for id in ids { diff --git a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift index 0582f4bb05..316d17ca13 100644 --- a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift @@ -1,6 +1,6 @@ import Foundation import Postbox - +import TelegramApi public extension MessageFlags { var isSending: Bool { @@ -302,3 +302,15 @@ public extension Message { } } +public func _internal_parseMediaAttachment(data: Data) -> Media? { + guard let object = Api.parse(Buffer(buffer: MemoryBuffer(data: data))) else { + return nil + } + if let photo = object as? Api.Photo { + return telegramMediaImageFromApiPhoto(photo) + } else if let file = object as? Api.Document { + return telegramMediaFileFromApiDocument(file) + } else { + return nil + } +} diff --git a/submodules/TelegramPresentationData/Sources/PresentationTheme.swift b/submodules/TelegramPresentationData/Sources/PresentationTheme.swift index 6c795a2d9b..c0d0a57389 100644 --- a/submodules/TelegramPresentationData/Sources/PresentationTheme.swift +++ b/submodules/TelegramPresentationData/Sources/PresentationTheme.swift @@ -1270,6 +1270,7 @@ public final class PresentationTheme: Equatable { public let inAppNotification: PresentationThemeInAppNotification public let chart: PresentationThemeChart public let preview: Bool + public var forceSync: Bool = false public let resourceCache: PresentationsResourceCache = PresentationsResourceCache() diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 9502dfa126..afd513db1b 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -3889,6 +3889,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } |> deliverOnMainQueue).start(next: { [weak self] value in if let strongSelf = self { + if value { + strongSelf.context.account.updateLocalInputActivity(peerId: activitySpace, activity: .typingText, isPresent: false) + } strongSelf.context.account.updateLocalInputActivity(peerId: activitySpace, activity: .choosingSticker, isPresent: value) } }) @@ -3961,7 +3964,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } let customTheme = useDarkAppearance ? theme.darkTheme : theme.theme if let settings = customTheme.settings, let theme = makePresentationTheme(settings: settings) { + theme.forceSync = true presentationData = presentationData.withUpdated(theme: theme).withUpdated(chatWallpaper: theme.chat.defaultWallpaper) + + Queue.mainQueue().after(1.0, { + theme.forceSync = false + }) } } else if let darkAppearancePreview = darkAppearancePreview { useDarkAppearance = darkAppearancePreview @@ -3985,7 +3993,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let themeSpecificWallpaper = themeSpecificWallpaper { lightWallpaper = themeSpecificWallpaper } else { - let theme = makePresentationTheme(mediaBox: accountManager.mediaBox, themeReference: themeSettings.theme, accentColor: currentColors?.color, bubbleColors: currentColors?.customBubbleColors ?? [], wallpaper: currentColors?.wallpaper, baseColor: currentColors?.baseColor) ?? defaultPresentationTheme + let theme = makePresentationTheme(mediaBox: accountManager.mediaBox, themeReference: themeSettings.theme, accentColor: currentColors?.color, bubbleColors: currentColors?.customBubbleColors ?? [], wallpaper: currentColors?.wallpaper, baseColor: currentColors?.baseColor, preview: true) ?? defaultPresentationTheme lightWallpaper = theme.chat.defaultWallpaper } @@ -4019,8 +4027,16 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if darkAppearancePreview { + darkTheme.forceSync = true + Queue.mainQueue().after(1.0, { + darkTheme.forceSync = false + }) presentationData = presentationData.withUpdated(theme: darkTheme).withUpdated(chatWallpaper: darkWallpaper) } else { + lightTheme.forceSync = true + Queue.mainQueue().after(1.0, { + lightTheme.forceSync = false + }) presentationData = presentationData.withUpdated(theme: lightTheme).withUpdated(chatWallpaper: lightWallpaper) } } @@ -4037,7 +4053,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G strongSelf.presentationDataPromise.set(.single(strongSelf.presentationData)) if !isFirstTime && (previousThemeEmoticon?.0 != themeEmoticon || previousThemeEmoticon?.1 != useDarkAppearance) { - strongSelf.presentCrossfadeSnapshot(delay: 0.2) + strongSelf.presentCrossfadeSnapshot() } } strongSelf.presentationReady.set(.single(true)) @@ -13386,14 +13402,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } private var crossfading = false - private func presentCrossfadeSnapshot(delay: Double) { + private func presentCrossfadeSnapshot() { guard !self.crossfading, let snapshotView = self.view.snapshotView(afterScreenUpdates: false) else { return } self.crossfading = true self.view.addSubview(snapshotView) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: delay, removeOnCompletion: false, completion: { [weak self, weak snapshotView] _ in + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatThemeScreen.themeCrossfadeDuration, delay: ChatThemeScreen.themeCrossfadeDelay, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak self, weak snapshotView] _ in self?.crossfading = false snapshotView?.removeFromSuperview() }) @@ -13451,7 +13467,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let controller = ChatThemeScreen(context: context, updatedPresentationData: strongSelf.updatedPresentationData, animatedEmojiStickers: animatedEmojiStickers, initiallySelectedEmoticon: selectedEmoticon, peerName: strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer?.compactDisplayTitle ?? "", previewTheme: { [weak self] emoticon, dark in if let strongSelf = self { - strongSelf.presentCrossfadeSnapshot(delay: 0.2) + strongSelf.presentCrossfadeSnapshot() strongSelf.themeEmoticonAndDarkAppearancePreviewPromise.set(.single((emoticon, dark))) } }, completion: { [weak self] emoticon in diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 8f6ba87cd9..f3473e49d1 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -1122,6 +1122,7 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { let rawTransition = preparedChatHistoryViewTransition(from: previous, to: processedView, reason: reason, reverse: reverse, chatLocation: chatLocation, controllerInteraction: controllerInteraction, scrollPosition: updatedScrollPosition, scrollAnimationCurve: scrollAnimationCurve, initialData: initialData?.initialData, keyboardButtonsMessage: view.topTaggedMessages.first, cachedData: initialData?.cachedData, cachedDataMessages: initialData?.cachedDataMessages, readStateData: initialData?.readStateData, flashIndicators: flashIndicators, updatedMessageSelection: previousSelectedMessages != selectedMessages, messageTransitionNode: messageTransitionNode(), allUpdated: updateAllOnEachVersion) var mappedTransition = mappedChatHistoryViewListTransition(context: context, chatLocation: chatLocation, associatedData: associatedData, controllerInteraction: controllerInteraction, mode: mode, lastHeaderId: lastHeaderId, transition: rawTransition) + if disableAnimations { mappedTransition.options.remove(.AnimateInsertion) mappedTransition.options.remove(.AnimateAlpha) diff --git a/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift index 877629edfb..207f531c85 100644 --- a/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -523,7 +523,23 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } self.updateVisibility() - if let animationItems = item.associatedData.additionalAnimatedEmojiStickers[item.message.text.strippedEmoji] { + let textEmoji = item.message.text.strippedEmoji + var additionalTextEmoji = textEmoji + let (basicEmoji, fitz) = item.message.text.basicEmoji + if ["💛", "💙", "💚", "💜", "🧡", "🖤"].contains(textEmoji) { + additionalTextEmoji = "❤️".strippedEmoji + } else if fitz != nil { + additionalTextEmoji = basicEmoji + } + + var animationItems: [Int: StickerPackItem]? + if let items = item.associatedData.additionalAnimatedEmojiStickers[item.message.text.strippedEmoji] { + animationItems = items + } else if let items = item.associatedData.additionalAnimatedEmojiStickers[additionalTextEmoji] { + animationItems = items + } + + if let animationItems = animationItems { for (_, animationItem) in animationItems { self.disposables.add(freeMediaFileInteractiveFetched(account: item.context.account, fileReference: .standalone(media: animationItem.file)).start()) } @@ -1405,7 +1421,15 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { } let textEmoji = item.message.text.strippedEmoji - guard let animationItems = item.associatedData.additionalAnimatedEmojiStickers[textEmoji], index < 10, let file = animationItems[index]?.file else { + var additionalTextEmoji = textEmoji + let (basicEmoji, fitz) = item.message.text.basicEmoji + if ["💛", "💙", "💚", "💜", "🧡", "🖤"].contains(textEmoji) { + additionalTextEmoji = "❤️".strippedEmoji + } else if fitz != nil { + additionalTextEmoji = basicEmoji + } + + guard let animationItems = item.associatedData.additionalAnimatedEmojiStickers[additionalTextEmoji], index < 10, let file = animationItems[index]?.file else { return } let source = AnimatedStickerResourceSource(account: item.context.account, resource: file.resource, fitzModifier: nil) @@ -1564,11 +1588,21 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { let text = item.message.text if var firstScalar = text.unicodeScalars.first { var textEmoji = text.strippedEmoji - let originalTextEmoji = textEmoji + var additionalTextEmoji = textEmoji if beatingHearts.contains(firstScalar.value) { textEmoji = "❤️" firstScalar = UnicodeScalar(heart)! } + + let (basicEmoji, fitz) = text.basicEmoji + if ["💛", "💙", "💚", "💜", "🧡", "🖤", "❤️"].contains(textEmoji) { + additionalTextEmoji = "❤️".strippedEmoji + } else if fitz != nil { + additionalTextEmoji = basicEmoji + } + + let syncAnimations = item.message.id.peerId.namespace == Namespaces.Peer.CloudUser + return .optionalAction({ var haptic: EmojiHaptic? if let current = self.haptic { @@ -1585,8 +1619,7 @@ class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { self.haptic = haptic } - if let animationItems = item.associatedData.additionalAnimatedEmojiStickers[originalTextEmoji] { - let syncAnimations = item.message.id.peerId.namespace == Namespaces.Peer.CloudUser + if syncAnimations, let animationItems = item.associatedData.additionalAnimatedEmojiStickers[additionalTextEmoji] { let playHaptic = haptic == nil var hapticFeedback: HapticFeedback diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 2a548b593f..bb9b90e426 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -2323,6 +2323,9 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode backgroundType = .incoming(mergeType) } let hasWallpaper = item.presentationData.theme.wallpaper.hasWallpaper + if item.presentationData.theme.theme.forceSync { + transition = .immediate + } strongSelf.backgroundNode.setType(type: backgroundType, highlighted: strongSelf.highlightedState, graphics: graphics, maskMode: strongSelf.backgroundMaskMode, hasWallpaper: hasWallpaper, transition: transition, backgroundNode: presentationContext.backgroundNode) strongSelf.backgroundWallpaperNode.setType(type: backgroundType, theme: item.presentationData.theme, essentialGraphics: graphics, maskMode: strongSelf.backgroundMaskMode, backgroundNode: presentationContext.backgroundNode) strongSelf.shadowNode.setType(type: backgroundType, hasWallpaper: hasWallpaper, graphics: graphics) @@ -2375,7 +2378,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.clippingNode.addSubnode(nameNode) } nameNode.frame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY), size: nameNodeSizeApply.0) - nameNode.displaysAsynchronously = !item.presentationData.isPreview + nameNode.displaysAsynchronously = !item.presentationData.isPreview && !item.presentationData.theme.theme.forceSync if let credibilityIconImage = currentCredibilityIconImage { let credibilityIconNode: ASImageNode diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index 026435bc42..098a079fd0 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -381,7 +381,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } - strongSelf.textNode.displaysAsynchronously = !item.presentationData.isPreview + strongSelf.textNode.displaysAsynchronously = !item.presentationData.isPreview && !item.presentationData.theme.theme.forceSync let _ = textApply() if let statusApply = statusApply, let adjustedStatusFrame = adjustedStatusFrame { diff --git a/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift b/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift index 0377049044..28996fbb26 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift @@ -206,7 +206,7 @@ public final class ChatMessageTransitionNode: ASDisplayNode { func updateLayout(size: CGSize) { self.clippingNode.frame = CGRect(origin: CGPoint(), size: size) - let absoluteRect = self.itemNode.view.convert(self.itemNode.view.bounds, to: self.view) + let absoluteRect = self.itemNode.view.convert(self.itemNode.view.bounds, to: self.itemNode.supernode?.supernode?.view) self.containerNode.frame = absoluteRect } diff --git a/submodules/TelegramUI/Sources/ChatThemeScreen.swift b/submodules/TelegramUI/Sources/ChatThemeScreen.swift index 16688c4715..43f8c64dc5 100644 --- a/submodules/TelegramUI/Sources/ChatThemeScreen.swift +++ b/submodules/TelegramUI/Sources/ChatThemeScreen.swift @@ -165,8 +165,7 @@ private struct ThemeSettingsThemeItemNodeTransition { private func ensureThemeVisible(listNode: ListView, emoticon: String?, animated: Bool) -> Bool { var resultNode: ThemeSettingsThemeItemIconNode? - var previousNode: ThemeSettingsThemeItemIconNode? - let _ = previousNode +// var previousNode: ThemeSettingsThemeItemIconNode? var nextNode: ThemeSettingsThemeItemIconNode? listNode.forEachItemNode { node in guard let node = node as? ThemeSettingsThemeItemIconNode else { @@ -176,7 +175,7 @@ private func ensureThemeVisible(listNode: ListView, emoticon: String?, animated: if node.item?.emoticon == emoticon { resultNode = node } else { - previousNode = node +// previousNode = node } } else if nextNode == nil { nextNode = node @@ -284,9 +283,11 @@ private final class ThemeSettingsThemeItemIconNode : ListViewItemNode { 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() @@ -496,7 +497,7 @@ private final class ThemeSettingsThemeItemIconNode : ListViewItemNode { snapshotView.frame = self.containerNode.view.frame self.view.insertSubview(snapshotView, aboveSubview: self.containerNode.view) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in + 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() }) } @@ -522,6 +523,9 @@ private final class ThemeSettingsThemeItemIconNode : ListViewItemNode { } final class ChatThemeScreen: ViewController { + static let themeCrossfadeDuration: Double = 0.3 + static let themeCrossfadeDelay: Double = 0.25 + private var controllerNode: ChatThemeScreenNode { return self.displayNode as! ChatThemeScreenNode } @@ -840,7 +844,7 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelega let action: (String?) -> Void = { [weak self] emoticon in if let strongSelf = self, strongSelf.selectedEmoticon != emoticon { - strongSelf.animateCrossfade(animateIcon: false) + strongSelf.animateCrossfade(animateIcon: true) strongSelf.previewTheme?(emoticon, strongSelf.isDarkAppearance) strongSelf.selectedEmoticon = emoticon @@ -964,7 +968,7 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelega self.doneButton.updateTheme(SolidRoundedButtonTheme(theme: self.presentationData.theme)) if self.animationNode.isPlaying { - if let animationNode = self.animationNode.makeCopy(colors: iconColors(theme: self.presentationData.theme), progress: 0.25) { + if let animationNode = self.animationNode.makeCopy(colors: iconColors(theme: self.presentationData.theme), progress: 0.2) { let previousAnimationNode = self.animationNode self.animationNode = animationNode @@ -974,7 +978,7 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelega animationNode.isUserInteractionEnabled = false animationNode.frame = previousAnimationNode.frame previousAnimationNode.supernode?.insertSubnode(animationNode, belowSubnode: previousAnimationNode) - previousAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + previousAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatThemeScreen.themeCrossfadeDuration, removeOnCompletion: false) animationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } else { @@ -1010,6 +1014,11 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelega } @objc func switchThemePressed() { + self.switchThemeButton.isUserInteractionEnabled = false + Queue.mainQueue().after(0.5) { + self.switchThemeButton.isUserInteractionEnabled = true + } + self.animateCrossfade(animateIcon: false) self.animationNode.setAnimation(name: self.isDarkAppearance ? "anim_sun_reverse" : "anim_sun", colors: iconColors(theme: self.presentationData.theme)) self.animationNode.playOnce() @@ -1025,21 +1034,19 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelega } } - private func animateCrossfade(animateIcon: Bool = true) { - let delay: Double = 0.2 - + private func animateCrossfade(animateIcon: Bool) { if animateIcon, let snapshotView = self.animationNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.frame = self.animationNode.frame self.animationNode.view.superview?.insertSubview(snapshotView, aboveSubview: self.animationNode.view) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: delay, removeOnCompletion: false, completion: { [weak snapshotView] _ in + 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() }) } - Queue.mainQueue().after(delay) { + Queue.mainQueue().after(ChatThemeScreen.themeCrossfadeDelay) { if let effectView = self.effectNode.view as? UIVisualEffectView { - UIView.animate(withDuration: 0.3, delay: 0.0, options: .curveEaseInOut) { + UIView.animate(withDuration: ChatThemeScreen.themeCrossfadeDuration, delay: 0.0, options: .curveLinear) { effectView.effect = UIBlurEffect(style: self.presentationData.theme.actionSheet.backgroundType == .light ? .light : .dark) } completion: { _ in } @@ -1047,14 +1054,14 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelega let previousColor = self.contentBackgroundNode.backgroundColor ?? .clear self.contentBackgroundNode.backgroundColor = self.presentationData.theme.actionSheet.itemBackgroundColor - self.contentBackgroundNode.layer.animate(from: previousColor.cgColor, to: (self.contentBackgroundNode.backgroundColor ?? .clear).cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.3) + self.contentBackgroundNode.layer.animate(from: previousColor.cgColor, to: (self.contentBackgroundNode.backgroundColor ?? .clear).cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: ChatThemeScreen.themeCrossfadeDuration) } if let snapshotView = self.contentContainerNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.frame = self.contentContainerNode.frame self.contentContainerNode.view.superview?.insertSubview(snapshotView, aboveSubview: self.contentContainerNode.view) - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, delay: delay, removeOnCompletion: false, completion: { [weak snapshotView] _ in + 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() }) } @@ -1068,8 +1075,6 @@ private class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelega private var animatedOut = false func animateIn() { - self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) - let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY let dimPosition = self.dimNode.layer.position diff --git a/submodules/TelegramUI/Sources/ChatThemeScreen.swift.orig b/submodules/TelegramUI/Sources/ChatThemeScreen.swift.orig new file mode 100644 index 0000000000..e3816ed48b --- /dev/null +++ b/submodules/TelegramUI/Sources/ChatThemeScreen.swift.orig @@ -0,0 +1,1238 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit +import AccountContext +import SolidRoundedButtonNode +import TelegramPresentationData +import TelegramUIPreferences +import TelegramNotices +import PresentationDataUtils +import AnimationUI +import MergeLists +import MediaResources +import StickerResources +import WallpaperResources +import TooltipUI +import AnimatedStickerNode +import TelegramAnimatedStickerNode +import ShimmerEffect + +private func closeButtonImage(theme: PresentationTheme) -> UIImage? { + return generateImage(CGSize(width: 30.0, height: 30.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setFillColor(UIColor(rgb: 0x808084, alpha: 0.1).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + context.setLineWidth(2.0) + context.setLineCap(.round) + context.setStrokeColor(theme.actionSheet.inputClearButtonColor.cgColor) + + context.move(to: CGPoint(x: 10.0, y: 10.0)) + context.addLine(to: CGPoint(x: 20.0, y: 20.0)) + context.strokePath() + + context.move(to: CGPoint(x: 20.0, y: 10.0)) + context.addLine(to: CGPoint(x: 10.0, y: 20.0)) + context.strokePath() + }) +} + +private struct ThemeSettingsThemeEntry: Comparable, Identifiable { + let index: Int + let emoticon: String? + let emojiFile: TelegramMediaFile? + let themeReference: PresentationThemeReference? + var selected: Bool + let theme: PresentationTheme + let strings: PresentationStrings + let wallpaper: TelegramWallpaper? + + var stableId: Int { + return index + } + + static func ==(lhs: ThemeSettingsThemeEntry, rhs: ThemeSettingsThemeEntry) -> Bool { + if lhs.index != rhs.index { + return false + } + if lhs.emoticon != rhs.emoticon { + return false + } + + if lhs.themeReference?.index != rhs.themeReference?.index { + 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 (String?) -> Void) -> ListViewItem { + return ThemeSettingsThemeIconItem(context: context, emoticon: self.emoticon, emojiFile: self.emojiFile, themeReference: self.themeReference, selected: self.selected, theme: self.theme, strings: self.strings, wallpaper: self.wallpaper, action: action) + } +} + + +private class ThemeSettingsThemeIconItem: ListViewItem { + let context: AccountContext + let emoticon: String? + let emojiFile: TelegramMediaFile? + let themeReference: PresentationThemeReference? + let selected: Bool + let theme: PresentationTheme + let strings: PresentationStrings + let wallpaper: TelegramWallpaper? + let action: (String?) -> Void + + public init(context: AccountContext, emoticon: String?, emojiFile: TelegramMediaFile?, themeReference: PresentationThemeReference?, selected: Bool, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper?, action: @escaping (String?) -> Void) { + self.context = context + self.emoticon = emoticon + self.emojiFile = emojiFile + self.themeReference = themeReference + 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?, (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.emoticon) + } +} + +private struct ThemeSettingsThemeItemNodeTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let crossfade: Bool + let entries: [ThemeSettingsThemeEntry] +} + +private func ensureThemeVisible(listNode: ListView, emoticon: String?, animated: Bool) -> Bool { + var resultNode: ThemeSettingsThemeItemIconNode? +<<<<<<< HEAD + var previousNode: ThemeSettingsThemeItemIconNode? + let _ = previousNode +======= +// var previousNode: ThemeSettingsThemeItemIconNode? +>>>>>>> beta + var nextNode: ThemeSettingsThemeItemIconNode? + listNode.forEachItemNode { node in + guard let node = node as? ThemeSettingsThemeItemIconNode else { + return + } + if resultNode == nil { + if node.item?.emoticon == emoticon { + resultNode = node + } else { +// previousNode = node + } + } else if nextNode == nil { + nextNode = node + } + } + if let resultNode = resultNode { + listNode.ensureItemNodeVisible(resultNode, animated: animated, overflow: 57.0) + return true + } else { + return false + } +} + +private func preparedTransition(context: AccountContext, action: @escaping (String?) -> 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: 18.0, height: 18.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: 9, topCapHeight: 9) + 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 + 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 = 8.0 + self.imageNode.clipsToBounds = true + + 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, dynamicBounce: 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() + + 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 + + if currentItem?.emoticon != item.emoticon { + 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 + } + + 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 = item.emoticon + let title = NSAttributedString(string: emoticon != nil ? "" : "❌", 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 { + 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, emoticon: true)) + strongSelf.imageNode.backgroundColor = nil + } + } + if item.themeReference == nil { + strongSelf.imageNode.backgroundColor = item.theme.actionSheet.opaqueItemBackgroundColor + } + + 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 = item.emoticon != nil + + 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 = AnimatedStickerNode() + animatedStickerNode.started = { [weak self] in + self?.emojiImageNode.isHidden = true + } + strongSelf.animatedStickerNode = animatedStickerNode + strongSelf.emojiContainerNode.insertSubnode(animatedStickerNode, belowSubnode: strongSelf.placeholderNode) + let pathPrefix = item.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(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(fetchedMediaResource(mediaBox: item.context.account.postbox.mediaBox, reference: MediaResourceReference.media(media: .standalone(media: file), resource: file.resource)).start()) + + 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, imageSize: thumbnailDimensions.cgSize) + strongSelf.placeholderNode.frame = emojiFrame + } + + if let animatedStickerNode = strongSelf.animatedStickerNode { + animatedStickerNode.frame = emojiFrame + animatedStickerNode.updateLayout(size: emojiFrame.size) + } + } + }) + } + } + + 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, short: Bool) { + super.animateInsertion(currentTimestamp, duration: duration, short: short) + + 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) + } +} + +final class ChatThemeScreen: ViewController { + static let themeCrossfadeDuration: Double = 0.3 + static let themeCrossfadeDelay: Double = 0.25 + + private var controllerNode: ChatThemeScreenNode { + return self.displayNode as! ChatThemeScreenNode + } + + private var animatedIn = false + + private let context: AccountContext + private let animatedEmojiStickers: [String: [StickerPackItem]] + private let initiallySelectedEmoticon: String? + private let peerName: String + private let previewTheme: (String?, Bool?) -> Void + private let completion: (String?) -> Void + + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + var dismissed: (() -> Void)? + + var passthroughHitTestImpl: ((CGPoint) -> UIView?)? { + didSet { + if self.isNodeLoaded { + self.controllerNode.passthroughHitTestImpl = self.passthroughHitTestImpl + } + } + } + + init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal), animatedEmojiStickers: [String: [StickerPackItem]], initiallySelectedEmoticon: String?, peerName: String, previewTheme: @escaping (String?, Bool?) -> Void, completion: @escaping (String?) -> Void) { + self.context = context + self.presentationData = updatedPresentationData.initial + self.animatedEmojiStickers = animatedEmojiStickers + self.initiallySelectedEmoticon = initiallySelectedEmoticon + self.peerName = peerName + self.previewTheme = previewTheme + self.completion = completion + + super.init(navigationBarPresentationData: nil) + + self.statusBar.statusBarStyle = .Ignore + + self.blocksBackgroundWhenInOverlay = true + + self.presentationDataDisposable = (updatedPresentationData.signal + |> deliverOnMainQueue).start(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 = ChatThemeScreenNode(context: self.context, presentationData: self.presentationData, controller: self, animatedEmojiStickers: self.animatedEmojiStickers, initiallySelectedEmoticon: self.initiallySelectedEmoticon, peerName: self.peerName) + self.controllerNode.passthroughHitTestImpl = self.passthroughHitTestImpl + self.controllerNode.previewTheme = { [weak self] emoticon, dark in + guard let strongSelf = self else { + return + } + strongSelf.previewTheme((emoticon ?? ""), dark) + } + self.controllerNode.present = { [weak self] c in + self?.present(c, in: .current) + } + self.controllerNode.completion = { [weak self] emoticon in + guard let strongSelf = self else { + return + } + strongSelf.dismiss() + if strongSelf.initiallySelectedEmoticon == nil && emoticon == nil { + } else { + strongSelf.completion(emoticon) + } + } + self.controllerNode.dismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: false, completion: nil) + } + self.controllerNode.cancel = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.dismiss() + 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(completion: (() -> Void)? = nil) { + self.forEachController({ controller in + if let controller = controller as? TooltipScreen { + controller.dismiss() + } + return true + }) + + self.controllerNode.animateOut(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) + } + + 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 class ChatThemeScreenNode: ViewControllerTracingNode, UIScrollViewDelegate { + private let context: AccountContext + private var presentationData: PresentationData + private weak var controller: ChatThemeScreen? + + private let dimNode: ASDisplayNode + private let wrappingScrollNode: ASScrollNode + private let contentContainerNode: ASDisplayNode + private let topContentContainerNode: SparseNode + private let effectNode: ASDisplayNode + private let backgroundNode: ASDisplayNode + private let contentBackgroundNode: ASDisplayNode + private let titleNode: ASTextNode + private let textNode: ImmediateTextNode + private let cancelButton: HighlightableButtonNode + private let switchThemeButton: HighlightTrackingButtonNode + private let animationContainerNode: ASDisplayNode + private var animationNode: AnimationNode + private let doneButton: SolidRoundedButtonNode + + private let listNode: ListView + private var entries: [ThemeSettingsThemeEntry]? + private var enqueuedTransitions: [ThemeSettingsThemeItemNodeTransition] = [] + private var initialized = false + + private let peerName: String + + private let initiallySelectedEmoticon: String? + private var selectedEmoticon: String? { + didSet { + self.selectedEmoticonPromise.set(self.selectedEmoticon) + } + } + private var selectedEmoticonPromise: ValuePromise + + private var isDarkAppearancePromise: ValuePromise + private var isDarkAppearance: Bool = false { + didSet { + self.isDarkAppearancePromise.set(self.isDarkAppearance) + } + } + + private var containerLayout: (ContainerViewLayout, CGFloat)? + + private let disposable = MetaDisposable() + + var present: ((ViewController) -> Void)? + var previewTheme: ((String?, Bool?) -> Void)? + var completion: ((String?) -> Void)? + var dismiss: (() -> Void)? + var cancel: (() -> Void)? + + init(context: AccountContext, presentationData: PresentationData, controller: ChatThemeScreen, animatedEmojiStickers: [String: [StickerPackItem]], initiallySelectedEmoticon: String?, peerName: String) { + self.context = context + self.controller = controller + self.initiallySelectedEmoticon = initiallySelectedEmoticon + self.peerName = peerName + self.selectedEmoticon = initiallySelectedEmoticon + self.selectedEmoticonPromise = ValuePromise(initiallySelectedEmoticon) + self.presentationData = presentationData + + self.wrappingScrollNode = ASScrollNode() + self.wrappingScrollNode.view.alwaysBounceVertical = true + self.wrappingScrollNode.view.delaysContentTouches = false + self.wrappingScrollNode.view.canCancelContentTouches = true + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = .clear + + self.contentContainerNode = ASDisplayNode() + self.contentContainerNode.isOpaque = false + + self.topContentContainerNode = SparseNode() + self.topContentContainerNode.isOpaque = false + + self.backgroundNode = ASDisplayNode() + self.backgroundNode.clipsToBounds = true + self.backgroundNode.cornerRadius = 16.0 + + self.isDarkAppearance = self.presentationData.theme.overallDarkAppearance + self.isDarkAppearancePromise = ValuePromise(self.presentationData.theme.overallDarkAppearance) + + let backgroundColor = self.presentationData.theme.actionSheet.itemBackgroundColor + let textColor = self.presentationData.theme.actionSheet.primaryTextColor + let secondaryTextColor = self.presentationData.theme.actionSheet.secondaryTextColor + let blurStyle: UIBlurEffect.Style = self.presentationData.theme.actionSheet.backgroundType == .light ? .light : .dark + + self.effectNode = ASDisplayNode(viewBlock: { + return UIVisualEffectView(effect: UIBlurEffect(style: blurStyle)) + }) + + self.contentBackgroundNode = ASDisplayNode() + self.contentBackgroundNode.backgroundColor = backgroundColor + + self.titleNode = ASTextNode() + self.titleNode.attributedText = NSAttributedString(string: self.presentationData.strings.Conversation_Theme_Title, font: Font.semibold(16.0), textColor: textColor) + + self.textNode = ImmediateTextNode() + self.textNode.attributedText = NSAttributedString(string: self.presentationData.strings.Conversation_Theme_Subtitle(peerName).string, font: Font.regular(12.0), textColor: secondaryTextColor) + + self.cancelButton = HighlightableButtonNode() + self.cancelButton.setImage(closeButtonImage(theme: self.presentationData.theme), for: .normal) + + self.switchThemeButton = HighlightTrackingButtonNode() + self.animationContainerNode = ASDisplayNode() + self.animationContainerNode.isUserInteractionEnabled = false + + self.animationNode = AnimationNode(animation: self.isDarkAppearance ? "anim_sun_reverse" : "anim_sun", colors: iconColors(theme: self.presentationData.theme), scale: 1.0) + self.animationNode.isUserInteractionEnabled = false + + self.doneButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(theme: self.presentationData.theme), height: 52.0, cornerRadius: 11.0, gloss: false) + self.doneButton.title = initiallySelectedEmoticon == nil ? self.presentationData.strings.Conversation_Theme_DontSetTheme : self.presentationData.strings.Conversation_Theme_Apply + + self.listNode = ListView() + self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + + super.init() + + self.backgroundColor = nil + self.isOpaque = false + + self.addSubnode(self.dimNode) + + self.wrappingScrollNode.view.delegate = self + self.addSubnode(self.wrappingScrollNode) + + self.wrappingScrollNode.addSubnode(self.backgroundNode) + self.wrappingScrollNode.addSubnode(self.contentContainerNode) + self.wrappingScrollNode.addSubnode(self.topContentContainerNode) + + self.backgroundNode.addSubnode(self.effectNode) + self.backgroundNode.addSubnode(self.contentBackgroundNode) + self.contentContainerNode.addSubnode(self.titleNode) + self.contentContainerNode.addSubnode(self.textNode) + self.contentContainerNode.addSubnode(self.doneButton) + + self.topContentContainerNode.addSubnode(self.animationContainerNode) + self.animationContainerNode.addSubnode(self.animationNode) + self.topContentContainerNode.addSubnode(self.switchThemeButton) + self.topContentContainerNode.addSubnode(self.listNode) + self.topContentContainerNode.addSubnode(self.cancelButton) + + self.switchThemeButton.addTarget(self, action: #selector(self.switchThemePressed), forControlEvents: .touchUpInside) + self.cancelButton.addTarget(self, action: #selector(self.cancelButtonPressed), forControlEvents: .touchUpInside) + self.doneButton.pressed = { [weak self] in + if let strongSelf = self { + strongSelf.doneButton.isUserInteractionEnabled = false + strongSelf.completion?(strongSelf.selectedEmoticon) + } + } + + self.disposable.set(combineLatest(queue: Queue.mainQueue(), self.context.engine.themes.getChatThemes(accountManager: self.context.sharedContext.accountManager), self.selectedEmoticonPromise.get(), self.isDarkAppearancePromise.get()).start(next: { [weak self] themes, selectedEmoticon, isDarkAppearance in + guard let strongSelf = self else { + return + } + + let isFirstTime = strongSelf.entries == nil + let presentationData = strongSelf.presentationData + + var entries: [ThemeSettingsThemeEntry] = [] + entries.append(ThemeSettingsThemeEntry(index: 0, emoticon: nil, emojiFile: nil, themeReference: nil, selected: selectedEmoticon == nil, theme: presentationData.theme, strings: presentationData.strings, wallpaper: nil)) + for theme in themes { + let emoticon = theme.emoji + entries.append(ThemeSettingsThemeEntry(index: entries.count, emoticon: theme.emoji, emojiFile: animatedEmojiStickers[emoticon]?.first?.file, themeReference: .cloud(PresentationCloudTheme(theme: isDarkAppearance ? theme.darkTheme : theme.theme, resolvedWallpaper: nil, creatorAccountId: nil)), selected: selectedEmoticon == theme.emoji, theme: presentationData.theme, strings: presentationData.strings, wallpaper: nil)) + } + + let action: (String?) -> Void = { [weak self] emoticon in + if let strongSelf = self, strongSelf.selectedEmoticon != emoticon { + strongSelf.animateCrossfade(animateIcon: true) + + strongSelf.previewTheme?(emoticon, strongSelf.isDarkAppearance) + strongSelf.selectedEmoticon = emoticon + let _ = ensureThemeVisible(listNode: strongSelf.listNode, emoticon: emoticon, animated: true) + + let doneButtonTitle: String + if emoticon == nil { + doneButtonTitle = strongSelf.initiallySelectedEmoticon == nil ? strongSelf.presentationData.strings.Conversation_Theme_DontSetTheme : strongSelf.presentationData.strings.Conversation_Theme_Reset + } else { + doneButtonTitle = strongSelf.presentationData.strings.Conversation_Theme_Apply + } + strongSelf.doneButton.title = doneButtonTitle + + strongSelf.themeSelectionsCount += 1 + if strongSelf.themeSelectionsCount == 2 { + strongSelf.maybePresentPreviewTooltip() + } + } + } + let previousEntries = strongSelf.entries ?? [] + let crossfade = previousEntries.count != entries.count + let transition = preparedTransition(context: strongSelf.context, action: action, from: previousEntries, to: entries, crossfade: crossfade) + strongSelf.enqueueTransition(transition) + + strongSelf.entries = entries + + if isFirstTime { + for theme in themes { + if let wallpaper = theme.theme.settings?.wallpaper, case let .file(file) = wallpaper { + let account = strongSelf.context.account + let accountManager = strongSelf.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, 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() + } + } + } + } + })) + + self.switchThemeButton.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.animationNode.layer.removeAnimation(forKey: "opacity") + strongSelf.animationNode.alpha = 0.4 + } else { + strongSelf.animationNode.alpha = 1.0 + strongSelf.animationNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + } + + 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.emoticon == self.initiallySelectedEmoticon + }) { + 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 + }) + } + + func updatePresentationData(_ presentationData: PresentationData) { + guard !self.animatedOut else { + return + } + let previousTheme = self.presentationData.theme + self.presentationData = presentationData + + self.titleNode.attributedText = NSAttributedString(string: self.titleNode.attributedText?.string ?? "", font: Font.semibold(16.0), textColor: self.presentationData.theme.actionSheet.primaryTextColor) + self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: Font.regular(12.0), textColor: self.presentationData.theme.actionSheet.secondaryTextColor) + + if previousTheme !== presentationData.theme, let (layout, navigationBarHeight) = self.containerLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) + } + + self.cancelButton.setImage(closeButtonImage(theme: self.presentationData.theme), for: .normal) + self.doneButton.updateTheme(SolidRoundedButtonTheme(theme: self.presentationData.theme)) + + if self.animationNode.isPlaying { + if let animationNode = self.animationNode.makeCopy(colors: iconColors(theme: self.presentationData.theme), progress: 0.2) { + let previousAnimationNode = self.animationNode + self.animationNode = animationNode + + animationNode.completion = { [weak previousAnimationNode] in + previousAnimationNode?.removeFromSupernode() + } + animationNode.isUserInteractionEnabled = false + animationNode.frame = previousAnimationNode.frame + previousAnimationNode.supernode?.insertSubnode(animationNode, belowSubnode: previousAnimationNode) + previousAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: ChatThemeScreen.themeCrossfadeDuration, removeOnCompletion: false) + animationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } else { + self.animationNode.setAnimation(name: self.isDarkAppearance ? "anim_sun_reverse" : "anim_sun", colors: iconColors(theme: self.presentationData.theme)) + } + } + + override func didLoad() { + super.didLoad() + + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never + } + + self.listNode.view.disablesInteractiveTransitionGestureRecognizer = true + } + + @objc func cancelButtonPressed() { + self.cancel?() + } + + func dimTapped() { + if self.selectedEmoticon == self.initiallySelectedEmoticon { + self.cancelButtonPressed() + } else { + let alertController = textAlertController(context: self.context, updatedPresentationData: (self.presentationData, .single(self.presentationData)), title: nil, text: self.presentationData.strings.Conversation_Theme_DismissAlert, actions: [TextAlertAction(type: .genericAction, title: self.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Conversation_Theme_DismissAlertApply, action: { [weak self] in + if let strongSelf = self { + strongSelf.completion?(strongSelf.selectedEmoticon) + } + })], actionLayout: .horizontal, dismissOnOutsideTap: true) + self.present?(alertController) + } + } + + @objc func switchThemePressed() { + self.switchThemeButton.isUserInteractionEnabled = false + Queue.mainQueue().after(0.5) { + self.switchThemeButton.isUserInteractionEnabled = true + } + + self.animateCrossfade(animateIcon: false) + self.animationNode.setAnimation(name: self.isDarkAppearance ? "anim_sun_reverse" : "anim_sun", colors: iconColors(theme: self.presentationData.theme)) + self.animationNode.playOnce() + + let isDarkAppearance = !self.isDarkAppearance + self.previewTheme?(self.selectedEmoticon, isDarkAppearance) + self.isDarkAppearance = isDarkAppearance + + if isDarkAppearance { + let _ = ApplicationSpecificNotice.incrementChatSpecificThemeDarkPreviewTip(accountManager: self.context.sharedContext.accountManager, count: 3, timestamp: Int32(Date().timeIntervalSince1970)).start() + } else { + let _ = ApplicationSpecificNotice.incrementChatSpecificThemeLightPreviewTip(accountManager: self.context.sharedContext.accountManager, count: 3, timestamp: Int32(Date().timeIntervalSince1970)).start() + } + } + + private func animateCrossfade(animateIcon: Bool) { + if animateIcon, let snapshotView = self.animationNode.view.snapshotView(afterScreenUpdates: false) { + snapshotView.frame = self.animationNode.frame + self.animationNode.view.superview?.insertSubview(snapshotView, aboveSubview: self.animationNode.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() + }) + } + + Queue.mainQueue().after(ChatThemeScreen.themeCrossfadeDelay) { + if let effectView = self.effectNode.view as? UIVisualEffectView { + UIView.animate(withDuration: ChatThemeScreen.themeCrossfadeDuration, delay: 0.0, options: .curveLinear) { + effectView.effect = UIBlurEffect(style: self.presentationData.theme.actionSheet.backgroundType == .light ? .light : .dark) + } completion: { _ in + } + } + + let previousColor = self.contentBackgroundNode.backgroundColor ?? .clear + self.contentBackgroundNode.backgroundColor = self.presentationData.theme.actionSheet.itemBackgroundColor + self.contentBackgroundNode.layer.animate(from: previousColor.cgColor, to: (self.contentBackgroundNode.backgroundColor ?? .clear).cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: ChatThemeScreen.themeCrossfadeDuration) + } + + if let snapshotView = self.contentContainerNode.view.snapshotView(afterScreenUpdates: false) { + snapshotView.frame = self.contentContainerNode.frame + self.contentContainerNode.view.superview?.insertSubview(snapshotView, aboveSubview: self.contentContainerNode.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() + }) + } + + self.listNode.forEachVisibleItemNode { node in + if let node = node as? ThemeSettingsThemeItemIconNode { + node.crossfade() + } + } + } + + private var animatedOut = false + func animateIn() { + let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY + let dimPosition = self.dimNode.layer.position + + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + let targetBounds = self.bounds + self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset) + self.dimNode.position = CGPoint(x: dimPosition.x, y: dimPosition.y - offset) + transition.animateView({ + self.bounds = targetBounds + self.dimNode.position = dimPosition + }) + } + + private var themeSelectionsCount = 0 + private var displayedPreviewTooltip = false + private func maybePresentPreviewTooltip() { + guard !self.displayedPreviewTooltip, !self.animatedOut else { + return + } + + let frame = self.switchThemeButton.view.convert(self.switchThemeButton.bounds, to: self.view) + let currentTimestamp = Int32(Date().timeIntervalSince1970) + + let isDark = self.presentationData.theme.overallDarkAppearance + + let signal: Signal<(Int32, Int32), NoError> + if isDark { + signal = ApplicationSpecificNotice.getChatSpecificThemeLightPreviewTip(accountManager: self.context.sharedContext.accountManager) + } else { + signal = ApplicationSpecificNotice.getChatSpecificThemeDarkPreviewTip(accountManager: self.context.sharedContext.accountManager) + } + + let _ = (signal + |> deliverOnMainQueue).start(next: { [weak self] count, timestamp in + if let strongSelf = self, count < 2 && currentTimestamp > timestamp + 24 * 60 * 60 { + strongSelf.displayedPreviewTooltip = true + + strongSelf.present?(TooltipScreen(account: strongSelf.context.account, text: isDark ? strongSelf.presentationData.strings.Conversation_Theme_PreviewLight(strongSelf.peerName).string : strongSelf.presentationData.strings.Conversation_Theme_PreviewDark(strongSelf.peerName).string, 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: strongSelf.context.sharedContext.accountManager, timestamp: currentTimestamp).start() + } else { + let _ = ApplicationSpecificNotice.incrementChatSpecificThemeDarkPreviewTip(accountManager: strongSelf.context.sharedContext.accountManager, timestamp: currentTimestamp).start() + } + } + }) + } + + func animateOut(completion: (() -> Void)? = nil) { + self.animatedOut = true + + let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY + self.wrappingScrollNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.dismiss?() + completion?() + } + }) + } + + var passthroughHitTestImpl: ((CGPoint) -> UIView?)? + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var presentingAlertController = false + self.controller?.forEachController({ c in + if c is AlertController { + presentingAlertController = true + } + return true + }) + + if !presentingAlertController && self.bounds.contains(point) { + if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) { + if let result = self.passthroughHitTestImpl?(point) { + return result + } else { + return nil + } + } + } + return super.hitTest(point, with: event) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + let contentOffset = scrollView.contentOffset + let additionalTopHeight = max(0.0, -contentOffset.y) + + if additionalTopHeight >= 30.0 { + self.cancelButtonPressed() + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = (layout, navigationBarHeight) + + var insets = layout.insets(options: [.statusBar, .input]) + let cleanInsets = layout.insets(options: [.statusBar]) + insets.top = max(10.0, insets.top) + + let bottomInset: CGFloat = 10.0 + cleanInsets.bottom + let titleHeight: CGFloat = 54.0 + let contentHeight = titleHeight + bottomInset + 188.0 + + let width = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: layout.safeInsets.left) + + let sideInset = floor((layout.size.width - width) / 2.0) + let contentContainerFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentHeight), size: CGSize(width: width, height: contentHeight)) + let contentFrame = contentContainerFrame + + var backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: contentFrame.width, height: contentFrame.height + 2000.0)) + if backgroundFrame.minY < contentFrame.minY { + backgroundFrame.origin.y = contentFrame.minY + } + transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) + transition.updateFrame(node: self.effectNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) + transition.updateFrame(node: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) + transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + + let titleSize = self.titleNode.measure(CGSize(width: width - 90.0, height: titleHeight)) + let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 11.0 + UIScreenPixel), size: titleSize) + transition.updateFrame(node: self.titleNode, frame: titleFrame) + + let textSize = self.textNode.updateLayout(CGSize(width: width - 90.0, height: titleHeight)) + let textFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - textSize.width) / 2.0), y: 31.0), size: textSize) + transition.updateFrame(node: self.textNode, frame: textFrame) + + let switchThemeSize = CGSize(width: 44.0, height: 44.0) + let switchThemeFrame = CGRect(origin: CGPoint(x: 3.0, y: 6.0), size: switchThemeSize) + transition.updateFrame(node: self.switchThemeButton, frame: switchThemeFrame) + transition.updateFrame(node: self.animationContainerNode, frame: switchThemeFrame.insetBy(dx: 9.0, dy: 9.0)) + transition.updateFrame(node: self.animationNode, frame: CGRect(origin: CGPoint(), size: self.animationContainerNode.frame.size)) + + let cancelSize = CGSize(width: 44.0, height: 44.0) + let cancelFrame = CGRect(origin: CGPoint(x: contentFrame.width - cancelSize.width - 3.0, y: 6.0), size: cancelSize) + transition.updateFrame(node: self.cancelButton, frame: cancelFrame) + + let buttonInset: CGFloat = 16.0 + let doneButtonHeight = self.doneButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition) + transition.updateFrame(node: self.doneButton, frame: CGRect(x: buttonInset, y: contentHeight - doneButtonHeight - insets.bottom - 6.0, width: contentFrame.width, height: doneButtonHeight)) + + transition.updateFrame(node: self.contentContainerNode, frame: contentContainerFrame) + transition.updateFrame(node: self.topContentContainerNode, frame: contentContainerFrame) + + var listInsets = UIEdgeInsets() + listInsets.top += layout.safeInsets.left + 12.0 + listInsets.bottom += layout.safeInsets.right + 12.0 + + let contentSize = CGSize(width: contentFrame.width, height: 120.0) + + self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: contentSize.height, height: contentSize.width) + self.listNode.position = CGPoint(x: contentSize.width / 2.0, y: contentSize.height / 2.0 + titleHeight + 6.0) + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: CGSize(width: contentSize.height, height: contentSize.width), insets: listInsets, duration: 0.0, curve: .Default(duration: nil)), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } +} diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index fe41936552..833832ba8a 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -3477,16 +3477,19 @@ private final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewD } var mainItemsImpl: (() -> Signal<[ContextMenuItem], NoError>)? - mainItemsImpl = { + mainItemsImpl = { [weak self] in var items: [ContextMenuItem] = [] + guard let strongSelf = self else { + return .single(items) + } - let allHeaderButtons = Set(peerInfoHeaderButtons(peer: peer, cachedData: data.cachedData, isOpenedFromChat: self.isOpenedFromChat, isExpanded: false, videoCallsEnabled: self.videoCallsEnabled, isSecretChat: self.peerId.namespace == Namespaces.Peer.SecretChat, isContact: self.data?.isContact ?? false)) - let headerButtons = Set(peerInfoHeaderButtons(peer: peer, cachedData: data.cachedData, isOpenedFromChat: self.isOpenedFromChat, isExpanded: self.headerNode.isAvatarExpanded, videoCallsEnabled: self.videoCallsEnabled, isSecretChat: self.peerId.namespace == Namespaces.Peer.SecretChat, isContact: self.data?.isContact ?? false)) + let allHeaderButtons = Set(peerInfoHeaderButtons(peer: peer, cachedData: data.cachedData, isOpenedFromChat: strongSelf.isOpenedFromChat, isExpanded: false, videoCallsEnabled: strongSelf.videoCallsEnabled, isSecretChat: strongSelf.peerId.namespace == Namespaces.Peer.SecretChat, isContact: strongSelf.data?.isContact ?? false)) + let headerButtons = Set(peerInfoHeaderButtons(peer: peer, cachedData: data.cachedData, isOpenedFromChat: strongSelf.isOpenedFromChat, isExpanded: strongSelf.headerNode.isAvatarExpanded, videoCallsEnabled: strongSelf.videoCallsEnabled, isSecretChat: strongSelf.peerId.namespace == Namespaces.Peer.SecretChat, isContact: strongSelf.data?.isContact ?? false)) let filteredButtons = allHeaderButtons.subtracting(headerButtons) var canChangeColors = false - if peer is TelegramUser, self.data?.encryptionKeyFingerprint == nil { + if peer is TelegramUser, strongSelf.data?.encryptionKeyFingerprint == nil { canChangeColors = true } @@ -3626,7 +3629,7 @@ private final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewD }))) } - if self.peerId.namespace == Namespaces.Peer.CloudUser && user.botInfo == nil && !user.flags.contains(.isSupport) { + if strongSelf.peerId.namespace == Namespaces.Peer.CloudUser && user.botInfo == nil && !user.flags.contains(.isSupport) { items.append(.action(ContextMenuActionItem(text: presentationData.strings.UserInfo_StartSecretChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Timer"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in @@ -3646,7 +3649,7 @@ private final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewD }))) } } - } else if self.peerId.namespace == Namespaces.Peer.SecretChat && data.isContact { + } else if strongSelf.peerId.namespace == Namespaces.Peer.SecretChat && data.isContact { if let cachedData = data.cachedData as? CachedUserData, cachedData.isBlocked { } else { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_BlockUser, icon: { theme in @@ -3659,7 +3662,7 @@ private final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewD } } } else if let channel = peer as? TelegramChannel { - if let cachedData = self.data?.cachedData as? CachedChannelData, cachedData.flags.contains(.canViewStats) { + if let cachedData = strongSelf.data?.cachedData as? CachedChannelData, cachedData.flags.contains(.canViewStats) { items.append(.action(ContextMenuActionItem(text: presentationData.strings.ChannelInfo_Stats, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Statistics"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in diff --git a/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift b/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift index 985d1b2095..5afc0c891d 100644 --- a/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift +++ b/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift @@ -65,6 +65,7 @@ public final class WallpaperBackgroundNode: ASDisplayNode { self.bubbleType = bubbleType self.contentNode = ASImageNode() + self.contentNode.displaysAsynchronously = false self.contentNode.isUserInteractionEnabled = false super.init() @@ -165,6 +166,7 @@ public final class WallpaperBackgroundNode: ASDisplayNode { if needsWallpaperBackground { if self.cleanWallpaperNode == nil { let cleanWallpaperNode = ASImageNode() + cleanWallpaperNode.displaysAsynchronously = false self.cleanWallpaperNode = cleanWallpaperNode cleanWallpaperNode.frame = self.bounds self.insertSubnode(cleanWallpaperNode, at: 0)