diff --git a/submodules/AvatarNode/Sources/PeerAvatar.swift b/submodules/AvatarNode/Sources/PeerAvatar.swift index 66393e4a84..44e193df27 100644 --- a/submodules/AvatarNode/Sources/PeerAvatar.swift +++ b/submodules/AvatarNode/Sources/PeerAvatar.swift @@ -65,11 +65,11 @@ public func peerAvatarImageData(account: Account, peerReference: PeerReference?, }) var fetchedDataDisposable: Disposable? if let peerReference = peerReference { - fetchedDataDisposable = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: .other, userContentType: .image, reference: .avatar(peer: peerReference, resource: smallProfileImage.resource), statsCategory: .generic).start() + fetchedDataDisposable = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: .other, userContentType: .avatar, reference: .avatar(peer: peerReference, resource: smallProfileImage.resource), statsCategory: .generic).start() } else if let authorOfMessage = authorOfMessage { - fetchedDataDisposable = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: .other, userContentType: .image, reference: .messageAuthorAvatar(message: authorOfMessage, resource: smallProfileImage.resource), statsCategory: .generic).start() + fetchedDataDisposable = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: .other, userContentType: .avatar, reference: .messageAuthorAvatar(message: authorOfMessage, resource: smallProfileImage.resource), statsCategory: .generic).start() } else { - fetchedDataDisposable = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: .other, userContentType: .image, reference: .standalone(resource: smallProfileImage.resource), statsCategory: .generic).start() + fetchedDataDisposable = fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: .other, userContentType: .avatar, reference: .standalone(resource: smallProfileImage.resource), statsCategory: .generic).start() } return ActionDisposable { resourceDataDisposable.dispose() diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index c253dda8ef..efcbe348be 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -605,7 +605,7 @@ public struct Transition { public func animateBounds(layer: CALayer, from fromValue: CGRect, to toValue: CGRect, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { switch self.animation { case .none: - break + completion?(true) case let .curve(duration, curve): layer.animate( from: NSValue(cgRect: fromValue), @@ -624,7 +624,7 @@ public struct Transition { public func animateBoundsOrigin(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { switch self.animation { case .none: - break + completion?(true) case let .curve(duration, curve): layer.animate( from: NSValue(cgPoint: fromValue), @@ -643,7 +643,7 @@ public struct Transition { public func animateBoundsSize(layer: CALayer, from fromValue: CGSize, to toValue: CGSize, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { switch self.animation { case .none: - break + completion?(true) case let .curve(duration, curve): layer.animate( from: NSValue(cgSize: fromValue), @@ -661,6 +661,7 @@ public struct Transition { public func setCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)? = nil) { if layer.cornerRadius == cornerRadius { + completion?(true) return } switch self.animation { @@ -693,8 +694,9 @@ public struct Transition { switch self.animation { case .none: layer.path = path + completion?(true) case let .curve(duration, curve): - if let previousPath = layer.path { + if let previousPath = layer.path, previousPath != path { layer.animate( from: previousPath, to: path, @@ -709,6 +711,7 @@ public struct Transition { layer.path = path } else { layer.path = path + completion?(true) } } } @@ -717,6 +720,7 @@ public struct Transition { switch self.animation { case .none: layer.lineWidth = lineWidth + completion?(true) case let .curve(duration, curve): let previousLineWidth = layer.lineWidth layer.lineWidth = lineWidth @@ -739,6 +743,7 @@ public struct Transition { switch self.animation { case .none: layer.lineDashPattern = pattern + completion?(true) case let .curve(duration, curve): if let previousLineDashPattern = layer.lineDashPattern { layer.lineDashPattern = pattern @@ -756,6 +761,7 @@ public struct Transition { ) } else { layer.lineDashPattern = pattern + completion?(true) } } } diff --git a/submodules/ComponentFlow/Source/Host/ComponentHostView.swift b/submodules/ComponentFlow/Source/Host/ComponentHostView.swift index ec1ae334f0..0315e6392f 100644 --- a/submodules/ComponentFlow/Source/Host/ComponentHostView.swift +++ b/submodules/ComponentFlow/Source/Host/ComponentHostView.swift @@ -141,6 +141,7 @@ public final class ComponentView { private var currentSize: CGSize? public private(set) var view: UIView? private(set) var isUpdating: Bool = false + public weak var parentState: ComponentState? public init() { } @@ -181,10 +182,15 @@ public final class ComponentView { context.erasedEnvironment = environmentResult } - let isEnvironmentUpdated = context.erasedEnvironment.calculateIsUpdated() - + var isStateUpdated = false + if componentState.isUpdated { + isStateUpdated = true + componentState.isUpdated = false + } - if !forceUpdate, !isEnvironmentUpdated, let currentComponent = self.currentComponent, let currentContainerSize = self.currentContainerSize, let currentSize = self.currentSize { + let isEnvironmentUpdated = context.erasedEnvironment.calculateIsUpdated() + + if !forceUpdate, !isEnvironmentUpdated, !isStateUpdated, let currentComponent = self.currentComponent, let currentContainerSize = self.currentContainerSize, let currentSize = self.currentSize { if currentContainerSize == containerSize && currentComponent == component { self.isUpdating = false return currentSize @@ -197,9 +203,13 @@ public final class ComponentView { guard let strongSelf = self else { return } - let _ = strongSelf._update(transition: transition, component: component, maybeEnvironment: { - preconditionFailure() - } as () -> Environment, updateEnvironment: false, forceUpdate: true, containerSize: containerSize) + if let parentState = strongSelf.parentState { + parentState.updated(transition: transition) + } else { + let _ = strongSelf._update(transition: transition, component: component, maybeEnvironment: { + preconditionFailure() + } as () -> Environment, updateEnvironment: false, forceUpdate: true, containerSize: containerSize) + } } let updatedSize = component._update(view: componentView, availableSize: containerSize, environment: context.erasedEnvironment, transition: transition) @@ -207,6 +217,9 @@ public final class ComponentView { if isEnvironmentUpdated { context.erasedEnvironment._isUpdated = false } + if isStateUpdated { + context.erasedState.isUpdated = false + } self.isUpdating = false diff --git a/submodules/Components/ReactionImageComponent/Sources/ReactionImageComponent.swift b/submodules/Components/ReactionImageComponent/Sources/ReactionImageComponent.swift index db634206d5..5f4efc9730 100644 --- a/submodules/Components/ReactionImageComponent/Sources/ReactionImageComponent.swift +++ b/submodules/Components/ReactionImageComponent/Sources/ReactionImageComponent.swift @@ -39,7 +39,7 @@ public func reactionStaticImage(context: AccountContext, animation: TelegramMedi } } - let fetchFrame = animationCacheFetchFile(context: context, userLocation: .other, userContentType: .emoji, resource: MediaResourceReference.standalone(resource: animation.resource), type: type, keyframeOnly: true, customColor: customColor) + let fetchFrame = animationCacheFetchFile(context: context, userLocation: .other, userContentType: .sticker, resource: MediaResourceReference.standalone(resource: animation.resource), type: type, keyframeOnly: true, customColor: customColor) class AnimationCacheItemWriterImpl: AnimationCacheItemWriter { let queue: Queue diff --git a/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift b/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift index 1064e74520..247a6d276f 100644 --- a/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift +++ b/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift @@ -9,6 +9,7 @@ public final class SolidRoundedButtonComponent: Component { public typealias Theme = SolidRoundedButtonTheme public let title: String? + public let label: String? public let icon: UIImage? public let theme: SolidRoundedButtonTheme public let font: SolidRoundedButtonFont @@ -16,6 +17,7 @@ public final class SolidRoundedButtonComponent: Component { public let height: CGFloat public let cornerRadius: CGFloat public let gloss: Bool + public let isEnabled: Bool public let iconName: String? public let animationName: String? public let iconPosition: SolidRoundedButtonIconPosition @@ -25,6 +27,7 @@ public final class SolidRoundedButtonComponent: Component { public init( title: String? = nil, + label: String? = nil, icon: UIImage? = nil, theme: SolidRoundedButtonTheme, font: SolidRoundedButtonFont = .bold, @@ -32,6 +35,7 @@ public final class SolidRoundedButtonComponent: Component { height: CGFloat = 48.0, cornerRadius: CGFloat = 24.0, gloss: Bool = false, + isEnabled: Bool = true, iconName: String? = nil, animationName: String? = nil, iconPosition: SolidRoundedButtonIconPosition = .left, @@ -40,6 +44,7 @@ public final class SolidRoundedButtonComponent: Component { action: @escaping () -> Void ) { self.title = title + self.label = label self.icon = icon self.theme = theme self.font = font @@ -47,6 +52,7 @@ public final class SolidRoundedButtonComponent: Component { self.height = height self.cornerRadius = cornerRadius self.gloss = gloss + self.isEnabled = isEnabled self.iconName = iconName self.animationName = animationName self.iconPosition = iconPosition @@ -59,6 +65,9 @@ public final class SolidRoundedButtonComponent: Component { if lhs.title != rhs.title { return false } + if lhs.label != rhs.label { + return false + } if lhs.icon !== rhs.icon { return false } @@ -80,6 +89,9 @@ public final class SolidRoundedButtonComponent: Component { if lhs.gloss != rhs.gloss { return false } + if lhs.isEnabled != rhs.isEnabled { + return false + } if lhs.iconName != rhs.iconName { return false } @@ -108,6 +120,7 @@ public final class SolidRoundedButtonComponent: Component { if self.button == nil { let button = SolidRoundedButtonView( title: component.title, + label: component.label, icon: component.icon, theme: component.theme, font: component.font, @@ -127,12 +140,15 @@ public final class SolidRoundedButtonComponent: Component { if let button = self.button { button.title = component.title + button.label = component.label button.iconPosition = component.iconPosition button.iconSpacing = component.iconSpacing button.icon = component.iconName.flatMap { UIImage(bundleImageName: $0) } button.animation = component.animationName button.gloss = component.gloss + button.isEnabled = component.isEnabled + button.updateTheme(component.theme) let height = button.updateLayout(width: availableSize.width, transition: .immediate) transition.setFrame(view: button, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height)), completion: nil) diff --git a/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift b/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift index 6111fff551..d07869b9fc 100644 --- a/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift +++ b/submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift @@ -250,6 +250,10 @@ private final class FetchManagerCategoryContext { userContentType = .image case .video: userContentType = .video + case .audio: + userContentType = .audio + case .file: + userContentType = .file default: userContentType = .other } @@ -376,6 +380,10 @@ private final class FetchManagerCategoryContext { userContentType = .image case .video: userContentType = .video + case .audio: + userContentType = .audio + case .file: + userContentType = .file default: userContentType = .other } @@ -407,6 +415,10 @@ private final class FetchManagerCategoryContext { userContentType = .image case .video: userContentType = .video + case .audio: + userContentType = .audio + case .file: + userContentType = .file default: userContentType = .other } diff --git a/submodules/MtProtoKit/PublicHeaders/MtProtoKit/MTProto.h b/submodules/MtProtoKit/PublicHeaders/MtProtoKit/MTProto.h index 990b94fba6..b8bcd8f79c 100644 --- a/submodules/MtProtoKit/PublicHeaders/MtProtoKit/MTProto.h +++ b/submodules/MtProtoKit/PublicHeaders/MtProtoKit/MTProto.h @@ -77,3 +77,11 @@ + (NSData *)_manuallyEncryptedMessage:(NSData *)preparedData messageId:(int64_t)messageId authKey:(MTDatacenterAuthKey *)authKey; @end + +//#define DIRSTAT_FAST_ONLY 0x1 +struct darwin_dirstat { + off_t total_size; + uint64_t descendants; +}; + +int dirstat_np(const char *path, int flags, struct darwin_dirstat *ds, size_t dirstat_size); diff --git a/submodules/Postbox/Sources/MediaBox.swift b/submodules/Postbox/Sources/MediaBox.swift index 495a31a679..ebbd34fe22 100644 --- a/submodules/Postbox/Sources/MediaBox.swift +++ b/submodules/Postbox/Sources/MediaBox.swift @@ -561,7 +561,7 @@ public final class MediaBox { paths.partial, paths.partial + ".meta" ]) - if let fileContext = MediaBoxFileContext(queue: self.dataQueue, manager: self.dataFileManager, path: paths.complete, partialPath: paths.partial, metaPath: paths.partial + ".meta") { + if let fileContext = MediaBoxFileContext(queue: self.dataQueue, manager: self.dataFileManager, storageBox: self.storageBox, resourceId: id.stringRepresentation.data(using: .utf8)!, path: paths.complete, partialPath: paths.partial, metaPath: paths.partial + ".meta") { context = fileContext self.fileContexts[resourceId] = fileContext } else { @@ -598,7 +598,7 @@ public final class MediaBox { return } - if let location = parameters?.location { + if let parameters = parameters, let location = parameters.location { var messageNamespace: Int32 = 0 var messageIdValue: Int32 = 0 if let messageId = location.messageId { @@ -606,7 +606,9 @@ public final class MediaBox { messageIdValue = messageId.id } - self.storageBox.add(reference: StorageBox.Reference(peerId: location.peerId.toInt64(), messageNamespace: UInt8(clamping: messageNamespace), messageId: messageIdValue), to: resource.id.stringRepresentation.data(using: .utf8)!) + self.storageBox.add(reference: StorageBox.Reference(peerId: location.peerId.toInt64(), messageNamespace: UInt8(clamping: messageNamespace), messageId: messageIdValue), to: resource.id.stringRepresentation.data(using: .utf8)!, contentType: parameters.contentType.rawValue) + } else { + self.storageBox.add(reference: StorageBox.Reference(peerId: 0, messageNamespace: 0, messageId: 0), to: resource.id.stringRepresentation.data(using: .utf8)!, contentType: parameters?.contentType.rawValue ?? 0) } guard let (fileContext, releaseContext) = self.fileContext(for: resource.id) else { @@ -771,7 +773,7 @@ public final class MediaBox { self.dataQueue.async { let paths = self.storePathsForId(resource.id) - if let location = parameters?.location { + if let parameters = parameters, let location = parameters.location { var messageNamespace: Int32 = 0 var messageIdValue: Int32 = 0 if let messageId = location.messageId { @@ -779,7 +781,9 @@ public final class MediaBox { messageIdValue = messageId.id } - self.storageBox.add(reference: StorageBox.Reference(peerId: location.peerId.toInt64(), messageNamespace: UInt8(clamping: messageNamespace), messageId: messageIdValue), to: resource.id.stringRepresentation.data(using: .utf8)!) + self.storageBox.add(reference: StorageBox.Reference(peerId: location.peerId.toInt64(), messageNamespace: UInt8(clamping: messageNamespace), messageId: messageIdValue), to: resource.id.stringRepresentation.data(using: .utf8)!, contentType: parameters.contentType.rawValue) + } else { + self.storageBox.add(reference: StorageBox.Reference(peerId: 0, messageNamespace: 0, messageId: 0), to: resource.id.stringRepresentation.data(using: .utf8)!, contentType: parameters?.contentType.rawValue ?? 0) } if let _ = fileSize(paths.complete) { @@ -1289,9 +1293,17 @@ public final class MediaBox { return } - storageBox.addEmptyReferencesIfNotReferenced(ids: results.map { name -> Data in - return MediaBox.idForFileName(name: name).data(using: .utf8)! - }, completion: { addedCount in + storageBox.addEmptyReferencesIfNotReferenced(ids: results.map { name -> (id: Data, size: Int64) in + let resourceId = MediaBox.idForFileName(name: name) + let paths = self.storePathsForId(MediaResourceId(resourceId)) + var size: Int64 = 0 + if let value = fileSize(paths.complete) { + size = value + } else if let value = fileSize(paths.partial) { + size = value + } + return (resourceId.data(using: .utf8)!, size) + }, contentType: MediaResourceUserContentType.other.rawValue, completion: { addedCount in if addedCount != 0 { postboxLog("UpdateResourceIndex: added \(addedCount) unreferenced ids") } diff --git a/submodules/Postbox/Sources/MediaBoxFile.swift b/submodules/Postbox/Sources/MediaBoxFile.swift index e05d2e2e01..686e2dd444 100644 --- a/submodules/Postbox/Sources/MediaBoxFile.swift +++ b/submodules/Postbox/Sources/MediaBoxFile.swift @@ -460,6 +460,8 @@ private class MediaBoxPartialFileDataRequest { final class MediaBoxPartialFile { private let queue: Queue private let manager: MediaBoxFileManager + private let storageBox: StorageBox + private let resourceId: Data private let path: String private let metaPath: String private let completePath: String @@ -476,9 +478,12 @@ final class MediaBoxPartialFile { private var currentFetch: (Promise<[(Range, MediaBoxFetchPriority)]>, Disposable)? private var processedAtLeastOneFetch: Bool = false - init?(queue: Queue, manager: MediaBoxFileManager, path: String, metaPath: String, completePath: String, completed: @escaping (Int64) -> Void) { + init?(queue: Queue, manager: MediaBoxFileManager, storageBox: StorageBox, resourceId: Data, path: String, metaPath: String, completePath: String, completed: @escaping (Int64) -> Void) { assert(queue.isCurrent()) self.manager = manager + self.storageBox = storageBox + self.resourceId = resourceId + if let fd = manager.open(path: path, mode: .readwrite) { self.queue = queue self.path = path @@ -504,6 +509,7 @@ final class MediaBoxPartialFile { } else { self.fileMap = MediaBoxFileMap() } + self.storageBox.update(id: self.resourceId, size: self.fileMap.sum) self.missingRanges = MediaBoxFileMissingRanges() } else { return nil @@ -586,6 +592,8 @@ final class MediaBoxPartialFile { } self.statusRequests.removeAll() + self.storageBox.update(id: self.resourceId, size: self.fileMap.sum) + self.completed(self.fileMap.sum) } else { assertionFailure() @@ -629,6 +637,8 @@ final class MediaBoxPartialFile { } self.statusRequests.removeAll() + self.storageBox.update(id: self.resourceId, size: size) + self.completed(size) } else { assertionFailure() @@ -675,6 +685,8 @@ final class MediaBoxPartialFile { self.fileMap.fill(range) self.fileMap.serialize(manager: self.manager, to: self.metaPath) + self.storageBox.update(id: self.resourceId, size: self.fileMap.sum) + self.checkDataRequestsAfterFill(range: range) } @@ -1184,7 +1196,7 @@ final class MediaBoxFileContext { return self.references.isEmpty } - init?(queue: Queue, manager: MediaBoxFileManager, path: String, partialPath: String, metaPath: String) { + init?(queue: Queue, manager: MediaBoxFileManager, storageBox: StorageBox, resourceId: Data, path: String, partialPath: String, metaPath: String) { assert(queue.isCurrent()) self.queue = queue @@ -1195,7 +1207,7 @@ final class MediaBoxFileContext { var completeImpl: ((Int64) -> Void)? if let size = fileSize(path) { self.content = .complete(path, size) - } else if let file = MediaBoxPartialFile(queue: queue, manager: manager, path: partialPath, metaPath: metaPath, completePath: path, completed: { size in + } else if let file = MediaBoxPartialFile(queue: queue, manager: manager, storageBox: storageBox, resourceId: resourceId, path: partialPath, metaPath: metaPath, completePath: path, completed: { size in completeImpl?(size) }) { self.content = .partial(file) diff --git a/submodules/Postbox/Sources/MediaResource.swift b/submodules/Postbox/Sources/MediaResource.swift index 70eca992a6..dcf3bcc7e8 100644 --- a/submodules/Postbox/Sources/MediaResource.swift +++ b/submodules/Postbox/Sources/MediaResource.swift @@ -50,14 +50,14 @@ public final class MediaResourceStorageLocation { } public enum MediaResourceUserContentType: UInt8, Equatable { - case image = 0 - case video = 1 - case audio = 2 - case file = 3 - case gif = 4 - case emoji = 5 + case other = 0 + case image = 1 + case video = 2 + case audio = 3 + case file = 4 + case gif = 5 case sticker = 6 - case other = 7 + case avatar = 7 } public struct MediaResourceFetchParameters { diff --git a/submodules/Postbox/Sources/StorageBox/StorageBox.swift b/submodules/Postbox/Sources/StorageBox/StorageBox.swift index 74cfa1267a..e26e15fe3e 100644 --- a/submodules/Postbox/Sources/StorageBox/StorageBox.swift +++ b/submodules/Postbox/Sources/StorageBox/StorageBox.swift @@ -19,6 +19,14 @@ private func md5Hash(_ data: Data) -> HashId { } public final class StorageBox { + public struct Stats { + public var contentTypes: [UInt8: Int64] + + public init(contentTypes: [UInt8: Int64]) { + self.contentTypes = contentTypes + } + } + public struct Reference { public var peerId: Int64 public var messageNamespace: UInt8 @@ -53,15 +61,78 @@ public final class StorageBox { } } + private struct ItemInfo { + var id: Data + var contentType: UInt8 + var size: Int64 + + init(id: Data, contentType: UInt8, size: Int64) { + self.id = id + self.contentType = contentType + self.size = size + } + + init(buffer: MemoryBuffer) { + var id = Data() + var contentType: UInt8 = 0 + var size: Int64 = 0 + + withExtendedLifetime(buffer, { + let readBuffer = ReadBuffer(memoryBufferNoCopy: buffer) + var version: UInt8 = 0 + readBuffer.read(&version, offset: 0, length: 1) + let _ = version + + var idLength: UInt16 = 0 + readBuffer.read(&idLength, offset: 0, length: 2) + id.count = Int(idLength) + id.withUnsafeMutableBytes { buffer -> Void in + readBuffer.read(buffer.baseAddress!, offset: 0, length: buffer.count) + } + + readBuffer.read(&contentType, offset: 0, length: 1) + + readBuffer.read(&size, offset: 0, length: 8) + }) + + self.id = id + self.contentType = contentType + self.size = size + } + + func serialize() -> MemoryBuffer { + let writeBuffer = WriteBuffer() + + var version: UInt8 = 0 + writeBuffer.write(&version, length: 1) + + var idLength = UInt16(clamping: self.id.count) + writeBuffer.write(&idLength, length: 2) + self.id.withUnsafeBytes { buffer in + writeBuffer.write(buffer.baseAddress!, length: Int(idLength)) + } + + var contentType = self.contentType + writeBuffer.write(&contentType, length: 1) + + var size = self.size + writeBuffer.write(&size, length: 8) + + return writeBuffer + } + } + private final class Impl { let queue: Queue let logger: StorageBox.Logger let basePath: String let valueBox: SqliteValueBox - let hashIdToIdTable: ValueBoxTable + let hashIdToInfoTable: ValueBoxTable let idToReferenceTable: ValueBoxTable let peerIdToIdTable: ValueBoxTable let peerIdTable: ValueBoxTable + let peerContentTypeStatsTable: ValueBoxTable + let contentTypeStatsTable: ValueBoxTable init(queue: Queue, logger: StorageBox.Logger, basePath: String) { self.queue = queue @@ -80,20 +151,81 @@ public final class StorageBox { } self.valueBox = valueBox - self.hashIdToIdTable = ValueBoxTable(id: 5, keyType: .binary, compactValuesOnCreation: true) - self.idToReferenceTable = ValueBoxTable(id: 6, keyType: .binary, compactValuesOnCreation: true) - self.peerIdToIdTable = ValueBoxTable(id: 7, keyType: .binary, compactValuesOnCreation: true) - self.peerIdTable = ValueBoxTable(id: 8, keyType: .binary, compactValuesOnCreation: true) + self.hashIdToInfoTable = ValueBoxTable(id: 15, keyType: .binary, compactValuesOnCreation: true) + self.idToReferenceTable = ValueBoxTable(id: 16, keyType: .binary, compactValuesOnCreation: true) + self.peerIdToIdTable = ValueBoxTable(id: 17, keyType: .binary, compactValuesOnCreation: true) + self.peerIdTable = ValueBoxTable(id: 18, keyType: .binary, compactValuesOnCreation: true) + self.peerContentTypeStatsTable = ValueBoxTable(id: 19, keyType: .binary, compactValuesOnCreation: true) + self.contentTypeStatsTable = ValueBoxTable(id: 20, keyType: .binary, compactValuesOnCreation: true) } - func add(reference: Reference, to id: Data) { + private func internalAddSize(contentType: UInt8, delta: Int64) { + let key = ValueBoxKey(length: 1) + key.setUInt8(0, value: contentType) + + var currentSize: Int64 = 0 + if let value = self.valueBox.get(self.contentTypeStatsTable, key: key) { + value.read(¤tSize, offset: 0, length: 8) + } + + currentSize += delta + + if currentSize < 0 { + assertionFailure() + currentSize = 0 + } + + self.valueBox.set(self.contentTypeStatsTable, key: key, value: MemoryBuffer(memory: ¤tSize, capacity: 8, length: 8, freeWhenDone: false)) + } + + private func internalAddSize(peerId: Int64, contentType: UInt8, delta: Int64) { + let key = ValueBoxKey(length: 8 + 1) + key.setInt64(0, value: peerId) + key.setUInt8(8, value: contentType) + + var currentSize: Int64 = 0 + if let value = self.valueBox.get(self.contentTypeStatsTable, key: key) { + value.read(¤tSize, offset: 0, length: 8) + } + + currentSize += delta + + if currentSize < 0 { + assertionFailure() + currentSize = 0 + } + + self.valueBox.set(self.contentTypeStatsTable, key: key, value: MemoryBuffer(memory: ¤tSize, capacity: 8, length: 8, freeWhenDone: false)) + } + + func add(reference: Reference, to id: Data, contentType: UInt8) { self.valueBox.begin() let hashId = md5Hash(id) let mainKey = ValueBoxKey(length: 16) mainKey.setData(0, value: hashId.data) - self.valueBox.setOrIgnore(self.hashIdToIdTable, key: mainKey, value: MemoryBuffer(data: id)) + + var previousContentType: UInt8? + var size: Int64 = 0 + if let currentInfoValue = self.valueBox.get(self.hashIdToInfoTable, key: mainKey) { + var info = ItemInfo(buffer: currentInfoValue) + if info.contentType != contentType { + previousContentType = info.contentType + } + size = info.size + info.contentType = contentType + self.valueBox.set(self.hashIdToInfoTable, key: mainKey, value: info.serialize()) + } else { + self.valueBox.set(self.hashIdToInfoTable, key: mainKey, value: ItemInfo(id: id, contentType: contentType, size: 0).serialize()) + } + + if let previousContentType = previousContentType, previousContentType != contentType { + if size != 0 { + self.internalAddSize(contentType: previousContentType, delta: -size) + self.internalAddSize(contentType: contentType, delta: size) + } + } let idKey = ValueBoxKey(length: hashId.data.count + 8 + 1 + 4) idKey.setData(0, value: hashId.data) @@ -145,25 +277,62 @@ public final class StorageBox { self.valueBox.commit() } - func addEmptyReferencesIfNotReferenced(ids: [Data]) -> Int { + func update(id: Data, size: Int64) { + self.valueBox.begin() + + let hashId = md5Hash(id) + + let mainKey = ValueBoxKey(length: 16) + mainKey.setData(0, value: hashId.data) + + if let currentInfoValue = self.valueBox.get(self.hashIdToInfoTable, key: mainKey) { + var info = ItemInfo(buffer: currentInfoValue) + + var sizeDelta: Int64 = 0 + if info.size != size { + sizeDelta = size - info.size + info.size = size + + self.valueBox.set(self.hashIdToInfoTable, key: mainKey, value: info.serialize()) + } + + if sizeDelta != 0 { + self.internalAddSize(contentType: info.contentType, delta: sizeDelta) + } + + var peerIds: [Int64] = [] + self.valueBox.range(self.idToReferenceTable, start: mainKey, end: mainKey.successor, keys: { key in + peerIds.append(key.getInt64(0)) + return true + }, limit: 0) + + for peerId in peerIds { + let _ = peerId + } + } + + self.valueBox.commit() + } + + func addEmptyReferencesIfNotReferenced(ids: [(id: Data, size: Int64)], contentType: UInt8) -> Int { self.valueBox.begin() var addedCount = 0 - for id in ids { + for (id, size) in ids { let reference = Reference(peerId: 0, messageNamespace: 0, messageId: 0) let hashId = md5Hash(id) let mainKey = ValueBoxKey(length: 16) mainKey.setData(0, value: hashId.data) - if self.valueBox.exists(self.hashIdToIdTable, key: mainKey) { + if self.valueBox.exists(self.hashIdToInfoTable, key: mainKey) { continue } addedCount += 1 - self.valueBox.setOrIgnore(self.hashIdToIdTable, key: mainKey, value: MemoryBuffer(data: id)) + self.valueBox.set(self.hashIdToInfoTable, key: mainKey, value: ItemInfo(id: id, contentType: contentType, size: size).serialize()) let idKey = ValueBoxKey(length: hashId.data.count + 8 + 1 + 4) idKey.setData(0, value: hashId.data) @@ -229,7 +398,15 @@ public final class StorageBox { let hashId = md5Hash(id) mainKey.setData(0, value: hashId.data) - self.valueBox.remove(self.hashIdToIdTable, key: mainKey, secure: false) + guard let infoValue = self.valueBox.get(self.hashIdToInfoTable, key: mainKey) else { + continue + } + let info = ItemInfo(buffer: infoValue) + self.valueBox.remove(self.hashIdToInfoTable, key: mainKey, secure: false) + + if info.size != 0 { + self.internalAddSize(contentType: info.contentType, delta: -info.size) + } var referenceKeys: [ValueBoxKey] = [] self.valueBox.range(self.idToReferenceTable, start: mainKey, end: mainKey.successor, keys: { key in @@ -302,8 +479,9 @@ public final class StorageBox { let mainKey = ValueBoxKey(length: 16) for hashId in hashIds { mainKey.setData(0, value: hashId) - if let value = self.valueBox.get(self.hashIdToIdTable, key: mainKey) { - result.append(value.makeData()) + if let infoValue = self.valueBox.get(self.hashIdToInfoTable, key: mainKey) { + let info = ItemInfo(buffer: infoValue) + result.append(info.id) } } @@ -336,8 +514,9 @@ public final class StorageBox { } else { if let currentId = currentId, !currentReferences.isEmpty { mainKey.setData(0, value: currentId) - if let value = self.valueBox.get(self.hashIdToIdTable, key: mainKey) { - result.append(StorageBox.Entry(id: value.makeData(), references: currentReferences)) + if let infoValue = self.valueBox.get(self.hashIdToInfoTable, key: mainKey) { + let info = ItemInfo(buffer: infoValue) + result.append(StorageBox.Entry(id: info.id, references: currentReferences)) } currentReferences.removeAll(keepingCapacity: true) } @@ -384,6 +563,20 @@ public final class StorageBox { return result } + + func getStats() -> Stats { + var contentTypes: [UInt8: Int64] = [:] + + self.valueBox.scan(self.contentTypeStatsTable, values: { key, value in + var size: Int64 = 0 + value.read(&size, offset: 0, length: 8) + contentTypes[key.getUInt8(0)] = size + + return true + }) + + return Stats(contentTypes: contentTypes) + } } private let queue: Queue @@ -396,15 +589,21 @@ public final class StorageBox { }) } - public func add(reference: Reference, to id: Data) { + public func add(reference: Reference, to id: Data, contentType: UInt8) { self.impl.with { impl in - impl.add(reference: reference, to: id) + impl.add(reference: reference, to: id, contentType: contentType) } } - public func addEmptyReferencesIfNotReferenced(ids: [Data], completion: @escaping (Int) -> Void) { + public func update(id: Data, size: Int64) { self.impl.with { impl in - let addedCount = impl.addEmptyReferencesIfNotReferenced(ids: ids) + impl.update(id: id, size: size) + } + } + + public func addEmptyReferencesIfNotReferenced(ids: [(id: Data, size: Int64)], contentType: UInt8, completion: @escaping (Int) -> Void) { + self.impl.with { impl in + let addedCount = impl.addEmptyReferencesIfNotReferenced(ids: ids, contentType: contentType) completion(addedCount) } @@ -451,4 +650,13 @@ public final class StorageBox { return EmptyDisposable } } + + public func getStats() -> Signal { + return self.impl.signalWith { impl, subscriber in + subscriber.putNext(impl.getStats()) + subscriber.putCompletion() + + return EmptyDisposable + } + } } diff --git a/submodules/SettingsUI/BUILD b/submodules/SettingsUI/BUILD index f609a3f352..92705d888a 100644 --- a/submodules/SettingsUI/BUILD +++ b/submodules/SettingsUI/BUILD @@ -109,6 +109,7 @@ swift_library( "//submodules/TelegramUI/Components/NotificationPeerExceptionController", "//submodules/TelegramUI/Components/ChatTimerScreen", "//submodules/AnimatedAvatarSetNode", + "//submodules/TelegramUI/Components/StorageUsageScreen", ], visibility = [ "//visibility:public", diff --git a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift index cfc2dc7471..86198d5553 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift @@ -12,6 +12,7 @@ import PresentationDataUtils import AccountContext import OpenInExternalAppUI import ItemListPeerActionItem +import StorageUsageScreen private final class DataAndStorageControllerArguments { let openStorageUsage: () -> Void @@ -594,7 +595,10 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da }) let arguments = DataAndStorageControllerArguments(openStorageUsage: { - pushControllerImpl?(storageUsageController(context: context, cacheUsagePromise: cacheUsagePromise)) + pushControllerImpl?(StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { category in + return storageUsageExceptionsScreen(context: context, category: category) + })) + //pushControllerImpl?(storageUsageController(context: context, cacheUsagePromise: cacheUsagePromise)) }, openNetworkUsage: { pushControllerImpl?(networkUsageStatsController(context: context)) }, openProxy: { diff --git a/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift b/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift index 2a8aa7db57..5322b39083 100644 --- a/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift +++ b/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift @@ -765,6 +765,14 @@ public final class SolidRoundedButtonView: UIView { } } + public var label: String? { + didSet { + if let width = self.validLayout { + _ = self.updateLayout(width: width, transition: .immediate) + } + } + } + public var subtitle: String? { didSet { if let width = self.validLayout { @@ -779,6 +787,14 @@ public final class SolidRoundedButtonView: UIView { } } + public var isEnabled: Bool = true { + didSet { + if self.isEnabled != oldValue { + self.titleNode.alpha = self.isEnabled ? 1.0 : 0.6 + } + } + } + private var animationTimer: SwiftSignalKit.Timer? public var animation: String? { didSet { @@ -854,13 +870,14 @@ public final class SolidRoundedButtonView: UIView { public var progressType: SolidRoundedButtonProgressType = .fullSize - public init(title: String? = nil, icon: UIImage? = nil, theme: SolidRoundedButtonTheme, font: SolidRoundedButtonFont = .bold, fontSize: CGFloat = 17.0, height: CGFloat = 48.0, cornerRadius: CGFloat = 24.0, gloss: Bool = false) { + public init(title: String? = nil, label: String? = nil, icon: UIImage? = nil, theme: SolidRoundedButtonTheme, font: SolidRoundedButtonFont = .bold, fontSize: CGFloat = 17.0, height: CGFloat = 48.0, cornerRadius: CGFloat = 24.0, gloss: Bool = false) { self.theme = theme self.font = font self.fontSize = fontSize self.buttonHeight = height self.buttonCornerRadius = cornerRadius self.title = title + self.label = label self.gloss = gloss self.buttonBackgroundNode = UIImageView() @@ -1174,7 +1191,13 @@ public final class SolidRoundedButtonView: UIView { self.buttonBackgroundAnimationView?.image = nil } - self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: theme.foregroundColor) + let titleText = NSMutableAttributedString() + titleText.append(NSAttributedString(string: self.title ?? "", font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: theme.foregroundColor)) + if let label = self.label { + titleText.append(NSAttributedString(string: " " + label, font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: theme.foregroundColor.withAlphaComponent(0.6))) + } + + self.titleNode.attributedText = titleText self.subtitleNode.attributedText = NSAttributedString(string: self.subtitle ?? "", font: Font.regular(14.0), textColor: theme.foregroundColor) self.iconNode.image = generateTintedImage(image: self.iconNode.image, color: theme.foregroundColor) @@ -1219,10 +1242,14 @@ public final class SolidRoundedButtonView: UIView { transition.updateFrame(view: self.buttonNode, frame: buttonFrame) - if self.title != self.titleNode.attributedText?.string { - self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: self.theme.foregroundColor) + let titleText = NSMutableAttributedString() + titleText.append(NSAttributedString(string: self.title ?? "", font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: theme.foregroundColor)) + if let label = self.label { + titleText.append(NSAttributedString(string: " " + label, font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: theme.foregroundColor.withAlphaComponent(0.6))) } + self.titleNode.attributedText = titleText + let iconSize: CGSize if let _ = self.animationNode { iconSize = CGSize(width: 30.0, height: 30.0) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift b/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift index 57f6587388..cac05196d9 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift @@ -1,7 +1,7 @@ import Foundation import Postbox import SwiftSignalKit - +import MtProtoKit public enum PeerCacheUsageCategory: Int32 { case image = 0 @@ -52,6 +52,130 @@ private final class CacheUsageStatsState { var upperBound: MessageIndex? } +public struct StorageUsageStats: Equatable { + public enum CategoryKey: Hashable { + case photos + case videos + case files + case music + case stickers + case avatars + case misc + } + + public struct CategoryData: Equatable { + public var size: Int64 + + public init(size: Int64) { + self.size = size + } + } + + public var categories: [CategoryKey: CategoryData] + + public init(categories: [CategoryKey: CategoryData]) { + self.categories = categories + } +} + +public struct AllStorageUsageStats: Equatable { + public struct PeerStats: Equatable { + public var peer: EnginePeer + public var stats: StorageUsageStats + + public init(peer: EnginePeer, stats: StorageUsageStats) { + self.peer = peer + self.stats = stats + } + } + + public var totalStats: StorageUsageStats + public var peers: [EnginePeer.Id: PeerStats] + + public init(totalStats: StorageUsageStats, peers: [EnginePeer.Id: PeerStats]) { + self.totalStats = totalStats + self.peers = peers + } +} + +func _internal_collectStorageUsageStats(account: Account) -> Signal { + let additionalStats = Signal { subscriber in + DispatchQueue.global().async { + var totalSize: Int64 = 0 + + let additionalPaths: [String] = [ + "cache", + "animation-cache", + "short-cache", + ] + + for path in additionalPaths { + let fullPath: String + if path.isEmpty { + fullPath = account.postbox.mediaBox.basePath + } else { + fullPath = account.postbox.mediaBox.basePath + "/\(path)" + } + + var s = darwin_dirstat() + var result = dirstat_np(fullPath, 1, &s, MemoryLayout.size) + if result != -1 { + totalSize += Int64(s.total_size) + } else { + result = dirstat_np(fullPath, 0, &s, MemoryLayout.size) + if result != -1 { + totalSize += Int64(s.total_size) + print(s.descendants) + } + } + } + + subscriber.putNext(totalSize) + subscriber.putCompletion() + } + + return EmptyDisposable + } + + return combineLatest( + additionalStats, + account.postbox.mediaBox.storageBox.getStats() + ) + |> deliverOnMainQueue + |> mapToSignal { additionalStats, allStats -> Signal in + var mappedCategories: [StorageUsageStats.CategoryKey: StorageUsageStats.CategoryData] = [:] + for (key, value) in allStats.contentTypes { + let mappedCategory: StorageUsageStats.CategoryKey + switch key { + case MediaResourceUserContentType.image.rawValue: + mappedCategory = .photos + case MediaResourceUserContentType.video.rawValue: + mappedCategory = .videos + case MediaResourceUserContentType.file.rawValue: + mappedCategory = .files + case MediaResourceUserContentType.audio.rawValue: + mappedCategory = .music + case MediaResourceUserContentType.avatar.rawValue: + mappedCategory = .avatars + case MediaResourceUserContentType.sticker.rawValue: + mappedCategory = .stickers + default: + mappedCategory = .misc + } + mappedCategories[mappedCategory] = StorageUsageStats.CategoryData(size: value) + } + + if additionalStats != 0 { + mappedCategories[.misc, default: StorageUsageStats.CategoryData(size: 0)].size += additionalStats + } + + return .single(AllStorageUsageStats( + totalStats: StorageUsageStats(categories: mappedCategories), + peers: [:] + )) + } +} + func _internal_collectCacheUsageStats(account: Account, peerId: PeerId? = nil, additionalCachePaths: [String] = [], logFilesPath: String? = nil) -> Signal { return account.postbox.mediaBox.storageBox.all() |> mapToSignal { entries -> Signal in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift index cb9f1bdc8d..78f946e1f1 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift @@ -8,10 +8,12 @@ public typealias EngineTempBoxFile = TempBoxFile public extension MediaResourceUserContentType { init(file: TelegramMediaFile) { - if file.isSticker || file.isAnimatedSticker { + if file.isMusic || file.isVoice { + self = .audio + } else if file.isSticker || file.isAnimatedSticker { self = .sticker } else if file.isCustomEmoji { - self = .emoji + self = .sticker } else if file.isVideo { if file.isAnimated { self = .gif @@ -220,6 +222,10 @@ public extension TelegramEngine { public func collectCacheUsageStats(peerId: PeerId? = nil, additionalCachePaths: [String] = [], logFilesPath: String? = nil) -> Signal { return _internal_collectCacheUsageStats(account: self.account, peerId: peerId, additionalCachePaths: additionalCachePaths, logFilesPath: logFilesPath) } + + public func collectStorageUsageStats() -> Signal { + return _internal_collectStorageUsageStats(account: self.account) + } public func clearCachedMediaResources(mediaResourceIds: Set) -> Signal { return _internal_clearCachedMediaResources(account: self.account, mediaResourceIds: mediaResourceIds) diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/BUILD b/submodules/TelegramUI/Components/StorageUsageScreen/BUILD new file mode 100644 index 0000000000..c6b2aa3ce2 --- /dev/null +++ b/submodules/TelegramUI/Components/StorageUsageScreen/BUILD @@ -0,0 +1,37 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "StorageUsageScreen", + module_name = "StorageUsageScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/Postbox:Postbox", + "//submodules/TelegramCore:TelegramCore", + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/Components/ViewControllerComponent:ViewControllerComponent", + "//submodules/Components/ComponentDisplayAdapters:ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent:MultilineTextComponent", + "//submodules/Components/SolidRoundedButtonComponent", + "//submodules/TelegramPresentationData:TelegramPresentationData", + "//submodules/AccountContext:AccountContext", + "//submodules/AppBundle:AppBundle", + "//submodules/TelegramStringFormatting:TelegramStringFormatting", + "//submodules/PresentationDataUtils:PresentationDataUtils", + "//submodules/TelegramUI/Components/EmojiStatusComponent", + "//submodules/CheckNode", + "//submodules/Markdown", + "//submodules/ContextUI", + "//submodules/AnimatedAvatarSetNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift new file mode 100644 index 0000000000..e7bea7fb22 --- /dev/null +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift @@ -0,0 +1,226 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import EmojiStatusComponent +import Postbox + +final class PieChartComponent: Component { + struct ChartData: Equatable { + struct Item: Equatable { + var id: AnyHashable + var value: Double + var color: UIColor + + init(id: AnyHashable, value: Double, color: UIColor) { + self.id = id + self.value = value + self.color = color + } + } + + var items: [Item] + + init(items: [Item]) { + self.items = items + } + } + + let theme: PresentationTheme + let chartData: ChartData + + init( + theme: PresentationTheme, + chartData: ChartData + ) { + self.theme = theme + self.chartData = chartData + } + + static func ==(lhs: PieChartComponent, rhs: PieChartComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.chartData != rhs.chartData { + return false + } + return true + } + + class View: UIView { + private var shapeLayers: [AnyHashable: SimpleShapeLayer] = [:] + private var labels: [AnyHashable: ComponentView] = [:] + var selectedKey: AnyHashable? + + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + let point = recognizer.location(in: self) + for (key, layer) in self.shapeLayers { + if layer.frame.contains(point), let path = layer.path { + if path.contains(self.layer.convert(point, to: layer)) { + if self.selectedKey == key { + self.selectedKey = nil + } else { + self.selectedKey = key + } + + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) + + break + } + } + } + } + } + + func update(component: PieChartComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.state = state + + let innerDiameter: CGFloat = 100.0 + let spacing: CGFloat = 2.0 + let innerAngleSpacing: CGFloat = spacing / (innerDiameter * 0.5) + + var valueSum: Double = 0.0 + for item in component.chartData.items { + valueSum += item.value + } + + let diameter: CGFloat = 200.0 + let reducedDiameter: CGFloat = 170.0 + + var startAngle: CGFloat = 0.0 + for i in 0 ..< component.chartData.items.count { + let item = component.chartData.items[i] + + let itemOuterDiameter: CGFloat + if let selectedKey = self.selectedKey { + if selectedKey == item.id { + itemOuterDiameter = diameter + } else { + itemOuterDiameter = reducedDiameter + } + } else { + itemOuterDiameter = diameter + } + + let shapeLayerFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - diameter) / 2.0), y: 0.0), size: CGSize(width: diameter, height: diameter)) + + let angleSpacing: CGFloat = spacing / (itemOuterDiameter * 0.5) + + let shapeLayer: SimpleShapeLayer + if let current = self.shapeLayers[item.id] { + shapeLayer = current + } else { + shapeLayer = SimpleShapeLayer() + self.shapeLayers[item.id] = shapeLayer + self.layer.insertSublayer(shapeLayer, at: 0) + } + + transition.setFrame(layer: shapeLayer, frame: shapeLayerFrame) + + let angleValue: CGFloat = item.value / valueSum * CGFloat.pi * 2.0 + + let innerStartAngle = startAngle + innerAngleSpacing * 0.5 + var innerEndAngle = startAngle + angleValue - innerAngleSpacing * 0.5 + innerEndAngle = max(innerEndAngle, innerStartAngle) + + let outerStartAngle = startAngle + angleSpacing * 0.5 + var outerEndAngle = startAngle + angleValue - angleSpacing * 0.5 + outerEndAngle = max(outerEndAngle, outerStartAngle) + + let path = CGMutablePath() + + path.addArc(center: CGPoint(x: diameter * 0.5, y: diameter * 0.5), radius: innerDiameter * 0.5, startAngle: innerEndAngle, endAngle: innerStartAngle, clockwise: true) + path.addArc(center: CGPoint(x: diameter * 0.5, y: diameter * 0.5), radius: itemOuterDiameter * 0.5, startAngle: outerStartAngle, endAngle: outerEndAngle, clockwise: false) + + transition.setShapeLayerPath(layer: shapeLayer, path: path) + + startAngle += angleValue + shapeLayer.fillColor = item.color.cgColor + + let fractionValue: Double = floor(item.value * 100.0 * 10.0) / 10.0 + let fractionString: String + if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 { + fractionString = "\(Int(fractionValue))" + } else { + fractionString = "\(fractionValue)" + } + + let label: ComponentView + if let current = self.labels[item.id] { + label = current + } else { + label = ComponentView() + self.labels[item.id] = label + } + let labelSize = label.update(transition: .immediate, component: AnyComponent(Text(text: "\(fractionString)%", font: Font.with(size: 15.0, design: .round, weight: .medium), color: component.theme.list.itemCheckColors.foregroundColor)), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0)) + + let midAngle: CGFloat = (innerStartAngle + innerEndAngle) * 0.5 + let centerDistance: CGFloat = (innerDiameter * 0.5 + (diameter - innerDiameter) * 0.25) + let labelCenter = CGPoint( + x: shapeLayerFrame.midX + cos(midAngle) * centerDistance, + y: shapeLayerFrame.midY + sin(midAngle) * centerDistance + ) + let labelFrame = CGRect(origin: CGPoint(x: labelCenter.x - labelSize.width * 0.5, y: labelCenter.y - labelSize.height * 0.5), size: labelSize) + + //x2 + y2 = r2 + //x = sqrt(r2 - y2) + //y = sqrt(r2 - x2) + + /*let localLabelRect = labelFrame.offsetBy(dx: -shapeLayerFrame.midX, dy: -shapeLayerFrame.midY) + let outerIntersectionX1 = sqrt(pow(diameter * 0.5, 2.0) - pow(localLabelRect.minY, 2.0)) + let outerIntersectionX2 = sqrt(pow(diameter * 0.5, 2.0) - pow(localLabelRect.maxY, 2.0)) + let outerIntersectionY1 = sqrt(pow(diameter * 0.5, 2.0) - pow(localLabelRect.minX, 2.0)) + let outerIntersectionY2 = sqrt(pow(diameter * 0.5, 2.0) - pow(localLabelRect.maxX, 2.0))*/ + + if let labelView = label.view { + if labelView.superview == nil { + self.addSubview(labelView) + } + labelView.bounds = CGRect(origin: CGPoint(), size: labelFrame.size) + transition.setPosition(view: labelView, position: labelFrame.center) + + if let selectedKey = self.selectedKey { + if selectedKey == item.id { + transition.setAlpha(view: labelView, alpha: 1.0) + } else { + transition.setAlpha(view: labelView, alpha: 0.0) + } + } else { + transition.setAlpha(view: labelView, alpha: 1.0) + } + } + } + + return CGSize(width: availableSize.width, height: 200.0) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoriesComponent.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoriesComponent.swift new file mode 100644 index 0000000000..0e22d9aedb --- /dev/null +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoriesComponent.swift @@ -0,0 +1,254 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import EmojiStatusComponent +import Postbox +import CheckNode +import SolidRoundedButtonComponent + +final class StorageCategoriesComponent: Component { + struct CategoryData: Equatable { + var key: AnyHashable + var color: UIColor + var title: String + var size: Int64 + var sizeFraction: Double + var isSelected: Bool + var subcategories: [CategoryData] + + init(key: AnyHashable, color: UIColor, title: String, size: Int64, sizeFraction: Double, isSelected: Bool, subcategories: [CategoryData]) { + self.key = key + self.title = title + self.color = color + self.size = size + self.sizeFraction = sizeFraction + self.isSelected = isSelected + self.subcategories = subcategories + } + } + + let theme: PresentationTheme + let strings: PresentationStrings + let categories: [CategoryData] + let toggleCategorySelection: (AnyHashable) -> Void + + init( + theme: PresentationTheme, + strings: PresentationStrings, + categories: [CategoryData], + toggleCategorySelection: @escaping (AnyHashable) -> Void + ) { + self.theme = theme + self.strings = strings + self.categories = categories + self.toggleCategorySelection = toggleCategorySelection + } + + static func ==(lhs: StorageCategoriesComponent, rhs: StorageCategoriesComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.categories != rhs.categories { + return false + } + return true + } + + class View: UIView { + private var itemViews: [AnyHashable: ComponentView] = [:] + private let button = ComponentView() + + private var expandedCategory: AnyHashable? + private var component: StorageCategoriesComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.clipsToBounds = true + self.layer.cornerRadius = 10.0 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: StorageCategoriesComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + var totalSelectedSize: Int64 = 0 + var hasDeselected = false + for category in component.categories { + if !category.subcategories.isEmpty { + for subcategory in category.subcategories { + if subcategory.isSelected { + totalSelectedSize += subcategory.size + } else { + hasDeselected = true + } + } + } else { + if category.isSelected { + totalSelectedSize += category.size + } else { + hasDeselected = true + } + } + } + + var contentHeight: CGFloat = 0.0 + + var validKeys = Set() + for i in 0 ..< component.categories.count { + let category = component.categories[i] + validKeys.insert(category.key) + + var itemTransition = transition + let itemView: ComponentView + if let current = self.itemViews[category.key] { + itemView = current + } else { + itemTransition = .immediate + itemView = ComponentView() + itemView.parentState = state + self.itemViews[category.key] = itemView + } + + let itemSize = itemView.update( + transition: itemTransition, + component: AnyComponent(StorageCategoryItemComponent( + theme: component.theme, + strings: component.strings, + category: category, + isExpandedLevel: false, + isExpanded: self.expandedCategory == category.key, + hasNext: i != component.categories.count - 1, + action: { [weak self] key, actionType in + guard let self, let component = self.component else { + return + } + + switch actionType { + case .generic: + if let category = component.categories.first(where: { $0.key == key }), !category.subcategories.isEmpty { + if self.expandedCategory == category.key { + self.expandedCategory = nil + } else { + self.expandedCategory = category.key + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } else { + component.toggleCategorySelection(key) + } + case .toggle: + component.toggleCategorySelection(key) + } + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 1000.0) + ) + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: itemSize) + if let itemComponentView = itemView.view { + if itemComponentView.superview == nil { + self.addSubview(itemComponentView) + } + itemTransition.setFrame(view: itemComponentView, frame: itemFrame) + } + + contentHeight += itemSize.height + } + + var removeKeys: [AnyHashable] = [] + for (key, itemView) in self.itemViews { + if !validKeys.contains(key) { + if let itemComponentView = itemView.view { + transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } + removeKeys.append(key) + } + } + for key in removeKeys { + self.itemViews.removeValue(forKey: key) + } + + //TODO:localize + let clearTitle: String + let label: String? + if totalSelectedSize == 0 { + clearTitle = "Clear" + label = nil + } else if hasDeselected { + clearTitle = "Clear Selected" + label = dataSizeString(totalSelectedSize, formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")) + } else { + clearTitle = "Clear All Cache" + label = dataSizeString(totalSelectedSize, formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")) + } + + contentHeight += 8.0 + let buttonSize = self.button.update( + transition: transition, + component: AnyComponent(SolidRoundedButtonComponent( + title: clearTitle, + label: label, + theme: SolidRoundedButtonComponent.Theme( + backgroundColor: component.theme.list.itemCheckColors.fillColor, + backgroundColors: [], + foregroundColor: component.theme.list.itemCheckColors.foregroundColor + ), + font: .bold, + fontSize: 17.0, + height: 50.0, + cornerRadius: 10.0, + gloss: false, + isEnabled: totalSelectedSize != 0, + animationName: nil, + iconPosition: .right, + iconSpacing: 4.0, + action: { + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 50.0) + ) + let buttonFrame = CGRect(origin: CGPoint(x: 16.0, y: contentHeight), size: buttonSize) + if let buttonView = button.view { + if buttonView.superview == nil { + self.addSubview(buttonView) + } + transition.setFrame(view: buttonView, frame: buttonFrame) + } + contentHeight += buttonSize.height + + contentHeight += 16.0 + + self.backgroundColor = component.theme.list.itemBlocksBackgroundColor + + return CGSize(width: availableSize.width, height: contentHeight) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoryItemCompoment.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoryItemCompoment.swift new file mode 100644 index 0000000000..d424387c98 --- /dev/null +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoryItemCompoment.swift @@ -0,0 +1,387 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import EmojiStatusComponent +import Postbox +import TelegramStringFormatting +import CheckNode + +final class StorageCategoryItemComponent: Component { + enum ActionType { + case toggle + case generic + } + + let theme: PresentationTheme + let strings: PresentationStrings + let category: StorageCategoriesComponent.CategoryData + let isExpandedLevel: Bool + let isExpanded: Bool + let hasNext: Bool + let action: (AnyHashable, ActionType) -> Void + + init( + theme: PresentationTheme, + strings: PresentationStrings, + category: StorageCategoriesComponent.CategoryData, + isExpandedLevel: Bool, + isExpanded: Bool, + hasNext: Bool, + action: @escaping (AnyHashable, ActionType) -> Void + ) { + self.theme = theme + self.strings = strings + self.category = category + self.isExpandedLevel = isExpandedLevel + self.isExpanded = isExpanded + self.hasNext = hasNext + self.action = action + } + + static func ==(lhs: StorageCategoryItemComponent, rhs: StorageCategoryItemComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.category != rhs.category { + return false + } + if lhs.isExpandedLevel != rhs.isExpandedLevel { + return false + } + if lhs.isExpanded != rhs.isExpanded { + return false + } + if lhs.hasNext != rhs.hasNext { + return false + } + return true + } + + class View: HighlightTrackingButton { + private let checkLayer: CheckLayer + private let title = ComponentView() + private let titleValue = ComponentView() + private let label = ComponentView() + private var iconView: UIImageView? + private let separatorLayer: SimpleLayer + + private let checkButtonArea: HighlightTrackingButton + + private let subcategoryClippingContainer: UIView + private var itemViews: [AnyHashable: ComponentView] = [:] + + private var component: StorageCategoryItemComponent? + + private var highlightBackgroundFrame: CGRect? + private var highlightBackgroundLayer: SimpleLayer? + + override init(frame: CGRect) { + self.checkLayer = CheckLayer() + self.separatorLayer = SimpleLayer() + + self.checkButtonArea = HighlightTrackingButton() + + self.subcategoryClippingContainer = UIView() + self.subcategoryClippingContainer.clipsToBounds = true + + super.init(frame: frame) + + self.addSubview(self.subcategoryClippingContainer) + + self.layer.addSublayer(self.separatorLayer) + self.layer.addSublayer(self.checkLayer) + + self.addSubview(self.checkButtonArea) + + self.highligthedChanged = { [weak self] isHighlighted in + guard let self, let component = self.component, let highlightBackgroundFrame = self.highlightBackgroundFrame else { + return + } + + if isHighlighted { + self.superview?.bringSubviewToFront(self) + + let highlightBackgroundLayer: SimpleLayer + if let current = self.highlightBackgroundLayer { + highlightBackgroundLayer = current + } else { + highlightBackgroundLayer = SimpleLayer() + self.highlightBackgroundLayer = highlightBackgroundLayer + self.layer.insertSublayer(highlightBackgroundLayer, above: self.separatorLayer) + highlightBackgroundLayer.backgroundColor = component.theme.list.itemHighlightedBackgroundColor.cgColor + } + highlightBackgroundLayer.frame = highlightBackgroundFrame + highlightBackgroundLayer.opacity = 1.0 + } else { + if let highlightBackgroundLayer = self.highlightBackgroundLayer { + self.highlightBackgroundLayer = nil + highlightBackgroundLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak highlightBackgroundLayer] _ in + highlightBackgroundLayer?.removeFromSuperlayer() + }) + } + } + } + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + + self.checkButtonArea.addTarget(self, action: #selector(self.checkPressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action(component.category.key, .generic) + } + + @objc private func checkPressed() { + guard let component = self.component else { + return + } + component.action(component.category.key, .toggle) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let result = super.hitTest(point, with: event) else { + return nil + } + if result === self.subcategoryClippingContainer { + return self + } + return result + } + + func update(component: StorageCategoryItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + self.component = component + + var leftInset: CGFloat = 62.0 + var additionalLeftInset: CGFloat = 0.0 + + if component.isExpandedLevel { + additionalLeftInset += 45.0 + } + leftInset += additionalLeftInset + + let rightInset: CGFloat = 16.0 + + var availableWidth: CGFloat = availableSize.width - leftInset + + if !component.category.subcategories.isEmpty { + let iconView: UIImageView + if let current = self.iconView { + iconView = current + if themeUpdated { + iconView.image = PresentationResourcesItemList.disclosureArrowImage(component.theme) + } + } else { + iconView = UIImageView() + iconView.image = PresentationResourcesItemList.disclosureArrowImage(component.theme) + self.iconView = iconView + self.addSubview(iconView) + } + + if let image = iconView.image { + availableWidth -= image.size.width + 6.0 + transition.setBounds(view: iconView, bounds: CGRect(origin: CGPoint(), size: image.size)) + } + } else if let iconView = self.iconView { + self.iconView = nil + iconView.removeFromSuperview() + } + + let fractionValue: Double = floor(component.category.sizeFraction * 100.0 * 10.0) / 10.0 + let fractionString: String + if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 { + fractionString = "\(Int(fractionValue))" + } else { + fractionString = "\(fractionValue)" + } + + let labelSize = self.label.update( + transition: transition, + component: AnyComponent(Text(text: dataSizeString(Int(component.category.size), formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")), font: Font.regular(17.0), color: component.theme.list.itemSecondaryTextColor)), + environment: {}, + containerSize: CGSize(width: availableWidth, height: 100.0) + ) + availableWidth = max(1.0, availableWidth - labelSize.width - 4.0) + + let titleValueSize = self.titleValue.update( + transition: transition, + component: AnyComponent(Text(text: "\(fractionString)%", font: Font.regular(17.0), color: component.theme.list.itemSecondaryTextColor)), + environment: {}, + containerSize: CGSize(width: availableWidth, height: 100.0) + ) + availableWidth = max(1.0, availableWidth - titleValueSize.width - 1.0) + + let titleSize = self.title.update( + transition: transition, + component: AnyComponent(Text(text: component.category.title, font: Font.regular(17.0), color: component.theme.list.itemPrimaryTextColor)), + environment: {}, + containerSize: CGSize(width: availableWidth, height: 100.0) + ) + + var height: CGFloat = 44.0 + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize) + let titleValueFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floor((height - titleValueSize.height) / 2.0)), size: titleValueSize) + + var labelFrame = CGRect(origin: CGPoint(x: availableSize.width - rightInset - labelSize.width, y: floor((height - labelSize.height) / 2.0)), size: labelSize) + + if let iconView = self.iconView, let image = iconView.image { + labelFrame.origin.x -= image.size.width - 6.0 + + transition.setPosition(view: iconView, position: CGPoint(x: availableSize.width - rightInset + 6.0 - floor(image.size.width * 0.5), y: floor(height * 0.5))) + let angle: CGFloat = component.isExpanded ? CGFloat.pi : 0.0 + transition.setTransform(view: iconView, transform: CATransform3DMakeRotation(CGFloat.pi * 0.5 - angle, 0.0, 0.0, 1.0)) + } + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + if let titleValueView = self.titleValue.view { + if titleValueView.superview == nil { + titleValueView.isUserInteractionEnabled = false + self.addSubview(titleValueView) + } + transition.setFrame(view: titleValueView, frame: titleValueFrame) + } + if let labelView = self.label.view { + if labelView.superview == nil { + labelView.isUserInteractionEnabled = false + self.addSubview(labelView) + } + transition.setFrame(view: labelView, frame: labelFrame) + } + + if themeUpdated { + self.checkLayer.theme = CheckNodeTheme( + backgroundColor: component.category.color, + strokeColor: component.theme.list.itemCheckColors.foregroundColor, + borderColor: component.theme.list.itemCheckColors.strokeColor, + overlayBorder: false, + hasInset: false, + hasShadow: false + ) + } + + let checkDiameter: CGFloat = 22.0 + let checkFrame = CGRect(origin: CGPoint(x: titleFrame.minX - 20.0 - checkDiameter, y: floor((height - checkDiameter) / 2.0)), size: CGSize(width: checkDiameter, height: checkDiameter)) + self.checkLayer.frame = checkFrame + + transition.setFrame(view: self.checkButtonArea, frame: CGRect(origin: CGPoint(x: additionalLeftInset, y: 0.0), size: CGSize(width: leftInset - additionalLeftInset, height: height))) + + if self.checkLayer.selected != component.category.isSelected { + self.checkLayer.setSelected(component.category.isSelected, animated: !transition.animation.isImmediate) + } + + if themeUpdated { + self.separatorLayer.backgroundColor = component.theme.list.itemBlocksSeparatorColor.cgColor + } + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel))) + + transition.setAlpha(layer: self.separatorLayer, alpha: (component.isExpanded || component.hasNext) ? 1.0 : 0.0) + + self.highlightBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height + ((component.isExpanded || component.hasNext) ? UIScreenPixel : 0.0))) + + var validKeys = Set() + if component.isExpanded { + for i in 0 ..< component.category.subcategories.count { + let category = component.category.subcategories[i] + validKeys.insert(category.key) + + var itemTransition = transition + let itemView: ComponentView + if let current = self.itemViews[category.key] { + itemView = current + } else { + itemTransition = .immediate + itemView = ComponentView() + self.itemViews[category.key] = itemView + } + + itemView.parentState = state + let itemSize = itemView.update( + transition: itemTransition, + component: AnyComponent(StorageCategoryItemComponent( + theme: component.theme, + strings: component.strings, + category: category, + isExpandedLevel: true, + isExpanded: false, + hasNext: i != component.category.subcategories.count - 1, + action: { [weak self] key, _ in + guard let self else { + return + } + self.component?.action(key, .toggle) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 1000.0) + ) + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: height), size: itemSize) + if let itemComponentView = itemView.view { + if itemComponentView.superview == nil { + self.subcategoryClippingContainer.addSubview(itemComponentView) + if !transition.animation.isImmediate { + itemComponentView.alpha = 0.0 + transition.setAlpha(view: itemComponentView, alpha: 1.0) + } + } + itemTransition.setFrame(view: itemComponentView, frame: itemFrame) + } + + height += itemSize.height + } + } + + var removeKeys: [AnyHashable] = [] + for (key, itemView) in self.itemViews { + if !validKeys.contains(key) { + if let itemComponentView = itemView.view { + transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } + removeKeys.append(key) + } + } + for key in removeKeys { + self.itemViews.removeValue(forKey: key) + } + + transition.setFrame(view: self.subcategoryClippingContainer, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height))) + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StoragePeerTypeItemComponent.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StoragePeerTypeItemComponent.swift new file mode 100644 index 0000000000..dad53704ba --- /dev/null +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StoragePeerTypeItemComponent.swift @@ -0,0 +1,224 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import EmojiStatusComponent +import Postbox +import TelegramStringFormatting +import CheckNode + +final class StoragePeerTypeItemComponent: Component { + enum ActionType { + case toggle + case generic + } + + let theme: PresentationTheme + let iconName: String + let title: String + let value: String + let hasNext: Bool + let action: (View) -> Void + + init( + theme: PresentationTheme, + iconName: String, + title: String, + value: String, + hasNext: Bool, + action: @escaping (View) -> Void + ) { + self.theme = theme + self.iconName = iconName + self.title = title + self.value = value + self.hasNext = hasNext + self.action = action + } + + static func ==(lhs: StoragePeerTypeItemComponent, rhs: StoragePeerTypeItemComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.iconName != rhs.iconName { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.value != rhs.value { + return false + } + if lhs.hasNext != rhs.hasNext { + return false + } + return true + } + + class View: HighlightTrackingButton { + private let iconView: UIImageView + private let title = ComponentView() + private let label = ComponentView() + private let separatorLayer: SimpleLayer + private let arrowIconView: UIImageView + + private var component: StoragePeerTypeItemComponent? + + private var highlightBackgroundFrame: CGRect? + private var highlightBackgroundLayer: SimpleLayer? + + var labelView: UIView? { + return self.label.view + } + + override init(frame: CGRect) { + self.separatorLayer = SimpleLayer() + self.iconView = UIImageView() + self.arrowIconView = UIImageView() + + super.init(frame: frame) + + self.layer.addSublayer(self.separatorLayer) + + self.addSubview(self.iconView) + self.addSubview(self.arrowIconView) + + self.highligthedChanged = { [weak self] isHighlighted in + guard let self, let component = self.component, let highlightBackgroundFrame = self.highlightBackgroundFrame else { + return + } + + if isHighlighted { + self.superview?.bringSubviewToFront(self) + + let highlightBackgroundLayer: SimpleLayer + if let current = self.highlightBackgroundLayer { + highlightBackgroundLayer = current + } else { + highlightBackgroundLayer = SimpleLayer() + self.highlightBackgroundLayer = highlightBackgroundLayer + self.layer.insertSublayer(highlightBackgroundLayer, above: self.separatorLayer) + highlightBackgroundLayer.backgroundColor = component.theme.list.itemHighlightedBackgroundColor.cgColor + } + highlightBackgroundLayer.frame = highlightBackgroundFrame + highlightBackgroundLayer.opacity = 1.0 + } else { + if let highlightBackgroundLayer = self.highlightBackgroundLayer { + self.highlightBackgroundLayer = nil + highlightBackgroundLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak highlightBackgroundLayer] _ in + highlightBackgroundLayer?.removeFromSuperlayer() + }) + } + } + } + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func pressed() { + guard let component = self.component else { + return + } + component.action(self) + } + + func setHasAssociatedMenu(_ hasAssociatedMenu: Bool) { + let transition: Transition + if hasAssociatedMenu { + transition = .immediate + } else { + transition = .easeInOut(duration: 0.25) + } + if let view = self.label.view { + transition.setAlpha(view: view, alpha: hasAssociatedMenu ? 0.5 : 1.0) + } + transition.setAlpha(view: self.arrowIconView, alpha: hasAssociatedMenu ? 0.5 : 1.0) + } + + func update(component: StoragePeerTypeItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let themeUpdated = self.component?.theme !== component.theme + + self.component = component + + let leftInset: CGFloat = 62.0 + let rightInset: CGFloat = 32.0 + + var availableWidth: CGFloat = availableSize.width - leftInset - rightInset + + let labelSize = self.label.update( + transition: transition, + component: AnyComponent(Text(text: component.value, font: Font.regular(17.0), color: component.theme.list.itemSecondaryTextColor)), + environment: {}, + containerSize: CGSize(width: availableWidth, height: 100.0) + ) + availableWidth = max(1.0, availableWidth - labelSize.width - 4.0) + + let titleSize = self.title.update( + transition: transition, + component: AnyComponent(Text(text: component.title, font: Font.regular(17.0), color: component.theme.list.itemPrimaryTextColor)), + environment: {}, + containerSize: CGSize(width: availableWidth, height: 100.0) + ) + + let height: CGFloat = 44.0 + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize) + let labelFrame = CGRect(origin: CGPoint(x: availableSize.width - rightInset - labelSize.width, y: floor((height - labelSize.height) / 2.0)), size: labelSize) + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + if let labelView = self.label.view { + if labelView.superview == nil { + labelView.isUserInteractionEnabled = false + self.addSubview(labelView) + } + transition.setFrame(view: labelView, frame: labelFrame) + } + + if themeUpdated { + self.separatorLayer.backgroundColor = component.theme.list.itemBlocksSeparatorColor.cgColor + self.iconView.image = UIImage(bundleImageName: component.iconName) + self.arrowIconView.image = PresentationResourcesItemList.disclosureOptionArrowsImage(component.theme) + } + + if let image = self.iconView.image { + transition.setFrame(view: self.iconView, frame: CGRect(origin: CGPoint(x: floor((leftInset - image.size.width) / 2.0), y: floor((height - image.size.height) / 2.0)), size: image.size)) + } + if let image = self.arrowIconView.image { + transition.setFrame(view: self.arrowIconView, frame: CGRect(origin: CGPoint(x: availableSize.width - rightInset + 5.0, y: floor((height - image.size.height) / 2.0)), size: image.size)) + } + + transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel))) + + transition.setAlpha(layer: self.separatorLayer, alpha: component.hasNext ? 1.0 : 0.0) + + self.highlightBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height + (component.hasNext ? UIScreenPixel : 0.0))) + + return CGSize(width: availableSize.width, height: height) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift new file mode 100644 index 0000000000..5c66ccbf3b --- /dev/null +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift @@ -0,0 +1,1140 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import EmojiStatusComponent +import Postbox +import Markdown +import ContextUI +import AnimatedAvatarSetNode + +private final class StorageUsageScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let makeStorageUsageExceptionsScreen: (CacheStorageSettings.PeerStorageCategory) -> ViewController? + + init( + context: AccountContext, + makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController? + ) { + self.context = context + self.makeStorageUsageExceptionsScreen = makeStorageUsageExceptionsScreen + } + + static func ==(lhs: StorageUsageScreenComponent, rhs: StorageUsageScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + return true + } + + private final class ScrollViewImpl: UIScrollView { + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + private final class AnimationHint { + let isFirstStatsUpdate: Bool + + init(isFirstStatsUpdate: Bool) { + self.isFirstStatsUpdate = isFirstStatsUpdate + } + } + + class View: UIView, UIScrollViewDelegate { + private let scrollView: ScrollViewImpl + + private var currentStats: AllStorageUsageStats? + private var cacheSettings: CacheStorageSettings? + + private var selectedCategories: Set = Set() + + private let navigationBackgroundView: BlurredBackgroundView + private let navigationSeparatorLayer: SimpleLayer + + private let headerView = ComponentView() + private let headerOffsetContainer: UIView + private let headerDescriptionView = ComponentView() + + private let headerProgressBackgroundLayer: SimpleLayer + private let headerProgressForegroundLayer: SimpleLayer + + private let pieChartView = ComponentView() + private let chartTotalLabel = ComponentView() + private let categoriesView = ComponentView() + private let categoriesDescriptionView = ComponentView() + + private let keepDurationTitleView = ComponentView() + private let keepDurationDescriptionView = ComponentView() + private var keepDurationSectionContainerView: UIView + private var keepDurationItems: [AnyHashable: ComponentView] = [:] + + private var component: StorageUsageScreenComponent? + private weak var state: EmptyComponentState? + private var navigationMetrics: (navigationHeight: CGFloat, statusBarHeight: CGFloat)? + private var controller: (() -> ViewController?)? + + private var ignoreScrolling: Bool = false + + private var statsDisposable: Disposable? + private var cacheSettingsDisposable: Disposable? + + override init(frame: CGRect) { + self.headerOffsetContainer = UIView() + self.headerOffsetContainer.isUserInteractionEnabled = false + + self.navigationBackgroundView = BlurredBackgroundView(color: nil, enableBlur: true) + self.navigationSeparatorLayer = SimpleLayer() + + self.scrollView = ScrollViewImpl() + + self.keepDurationSectionContainerView = UIView() + self.keepDurationSectionContainerView.clipsToBounds = true + self.keepDurationSectionContainerView.layer.cornerRadius = 10.0 + + self.headerProgressBackgroundLayer = SimpleLayer() + self.headerProgressForegroundLayer = SimpleLayer() + + super.init(frame: frame) + + self.scrollView.layer.anchorPoint = CGPoint() + self.scrollView.delaysContentTouches = true + self.scrollView.canCancelContentTouches = true + self.scrollView.clipsToBounds = false + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + if #available(iOS 13.0, *) { + self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.alwaysBounceHorizontal = false + self.scrollView.scrollsToTop = false + self.scrollView.delegate = self + self.scrollView.clipsToBounds = true + self.addSubview(self.scrollView) + + self.scrollView.addSubview(self.keepDurationSectionContainerView) + + self.scrollView.layer.addSublayer(self.headerProgressBackgroundLayer) + self.scrollView.layer.addSublayer(self.headerProgressForegroundLayer) + + self.addSubview(self.navigationBackgroundView) + self.layer.addSublayer(self.navigationSeparatorLayer) + + self.addSubview(self.headerOffsetContainer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.statsDisposable?.dispose() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + private func updateScrolling(transition: Transition) { + let scrollBounds = self.scrollView.bounds + + if let headerView = self.headerView.view, let navigationMetrics = self.navigationMetrics { + var headerOffset: CGFloat = scrollBounds.minY + + let minY = navigationMetrics.statusBarHeight + floor((navigationMetrics.navigationHeight - navigationMetrics.statusBarHeight) / 2.0) + + let minOffset = headerView.center.y - minY + + headerOffset = min(headerOffset, minOffset) + + let animatedTransition = Transition(animation: .curve(duration: 0.18, curve: .easeInOut)) + if abs(headerOffset - minOffset) < 4.0 { + animatedTransition.setAlpha(view: self.navigationBackgroundView, alpha: 1.0) + animatedTransition.setAlpha(layer: self.navigationSeparatorLayer, alpha: 1.0) + } else { + animatedTransition.setAlpha(view: self.navigationBackgroundView, alpha: 0.0) + animatedTransition.setAlpha(layer: self.navigationSeparatorLayer, alpha: 0.0) + } + + var offsetFraction: CGFloat = abs(headerOffset - minOffset) / 60.0 + offsetFraction = min(1.0, max(0.0, offsetFraction)) + transition.setScale(view: headerView, scale: 1.0 * offsetFraction + 0.8 * (1.0 - offsetFraction)) + + transition.setBounds(view: self.headerOffsetContainer, bounds: CGRect(origin: CGPoint(x: 0.0, y: headerOffset), size: self.headerOffsetContainer.bounds.size)) + } + } + + func update(component: StorageUsageScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + if self.statsDisposable == nil { + self.cacheSettingsDisposable = (component.context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings]) + |> map { sharedData -> CacheStorageSettings in + let cacheSettings: CacheStorageSettings + if let value = sharedData.entries[SharedDataKeys.cacheStorageSettings]?.get(CacheStorageSettings.self) { + cacheSettings = value + } else { + cacheSettings = CacheStorageSettings.defaultSettings + } + + return cacheSettings + } + |> deliverOnMainQueue).start(next: { [weak self] cacheSettings in + guard let self else { + return + } + self.cacheSettings = cacheSettings + if self.currentStats != nil { + self.state?.updated(transition: .immediate) + } + }) + self.statsDisposable = (component.context.engine.resources.collectStorageUsageStats() + |> deliverOnMainQueue).start(next: { [weak self] stats in + guard let self else { + return + } + self.currentStats = stats + self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(isFirstStatsUpdate: true))) + }) + } + + let animationHint = transition.userData(AnimationHint.self) + + if let animationHint, animationHint.isFirstStatsUpdate { + var alphaTransition = transition + if animationHint.isFirstStatsUpdate { + alphaTransition = .easeInOut(duration: 0.25) + } + alphaTransition.setAlpha(view: self.scrollView, alpha: self.currentStats != nil ? 1.0 : 0.0) + } else { + transition.setAlpha(view: self.scrollView, alpha: self.currentStats != nil ? 1.0 : 0.0) + } + + let environment = environment[ViewControllerComponentContainer.Environment.self].value + + self.controller = environment.controller + + self.navigationMetrics = (environment.navigationHeight, environment.statusBarHeight) + + self.navigationSeparatorLayer.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor + + let navigationFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: environment.navigationHeight)) + self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) + self.navigationBackgroundView.update(size: navigationFrame.size, transition: transition.containedViewLayoutTransition) + transition.setFrame(view: self.navigationBackgroundView, frame: navigationFrame) + transition.setFrame(layer: self.navigationSeparatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationFrame.maxY), size: CGSize(width: availableSize.width, height: UIScreenPixel))) + + enum Category: Hashable { + case photos + case videos + case files + case music + case other + case stickers + case avatars + case misc + + var color: UIColor { + switch self { + case .photos: + return UIColor(rgb: 0x5AC8FA) + case .videos: + return UIColor(rgb: 0x3478F6) + case .files: + return UIColor(rgb: 0x34C759) + case .music: + return UIColor(rgb: 0xFF2D55) + case .other: + return UIColor(rgb: 0xC4C4C6) + case .stickers: + return UIColor(rgb: 0x5856D6) + case .avatars: + return UIColor(rgb: 0xAF52DE) + case .misc: + return UIColor(rgb: 0xFF9500) + } + } + + func title(strings: PresentationStrings) -> String { + switch self { + case .photos: + return "Photos" + case .videos: + return "Videos" + case .files: + return "Files" + case .music: + return "Music" + case .other: + return "Other" + case .stickers: + return "Stickers" + case .avatars: + return "Avatars" + case .misc: + return "Miscellaneous" + } + } + } + + self.backgroundColor = environment.theme.list.blocksBackgroundColor + + var contentHeight: CGFloat = 0.0 + + let topInset: CGFloat = 19.0 + let sideInset: CGFloat = 16.0 + + contentHeight += environment.statusBarHeight + topInset + + let chartOrder: [Category] = [ + .photos, + .videos, + .files, + .music, + .stickers, + .avatars, + .misc + ] + + if let animationHint, animationHint.isFirstStatsUpdate { + for category in chartOrder { + let _ = self.selectedCategories.insert(category) + } + } + + var chartItems: [PieChartComponent.ChartData.Item] = [] + var listCategories: [StorageCategoriesComponent.CategoryData] = [] + + var totalSize: Int64 = 0 + if let currentStats = self.currentStats { + for (_, value) in currentStats.totalStats.categories { + totalSize += value.size + } + + for category in chartOrder { + let mappedCategory: StorageUsageStats.CategoryKey + switch category { + case .photos: + mappedCategory = .photos + case .videos: + mappedCategory = .videos + case .files: + mappedCategory = .files + case .music: + mappedCategory = .music + case .stickers: + mappedCategory = .stickers + case .avatars: + mappedCategory = .avatars + case .misc: + mappedCategory = .misc + case .other: + continue + } + + if let categoryData = currentStats.totalStats.categories[mappedCategory] { + let categoryFraction: Double + if categoryData.size == 0 || totalSize == 0 { + categoryFraction = 0.0 + } else { + categoryFraction = Double(categoryData.size) / Double(totalSize) + } + chartItems.append(PieChartComponent.ChartData.Item(id: category, value: categoryFraction, color: category.color)) + + listCategories.append(StorageCategoriesComponent.CategoryData( + key: category, color: category.color, title: category.title(strings: environment.strings), size: categoryData.size, sizeFraction: categoryFraction, isSelected: self.selectedCategories.contains(category), subcategories: [])) + } + } + } + + let otherCategories: [Category] = [ + .stickers, + .avatars, + .misc + ] + + var otherListCategories: [StorageCategoriesComponent.CategoryData] = [] + for listCategory in listCategories { + if otherCategories.contains(where: { AnyHashable($0) == listCategory.key }) { + otherListCategories.append(listCategory) + } + } + listCategories = listCategories.filter { item in + return !otherCategories.contains(where: { AnyHashable($0) == item.key }) + } + if !otherListCategories.isEmpty { + var totalOtherSize: Int64 = 0 + for listCategory in otherListCategories { + totalOtherSize += listCategory.size + } + let categoryFraction: Double + if totalOtherSize == 0 || totalSize == 0 { + categoryFraction = 0.0 + } else { + categoryFraction = Double(totalOtherSize) / Double(totalSize) + } + let isSelected = otherListCategories.allSatisfy { item in + return self.selectedCategories.contains(item.key) + } + listCategories.append(StorageCategoriesComponent.CategoryData( + key: Category.other, color: Category.other.color, title: Category.other.title(strings: environment.strings), size: totalOtherSize, sizeFraction: categoryFraction, isSelected: isSelected, subcategories: otherListCategories)) + } + + let chartData = PieChartComponent.ChartData(items: chartItems) + self.pieChartView.parentState = state + let pieChartSize = self.pieChartView.update( + transition: transition, + component: AnyComponent(PieChartComponent( + theme: environment.theme, + chartData: chartData + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 60.0) + ) + let pieChartFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: pieChartSize) + if let pieChartComponentView = self.pieChartView.view { + if pieChartComponentView.superview == nil { + self.scrollView.addSubview(pieChartComponentView) + } + + transition.setFrame(view: pieChartComponentView, frame: pieChartFrame) + } + contentHeight += pieChartSize.height + contentHeight += 26.0 + + let headerViewSize = self.headerView.update( + transition: transition, + component: AnyComponent(Text(text: "Storage Usage", font: Font.semibold(22.0), color: environment.theme.list.itemPrimaryTextColor)), + environment: {}, + containerSize: CGSize(width: availableSize.width - 40.0 * 2.0, height: 100.0) + ) + let headerViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerViewSize.width) / 2.0), y: contentHeight), size: headerViewSize) + if let headerComponentView = self.headerView.view { + if headerComponentView.superview == nil { + self.headerOffsetContainer.addSubview(headerComponentView) + } + transition.setPosition(view: headerComponentView, position: headerViewFrame.center) + transition.setBounds(view: headerComponentView, bounds: CGRect(origin: CGPoint(), size: headerViewFrame.size)) + } + contentHeight += headerViewSize.height + + contentHeight += 4.0 + + let body = MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor) + let bold = MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.freeTextColor) + + //TODO:localize + let headerDescriptionSize = self.headerDescriptionView.update( + transition: transition, + component: AnyComponent(MultilineTextComponent(text: .markdown(text: "Telegram users 9.7% of your free disk space.", attributes: MarkdownAttributes( + body: body, + bold: bold, + link: body, + linkAttribute: { _ in nil } + )), maximumNumberOfLines: 0)), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) + ) + let headerDescriptionFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerDescriptionSize.width) / 2.0), y: contentHeight), size: headerDescriptionSize) + if let headerDescriptionComponentView = self.headerDescriptionView.view { + if headerDescriptionComponentView.superview == nil { + self.scrollView.addSubview(headerDescriptionComponentView) + } + transition.setFrame(view: headerDescriptionComponentView, frame: headerDescriptionFrame) + } + contentHeight += headerDescriptionSize.height + contentHeight += 8.0 + + let headerProgressWidth: CGFloat = min(200.0, availableSize.width - sideInset * 2.0) + let headerProgressFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerProgressWidth) / 2.0), y: contentHeight), size: CGSize(width: headerProgressWidth, height: 4.0)) + transition.setFrame(layer: self.headerProgressBackgroundLayer, frame: headerProgressFrame) + transition.setCornerRadius(layer: self.headerProgressBackgroundLayer, cornerRadius: headerProgressFrame.height * 0.5) + self.headerProgressBackgroundLayer.backgroundColor = environment.theme.list.itemAccentColor.withMultipliedAlpha(0.2).cgColor + + let headerProgress: CGFloat = 0.097 + transition.setFrame(layer: self.headerProgressForegroundLayer, frame: CGRect(origin: headerProgressFrame.origin, size: CGSize(width: floorToScreenPixels(headerProgress * headerProgressFrame.width), height: headerProgressFrame.height))) + transition.setCornerRadius(layer: self.headerProgressForegroundLayer, cornerRadius: headerProgressFrame.height * 0.5) + self.headerProgressForegroundLayer.backgroundColor = environment.theme.list.itemAccentColor.cgColor + contentHeight += 4.0 + + contentHeight += 24.0 + + let chartTotalLabelSize = self.chartTotalLabel.update( + transition: transition, + component: AnyComponent(Text(text: dataSizeString(Int(totalSize), formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")), font: Font.with(size: 20.0, design: .round, weight: .bold), color: environment.theme.list.itemPrimaryTextColor)), environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) + ) + if let chartTotalLabelView = self.chartTotalLabel.view { + if chartTotalLabelView.superview == nil { + self.scrollView.addSubview(chartTotalLabelView) + } + transition.setFrame(view: chartTotalLabelView, frame: CGRect(origin: CGPoint(x: pieChartFrame.minX + floor((pieChartFrame.width - chartTotalLabelSize.width) / 2.0), y: pieChartFrame.minY + floor((pieChartFrame.height - chartTotalLabelSize.height) / 2.0)), size: chartTotalLabelSize)) + } + + self.categoriesView.parentState = state + let categoriesSize = self.categoriesView.update( + transition: transition, + component: AnyComponent(StorageCategoriesComponent( + theme: environment.theme, + strings: environment.strings, + categories: listCategories, + toggleCategorySelection: { [weak self] key in + guard let self else { + return + } + if key == AnyHashable(Category.other) { + let otherCategories: [Category] = [.stickers, .avatars, .misc] + if otherCategories.allSatisfy(self.selectedCategories.contains) { + for item in otherCategories { + self.selectedCategories.remove(item) + } + self.selectedCategories.remove(Category.other) + } else { + for item in otherCategories { + let _ = self.selectedCategories.insert(item) + } + let _ = self.selectedCategories.insert(Category.other) + } + } else { + if self.selectedCategories.contains(key) { + self.selectedCategories.remove(key) + } else { + self.selectedCategories.insert(key) + } + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude) + ) + if let categoriesComponentView = self.categoriesView.view { + if categoriesComponentView.superview == nil { + self.scrollView.addSubview(categoriesComponentView) + } + + transition.setFrame(view: categoriesComponentView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: categoriesSize)) + } + contentHeight += categoriesSize.height + contentHeight += 8.0 + + //TODO:localize + let categoriesDescriptionSize = self.categoriesDescriptionView.update( + transition: transition, + component: AnyComponent(MultilineTextComponent(text: .markdown(text: "All media will stay in the Telegram cloud and can be re-downloaded if you need it again.", attributes: MarkdownAttributes( + body: body, + bold: bold, + link: body, + linkAttribute: { _ in nil } + )), maximumNumberOfLines: 0)), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) + ) + let categoriesDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: categoriesDescriptionSize) + if let categoriesDescriptionComponentView = self.categoriesDescriptionView.view { + if categoriesDescriptionComponentView.superview == nil { + self.scrollView.addSubview(categoriesDescriptionComponentView) + } + transition.setFrame(view: categoriesDescriptionComponentView, frame: categoriesDescriptionFrame) + } + contentHeight += categoriesDescriptionSize.height + + contentHeight += 40.0 + + //TODO:localize + let keepDurationTitleSize = self.keepDurationTitleView.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .markdown( + text: "KEEP MEDIA", attributes: MarkdownAttributes( + body: body, + bold: bold, + link: body, + linkAttribute: { _ in nil } + ) + ), + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) + ) + let keepDurationTitleFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepDurationTitleSize) + if let keepDurationTitleComponentView = self.keepDurationTitleView.view { + if keepDurationTitleComponentView.superview == nil { + self.scrollView.addSubview(keepDurationTitleComponentView) + } + transition.setFrame(view: keepDurationTitleComponentView, frame: keepDurationTitleFrame) + } + contentHeight += keepDurationTitleSize.height + contentHeight += 8.0 + + var keepContentHeight: CGFloat = 0.0 + for i in 0 ..< 3 { + let item: ComponentView + if let current = self.keepDurationItems[i] { + item = current + } else { + item = ComponentView() + self.keepDurationItems[i] = item + } + + let mappedCategory: CacheStorageSettings.PeerStorageCategory + + //TODO:localize + let iconName: String + let title: String + switch i { + case 0: + iconName = "Settings/Menu/EditProfile" + title = "Private Chats" + mappedCategory = .privateChats + case 1: + iconName = "Settings/Menu/GroupChats" + title = "Group Chats" + mappedCategory = .groups + default: + iconName = "Settings/Menu/Channels" + title = "Channels" + mappedCategory = .channels + } + + let value = self.cacheSettings?.categoryStorageTimeout[mappedCategory] ?? Int32.max + let optionText: String + if value == Int32.max { + optionText = environment.strings.ClearCache_Forever + } else { + optionText = timeIntervalString(strings: environment.strings, value: value) + } + + let itemSize = item.update( + transition: transition, + component: AnyComponent(StoragePeerTypeItemComponent( + theme: environment.theme, + iconName: iconName, + title: title, + value: optionText, + hasNext: i != 3 - 1, + action: { [weak self] sourceView in + guard let self else { + return + } + self.openKeepMediaCategory(mappedCategory: mappedCategory, sourceView: sourceView) + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) + ) + let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: keepContentHeight), size: itemSize) + if let itemView = item.view { + if itemView.superview == nil { + self.keepDurationSectionContainerView.addSubview(itemView) + } + transition.setFrame(view: itemView, frame: itemFrame) + } + keepContentHeight += itemSize.height + } + self.keepDurationSectionContainerView.backgroundColor = environment.theme.list.itemBlocksBackgroundColor + transition.setFrame(view: self.keepDurationSectionContainerView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: keepContentHeight))) + contentHeight += keepContentHeight + contentHeight += 8.0 + + //TODO:localize + let keepDurationDescriptionSize = self.keepDurationDescriptionView.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .markdown( + text: "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.", attributes: MarkdownAttributes( + body: body, + bold: bold, + link: body, + linkAttribute: { _ in nil } + ) + ), + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) + ) + let keepDurationDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepDurationDescriptionSize) + if let keepDurationDescriptionComponentView = self.keepDurationDescriptionView.view { + if keepDurationDescriptionComponentView.superview == nil { + self.scrollView.addSubview(keepDurationDescriptionComponentView) + } + transition.setFrame(view: keepDurationDescriptionComponentView, frame: keepDurationDescriptionFrame) + } + contentHeight += keepDurationDescriptionSize.height + contentHeight += 40.0 + + contentHeight += availableSize.height + + self.ignoreScrolling = true + + let contentOffset = self.scrollView.bounds.minY + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize)) + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset { + let deltaOffset = self.scrollView.bounds.minY - contentOffset + transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true) + } + + self.ignoreScrolling = false + + self.updateScrolling(transition: transition) + + return availableSize + } + + private func openKeepMediaCategory(mappedCategory: CacheStorageSettings.PeerStorageCategory, sourceView: StoragePeerTypeItemComponent.View) { + guard let component = self.component else { + return + } + let context = component.context + let makeStorageUsageExceptionsScreen = component.makeStorageUsageExceptionsScreen + + let pushControllerImpl: ((ViewController) -> Void)? = { [weak self] c in + guard let self else { + return + } + self.controller?()?.push(c) + } + let presentInGlobalOverlay: ((ViewController) -> Void)? = { [weak self] c in + guard let self else { + return + } + self.controller?()?.presentInGlobalOverlay(c, with: nil) + } + + let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings])) + let accountSpecificSettings: Signal = context.account.postbox.combinedView(keys: [viewKey]) + |> map { views -> AccountSpecificCacheStorageSettings in + let cacheSettings: AccountSpecificCacheStorageSettings + if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) { + cacheSettings = value + } else { + cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings + } + + return cacheSettings + } + |> distinctUntilChanged + + let peerExceptions: Signal<[(peer: FoundPeer, value: Int32)], NoError> = accountSpecificSettings + |> mapToSignal { accountSpecificSettings -> Signal<[(peer: FoundPeer, value: Int32)], NoError> in + return context.account.postbox.transaction { transaction -> [(peer: FoundPeer, value: Int32)] in + var result: [(peer: FoundPeer, value: Int32)] = [] + + for item in accountSpecificSettings.peerStorageTimeoutExceptions { + let peerId = item.key + let value = item.value + + guard let peer = transaction.getPeer(peerId) else { + continue + } + let peerCategory: CacheStorageSettings.PeerStorageCategory + var subscriberCount: Int32? + if peer is TelegramUser { + peerCategory = .privateChats + } else if peer is TelegramGroup { + peerCategory = .groups + + if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedGroupData { + subscriberCount = (cachedData.participants?.participants.count).flatMap(Int32.init) + } + } else if let channel = peer as? TelegramChannel { + if case .group = channel.info { + peerCategory = .groups + } else { + peerCategory = .channels + } + if peerCategory == mappedCategory { + if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedChannelData { + subscriberCount = cachedData.participantsSummary.memberCount + } + } + } else { + continue + } + + if peerCategory != mappedCategory { + continue + } + + result.append((peer: FoundPeer(peer: peer, subscribers: subscriberCount), value: value)) + } + + return result.sorted(by: { lhs, rhs in + if lhs.value != rhs.value { + return lhs.value < rhs.value + } + return lhs.peer.peer.debugDisplayTitle < rhs.peer.peer.debugDisplayTitle + }) + } + } + + let cacheSettings = context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings]) + |> map { sharedData -> CacheStorageSettings in + let cacheSettings: CacheStorageSettings + if let value = sharedData.entries[SharedDataKeys.cacheStorageSettings]?.get(CacheStorageSettings.self) { + cacheSettings = value + } else { + cacheSettings = CacheStorageSettings.defaultSettings + } + + return cacheSettings + } + + let _ = (combineLatest( + cacheSettings |> take(1), + peerExceptions |> take(1) + ) + |> deliverOnMainQueue).start(next: { cacheSettings, peerExceptions in + let currentValue: Int32 = cacheSettings.categoryStorageTimeout[mappedCategory] ?? Int32.max + + let applyValue: (Int32) -> Void = { value in + let _ = updateCacheStorageSettingsInteractively(accountManager: context.sharedContext.accountManager, { cacheSettings in + var cacheSettings = cacheSettings + cacheSettings.categoryStorageTimeout[mappedCategory] = value + return cacheSettings + }).start() + } + + var subItems: [ContextMenuItem] = [] + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + + var presetValues: [Int32] = [ + Int32.max, + 31 * 24 * 60 * 60, + 7 * 24 * 60 * 60, + 1 * 24 * 60 * 60 + ] + if currentValue != 0 && !presetValues.contains(currentValue) { + presetValues.append(currentValue) + presetValues.sort(by: >) + } + + for value in presetValues { + let optionText: String + if value == Int32.max { + optionText = presentationData.strings.ClearCache_Forever + } else { + optionText = timeIntervalString(strings: presentationData.strings, value: value) + } + subItems.append(.action(ContextMenuActionItem(text: optionText, icon: { theme in + if currentValue == value { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) + } else { + return nil + } + }, action: { _, f in + applyValue(value) + f(.default) + }))) + } + + subItems.append(.separator) + + if peerExceptions.isEmpty { + let exceptionsText = presentationData.strings.GroupInfo_Permissions_AddException + subItems.append(.action(ContextMenuActionItem(text: exceptionsText, icon: { theme in + if case .privateChats = mappedCategory { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor) + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Location/CreateGroupIcon"), color: theme.contextMenu.primaryColor) + } + }, action: { _, f in + f(.default) + + if let exceptionsController = makeStorageUsageExceptionsScreen(mappedCategory) { + pushControllerImpl?(exceptionsController) + } + }))) + } else { + subItems.append(.custom(MultiplePeerAvatarsContextItem(context: context, peers: peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }, action: { c, _ in + c.dismiss(completion: { + + }) + if let exceptionsController = makeStorageUsageExceptionsScreen(mappedCategory) { + pushControllerImpl?(exceptionsController) + } + }), false)) + } + + if let sourceLabelView = sourceView.labelView { + let items: Signal = .single(ContextController.Items(content: .list(subItems))) + let source: ContextContentSource = .reference(StorageUsageContextReferenceContentSource(sourceView: sourceLabelView)) + + let contextController = ContextController( + account: context.account, + presentationData: presentationData, + source: source, + items: items, + gesture: nil + ) + sourceView.setHasAssociatedMenu(true) + contextController.dismissed = { [weak sourceView] in + sourceView?.setHasAssociatedMenu(false) + } + presentInGlobalOverlay?(contextController) + } + }) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public final class StorageUsageScreen: ViewControllerComponentContainer { + private let context: AccountContext + + public init(context: AccountContext, makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController?) { + self.context = context + + super.init(context: context, component: StorageUsageScreenComponent(context: context, makeStorageUsageExceptionsScreen: makeStorageUsageExceptionsScreen), navigationBarAppearance: .transparent) + + //self.navigationPresentation = .modal + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + } +} + +private final class StorageUsageContextReferenceContentSource: ContextReferenceContentSource { + private let sourceView: UIView + + init(sourceView: UIView) { + self.sourceView = sourceView + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, insets: UIEdgeInsets(top: -4.0, left: 0.0, bottom: -4.0, right: 0.0)) + } +} + +final class MultiplePeerAvatarsContextItem: ContextMenuCustomItem { + fileprivate let context: AccountContext + fileprivate let peers: [EnginePeer] + fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void + + init(context: AccountContext, peers: [EnginePeer], action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void) { + self.context = context + self.peers = peers + self.action = action + } + + func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode { + return MultiplePeerAvatarsContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected) + } +} + +private final class MultiplePeerAvatarsContextItemNode: ASDisplayNode, ContextMenuCustomNode, ContextActionNodeProtocol { + private let item: MultiplePeerAvatarsContextItem + private var presentationData: PresentationData + private let getController: () -> ContextControllerProtocol? + private let actionSelected: (ContextMenuActionResult) -> Void + + private let backgroundNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private let textNode: ImmediateTextNode + + private let avatarsNode: AnimatedAvatarSetNode + private let avatarsContext: AnimatedAvatarSetContext + + private let buttonNode: HighlightTrackingButtonNode + + private var pointerInteraction: PointerInteraction? + + init(presentationData: PresentationData, item: MultiplePeerAvatarsContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) { + self.item = item + self.presentationData = presentationData + self.getController = getController + self.actionSelected = actionSelected + + let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) + + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isAccessibilityElement = false + self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.isAccessibilityElement = false + self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor + self.highlightedBackgroundNode.alpha = 0.0 + + self.textNode = ImmediateTextNode() + self.textNode.isAccessibilityElement = false + self.textNode.isUserInteractionEnabled = false + self.textNode.displaysAsynchronously = false + self.textNode.attributedText = NSAttributedString(string: " ", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor) + self.textNode.maximumNumberOfLines = 1 + + self.buttonNode = HighlightTrackingButtonNode() + self.buttonNode.isAccessibilityElement = true + self.buttonNode.accessibilityLabel = presentationData.strings.VoiceChat_StopRecording + + self.avatarsNode = AnimatedAvatarSetNode() + self.avatarsContext = AnimatedAvatarSetContext() + + super.init() + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.highlightedBackgroundNode) + self.addSubnode(self.textNode) + self.addSubnode(self.avatarsNode) + self.addSubnode(self.buttonNode) + + self.buttonNode.highligthedChanged = { [weak self] highligted in + guard let strongSelf = self else { + return + } + if highligted { + strongSelf.highlightedBackgroundNode.alpha = 1.0 + } else { + strongSelf.highlightedBackgroundNode.alpha = 0.0 + strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) + } + } + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + self.buttonNode.isUserInteractionEnabled = true + } + + deinit { + } + + override func didLoad() { + super.didLoad() + + self.pointerInteraction = PointerInteraction(node: self.buttonNode, style: .hover, willEnter: { [weak self] in + if let strongSelf = self { + strongSelf.highlightedBackgroundNode.alpha = 0.75 + } + }, willExit: { [weak self] in + if let strongSelf = self { + strongSelf.highlightedBackgroundNode.alpha = 0.0 + } + }) + } + + private var validLayout: (calculatedWidth: CGFloat, size: CGSize)? + + func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) { + let sideInset: CGFloat = 14.0 + let verticalInset: CGFloat = 12.0 + + let rightTextInset: CGFloat = sideInset + 36.0 + + let calculatedWidth = min(constrainedWidth, 250.0) + + let textFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize) + let text: String = self.presentationData.strings.CacheEvictionMenu_CategoryExceptions(Int32(self.item.peers.count)) + self.textNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: self.presentationData.theme.contextMenu.primaryColor) + + let textSize = self.textNode.updateLayout(CGSize(width: calculatedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude)) + + let combinedTextHeight = textSize.height + return (CGSize(width: calculatedWidth, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in + self.validLayout = (calculatedWidth: calculatedWidth, size: size) + let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0) + let textFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalOrigin), size: textSize) + transition.updateFrameAdditive(node: self.textNode, frame: textFrame) + + let avatarsContent: AnimatedAvatarSetContext.Content + + let avatarsPeers: [EnginePeer] = self.item.peers + + avatarsContent = self.avatarsContext.update(peers: avatarsPeers, animated: false) + + let avatarsSize = self.avatarsNode.update(context: self.item.context, content: avatarsContent, itemSize: CGSize(width: 24.0, height: 24.0), customSpacing: 10.0, animated: false, synchronousLoad: true) + self.avatarsNode.frame = CGRect(origin: CGPoint(x: size.width - sideInset - 12.0 - avatarsSize.width, y: floor((size.height - avatarsSize.height) / 2.0)), size: avatarsSize) + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + }) + } + + func updateTheme(presentationData: PresentationData) { + self.presentationData = presentationData + + self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor + self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor + + let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) + + self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor) + } + + @objc private func buttonPressed() { + self.performAction() + } + + private var actionTemporarilyDisabled: Bool = false + + func canBeHighlighted() -> Bool { + return self.isActionEnabled + } + + func updateIsHighlighted(isHighlighted: Bool) { + self.setIsHighlighted(isHighlighted) + } + + func performAction() { + if self.actionTemporarilyDisabled { + return + } + self.actionTemporarilyDisabled = true + Queue.mainQueue().async { [weak self] in + self?.actionTemporarilyDisabled = false + } + + guard let controller = self.getController() else { + return + } + self.item.action(controller, { [weak self] result in + self?.actionSelected(result) + }) + } + + var isActionEnabled: Bool { + return true + } + + func setIsHighlighted(_ value: Bool) { + if value { + self.highlightedBackgroundNode.alpha = 1.0 + } else { + self.highlightedBackgroundNode.alpha = 0.0 + } + } + + func actionNode(at point: CGPoint) -> ContextActionNodeProtocol { + return self + } +} diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 0a8982ebd0..92daf2f5d9 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -84,6 +84,7 @@ import NotificationPeerExceptionController import StickerPackPreviewUI import ChatListHeaderComponent import ChatControllerInteraction +import StorageUsageScreen enum PeerInfoAvatarEditingMode { case generic