import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import ViewControllerComponent import ComponentDisplayAdapters import TelegramPresentationData import AccountContext import TelegramCore import Postbox import MultilineTextComponent import EmojiStatusComponent import Markdown import ContextUI import AnimatedAvatarSetNode import AvatarNode import RadialStatusNode import UndoUI import AnimatedStickerNode import TelegramAnimatedStickerNode import TelegramStringFormatting import GalleryData import AnimatedTextComponent import BottomButtonPanelComponent #if DEBUG import os.signpost private class SignpostContext { enum EventType { case begin case end } class OpaqueData { } static var shared: SignpostContext? = { if #available(iOS 15.0, *) { return SignpostContextImpl() } else { return nil } }() func begin(name: StaticString) -> OpaqueData { preconditionFailure() } func end(name: StaticString, data: OpaqueData) { } } @available(iOS 15.0, *) private final class SignpostContextImpl: SignpostContext { final class OpaqueDataImpl: OpaqueData { let state: OSSignpostIntervalState let timestamp: Double init(state: OSSignpostIntervalState, timestamp: Double) { self.state = state self.timestamp = timestamp } } private let signpost = OSSignposter(subsystem: "org.telegram.Telegram-iOS", category: "StorageUsageScreen") private let id: OSSignpostID override init() { self.id = self.signpost.makeSignpostID() super.init() } override func begin(name: StaticString) -> OpaqueData { let result = self.signpost.beginInterval(name, id: self.id) return OpaqueDataImpl(state: result, timestamp: CFAbsoluteTimeGetCurrent()) } override func end(name: StaticString, data: OpaqueData) { if let data = data as? OpaqueDataImpl { self.signpost.endInterval(name, data.state) print("Signpost \(name): \((CFAbsoluteTimeGetCurrent() - data.timestamp) * 1000.0) ms") } } } #endif private extension StorageUsageScreenComponent.Category { init(_ category: StorageUsageStats.CategoryKey) { switch category { case .photos: self = .photos case .videos: self = .videos case .files: self = .files case .music: self = .music case .stickers: self = .stickers case .avatars: self = .avatars case .misc: self = .misc case .stories: self = .stories } } } final class StorageUsageScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let makeStorageUsageExceptionsScreen: (CacheStorageSettings.PeerStorageCategory) -> ViewController? let peer: EnginePeer? let ready: Promise init( context: AccountContext, makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController?, peer: EnginePeer?, ready: Promise ) { self.context = context self.makeStorageUsageExceptionsScreen = makeStorageUsageExceptionsScreen self.peer = peer self.ready = ready } static func ==(lhs: StorageUsageScreenComponent, rhs: StorageUsageScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.peer != rhs.peer { return false } return true } private final class ScrollViewImpl: UIScrollView { override func touchesShouldCancel(in view: UIView) -> Bool { return true } override var contentOffset: CGPoint { set(value) { var value = value if value.y > self.contentSize.height - self.bounds.height { value.y = max(0.0, self.contentSize.height - self.bounds.height) self.bounces = false } else { self.bounces = true } super.contentOffset = value } get { return super.contentOffset } } } private final class AnimationHint { enum Value { case firstStatsUpdate case clearedItems } let value: Value init(value: Value) { self.value = value } } final class SelectionState: Equatable { let selectedPeers: Set let selectedMessages: Set var isEmpty: Bool { if !self.selectedPeers.isEmpty { return false } if !self.selectedMessages.isEmpty { return false } return true } init( selectedPeers: Set, selectedMessages: Set ) { self.selectedPeers = selectedPeers self.selectedMessages = selectedMessages } convenience init() { self.init( selectedPeers: Set(), selectedMessages: Set() ) } static func ==(lhs: SelectionState, rhs: SelectionState) -> Bool { if lhs.selectedPeers != rhs.selectedPeers { return false } if lhs.selectedMessages != rhs.selectedMessages { return false } return true } func togglePeer(id: EnginePeer.Id, availableMessages: [EngineMessage.Id: Message]) -> SelectionState { var selectedPeers = self.selectedPeers var selectedMessages = self.selectedMessages if selectedPeers.contains(id) { selectedPeers.remove(id) for (messageId, _) in availableMessages { if messageId.peerId == id { selectedMessages.remove(messageId) } } } else { selectedPeers.insert(id) for (messageId, _) in availableMessages { if messageId.peerId == id { selectedMessages.insert(messageId) } } } return SelectionState( selectedPeers: selectedPeers, selectedMessages: selectedMessages ) } func toggleMessage(id: EngineMessage.Id) -> SelectionState { var selectedMessages = self.selectedMessages if selectedMessages.contains(id) { selectedMessages.remove(id) } else { selectedMessages.insert(id) } return SelectionState( selectedPeers: self.selectedPeers, selectedMessages: selectedMessages ) } } enum Category: Hashable { case photos case videos case files case music case other case stickers case avatars case misc case stories 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) case .stories: return UIColor(rgb: 0xFF2D55) } } func title(strings: PresentationStrings) -> String { switch self { case .photos: return strings.StorageManagement_SectionPhotos case .videos: return strings.StorageManagement_SectionVideos case .files: return strings.StorageManagement_SectionFiles case .music: return strings.StorageManagement_SectionMusic case .other: return strings.StorageManagement_SectionOther case .stickers: return strings.StorageManagement_SectionStickers case .avatars: return strings.StorageManagement_SectionAvatars case .misc: return strings.StorageManagement_SectionMiscellaneous case .stories: return strings.StorageManagement_SectionStories } } var particle: String? { switch self { case .photos: return "Settings/Storage/ParticlePhotos" case .videos: return "Settings/Storage/ParticleVideos" case .files: return "Settings/Storage/ParticleDocuments" case .music: return "Settings/Storage/ParticleMusic" case .other: return "Settings/Storage/ParticleOther" case .stickers: return "Settings/Storage/ParticleStickers" case .avatars: return "Settings/Storage/ParticleAvatars" case .misc: return "Settings/Storage/ParticleOther" case .stories: return "Settings/Storage/ParticleOther" } } } private final class AggregatedData { let peerId: EnginePeer.Id? let stats: AllStorageUsageStats let contextStats: StorageUsageStats let messages: [MessageId: Message] var isSelectingPeers: Bool = false private(set) var selectionState: SelectionState let existingCategories: Set private(set) var selectedCategories: Set let peerItems: StoragePeerListPanelComponent.Items? let imageItems: StorageMediaGridPanelComponent.Items? let fileItems: StorageFileListPanelComponent.Items? let musicItems: StorageFileListPanelComponent.Items? private let allPhotos: Set private let allVideos: Set private let allFiles: Set private let allMusic: Set private(set) var selectedSize: Int64 = 0 private(set) var clearIncludeMessages: [Message] = [] private(set) var clearExcludeMessages: [Message] = [] init( peerId: EnginePeer.Id?, stats: AllStorageUsageStats, messages: [MessageId: Message], peerItems: StoragePeerListPanelComponent.Items?, imageItems: StorageMediaGridPanelComponent.Items?, fileItems: StorageFileListPanelComponent.Items?, musicItems: StorageFileListPanelComponent.Items? ) { self.peerId = peerId self.stats = stats if let peerId { self.contextStats = stats.peers[peerId]?.stats ?? StorageUsageStats(categories: [:]) } else { self.contextStats = stats.totalStats } self.messages = messages self.selectionState = SelectionState() self.peerItems = peerItems self.imageItems = imageItems self.fileItems = fileItems self.musicItems = musicItems var allPhotos = Set() var allVideos = Set() if let imageItems = self.imageItems { for item in imageItems.items { var isImage = false for media in item.message.media { if media is TelegramMediaImage { isImage = true break } } if isImage { allPhotos.insert(item.message.id) } else { allVideos.insert(item.message.id) } } } self.allPhotos = allPhotos self.allVideos = allVideos var allFiles = Set() if let fileItems = self.fileItems { for item in fileItems.items { allFiles.insert(item.message.id) } } self.allFiles = allFiles var allMusic = Set() if let musicItems = self.musicItems { for item in musicItems.items { allMusic.insert(item.message.id) } } self.allMusic = allMusic var existingCategories = Set() for (category, value) in self.contextStats.categories { if value.size != 0 { existingCategories.insert(StorageUsageScreenComponent.Category(category)) } } self.existingCategories = existingCategories self.selectedCategories = existingCategories if self.peerId != nil { var selectedMessages = self.selectionState.selectedMessages selectedMessages.formUnion(self.allPhotos) selectedMessages.formUnion(self.allVideos) selectedMessages.formUnion(self.allFiles) selectedMessages.formUnion(self.allMusic) self.selectionState = SelectionState(selectedPeers: self.selectionState.selectedPeers, selectedMessages: selectedMessages) } self.refreshSelectionStats() } func setIsCategorySelected(category: Category, isSelected: Bool) { if isSelected { self.selectedCategories.insert(category) } else { self.selectedCategories.remove(category) } if self.peerId != nil { var selectedMessages = self.selectionState.selectedMessages switch category { case .photos: if isSelected { selectedMessages.formUnion(self.allPhotos) } else { selectedMessages.subtract(self.allPhotos) } case .videos: if isSelected { selectedMessages.formUnion(self.allVideos) } else { selectedMessages.subtract(self.allVideos) } case .files: if let fileItems = self.fileItems { for item in fileItems.items { if isSelected { selectedMessages.insert(item.message.id) } else { selectedMessages.remove(item.message.id) } } } case .music: if let fileItems = self.musicItems { for item in fileItems.items { if isSelected { selectedMessages.insert(item.message.id) } else { selectedMessages.remove(item.message.id) } } } default: break } self.selectionState = SelectionState(selectedPeers: self.selectionState.selectedPeers, selectedMessages: selectedMessages) } self.refreshSelectionStats() } func clearPeerSelection() { self.selectionState = SelectionState(selectedPeers: Set(), selectedMessages: Set()) self.refreshSelectionStats() } func togglePeerSelection(id: EnginePeer.Id) { self.selectionState = self.selectionState.togglePeer(id: id, availableMessages: self.messages) self.refreshSelectionStats() } func toggleMessageSelection(id: EngineMessage.Id) { self.selectionState = self.selectionState.toggleMessage(id: id) if self.peerId != nil { if self.allPhotos.contains(id) { if !self.selectionState.selectedMessages.contains(id) { if self.allPhotos.intersection(self.selectionState.selectedMessages).isEmpty { self.selectedCategories.remove(.photos) } } else { if self.allPhotos.intersection(self.selectionState.selectedMessages) == self.allPhotos { self.selectedCategories.insert(.photos) } } } else if self.allVideos.contains(id) { if !self.selectionState.selectedMessages.contains(id) { if self.allVideos.intersection(self.selectionState.selectedMessages).isEmpty { self.selectedCategories.remove(.videos) } } else { if self.allVideos.intersection(self.selectionState.selectedMessages) == self.allVideos { self.selectedCategories.insert(.videos) } } } else if self.allFiles.contains(id) { if !self.selectionState.selectedMessages.contains(id) { if self.allFiles.intersection(self.selectionState.selectedMessages).isEmpty { self.selectedCategories.remove(.files) } } else { if self.allFiles.intersection(self.selectionState.selectedMessages) == self.allFiles { self.selectedCategories.insert(.files) } } } else if self.allMusic.contains(id) { if !self.selectionState.selectedMessages.contains(id) { if self.allMusic.intersection(self.selectionState.selectedMessages).isEmpty { self.selectedCategories.remove(.music) } } else { if self.allMusic.intersection(self.selectionState.selectedMessages) == self.allMusic { self.selectedCategories.insert(.music) } } } } self.refreshSelectionStats() } private func refreshSelectionStats() { if let _ = self.peerId { var selectedSize: Int64 = 0 for (category, value) in self.contextStats.categories { let mappedCategory = StorageUsageScreenComponent.Category(category) if self.selectedCategories.contains(mappedCategory) { selectedSize += value.size } } var clearIncludeMessages: [Message] = [] var clearExcludeMessages: [Message] = [] if self.selectedCategories.contains(.photos) { let deselectedPhotos = self.allPhotos.subtracting(self.selectionState.selectedMessages) if !deselectedPhotos.isEmpty, let imageItems = self.imageItems { for item in imageItems.items { if deselectedPhotos.contains(item.message.id) { selectedSize -= item.size clearExcludeMessages.append(item.message._asMessage()) } } } } else { let selectedPhotos = self.allPhotos.intersection(self.selectionState.selectedMessages) if !selectedPhotos.isEmpty, let imageItems = self.imageItems { for item in imageItems.items { if selectedPhotos.contains(item.message.id) { selectedSize += item.size clearIncludeMessages.append(item.message._asMessage()) } } } } if self.selectedCategories.contains(.videos) { let deselectedVideos = self.allVideos.subtracting(self.selectionState.selectedMessages) if !deselectedVideos.isEmpty, let imageItems = self.imageItems { for item in imageItems.items { if deselectedVideos.contains(item.message.id) { selectedSize -= item.size clearExcludeMessages.append(item.message._asMessage()) } } } } else { let selectedVideos = self.allVideos.intersection(self.selectionState.selectedMessages) if !selectedVideos.isEmpty, let imageItems = self.imageItems { for item in imageItems.items { if selectedVideos.contains(item.message.id) { selectedSize += item.size clearIncludeMessages.append(item.message._asMessage()) } } } } if self.selectedCategories.contains(.files) { let deselectedFiles = self.allFiles.subtracting(self.selectionState.selectedMessages) if !deselectedFiles.isEmpty, let fileItems = self.fileItems { for item in fileItems.items { if deselectedFiles.contains(item.message.id) { selectedSize -= item.size clearExcludeMessages.append(item.message) } } } } else { let selectedFiles = self.allFiles.intersection(self.selectionState.selectedMessages) if !selectedFiles.isEmpty, let fileItems = self.fileItems { for item in fileItems.items { if selectedFiles.contains(item.message.id) { selectedSize += item.size clearIncludeMessages.append(item.message) } } } } if self.selectedCategories.contains(.music) { let deselectedMusic = self.allMusic.subtracting(self.selectionState.selectedMessages) if !deselectedMusic.isEmpty, let musicItems = self.musicItems { for item in musicItems.items { if deselectedMusic.contains(item.message.id) { selectedSize -= item.size clearExcludeMessages.append(item.message) } } } } else { let selectedMusic = self.allMusic.intersection(self.selectionState.selectedMessages) if !selectedMusic.isEmpty, let musicItems = self.musicItems { for item in musicItems.items { if selectedMusic.contains(item.message.id) { selectedSize += item.size clearIncludeMessages.append(item.message) } } } } self.selectedSize = selectedSize self.clearExcludeMessages = clearExcludeMessages self.clearIncludeMessages = clearIncludeMessages } else { var selectedSize: Int64 = 0 for peerId in self.selectionState.selectedPeers { if let stats = self.stats.peers[peerId] { let peerSize = stats.stats.categories.values.reduce(0, { $0 + $1.size }) selectedSize += peerSize for (messageId, _) in self.messages { if messageId.peerId == peerId { if !self.selectionState.selectedMessages.contains(messageId) { inner: for (_, category) in stats.stats.categories { if let messageSize = category.messages[messageId] { selectedSize -= messageSize break inner } } } } } } } for messageId in self.selectionState.selectedMessages { for (_, category) in self.contextStats.categories { if let messageSize = category.messages[messageId] { if !self.selectionState.selectedPeers.contains(messageId.peerId) { selectedSize += messageSize } break } } } self.selectedSize = selectedSize self.clearIncludeMessages = [] self.clearExcludeMessages = [] } } } class View: UIView, UIScrollViewDelegate { private let scrollView: ScrollViewImpl private var aggregatedData: AggregatedData? private var otherCategories: Set = Set() private var cacheSettings: CacheStorageSettings? private var cacheSettingsExceptionCount: [CacheStorageSettings.PeerStorageCategory: Int32]? private var currentSelectedPanelId: AnyHashable? private var clearingDisplayTimestamp: Double? private var isClearing: Bool = false { didSet { if self.isClearing != oldValue { if self.isClearing { if self.keepScreenActiveDisposable == nil { self.keepScreenActiveDisposable = self.component?.context.sharedContext.applicationBindings.pushIdleTimerExtension() } } else { if let keepScreenActiveDisposable = self.keepScreenActiveDisposable { self.keepScreenActiveDisposable = nil keepScreenActiveDisposable.dispose() } } } } } private var isOtherCategoryExpanded: Bool = false private let navigationBackgroundView: BlurredBackgroundView private let navigationSeparatorLayer: SimpleLayer private let navigationSeparatorLayerContainer: SimpleLayer private let navigationEditButton = ComponentView() private let navigationDoneButton = ComponentView() private let headerView = ComponentView() private let headerOffsetContainer: UIView private let headerDescriptionView = ComponentView() private let headerProgressBackgroundLayer: SimpleLayer private let headerProgressForegroundLayer: SimpleLayer private var chartAvatarNode: AvatarNode? private var doneStatusCircle: SimpleShapeLayer? private var doneStatusNode: RadialStatusNode? private let scrollContainerView: UIView 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 let keepSizeTitleView = ComponentView() private let keepSizeView = ComponentView() private let keepSizeDescriptionView = ComponentView() private let panelContainer = ComponentView() private var selectionPanel: ComponentView? private var clearingNode: StorageUsageClearProgressOverlayNode? private var loadingView: UIActivityIndicatorView? private var component: StorageUsageScreenComponent? private weak var state: EmptyComponentState? private var navigationMetrics: (navigationHeight: CGFloat, statusBarHeight: CGFloat)? private var controller: (() -> ViewController?)? private var enableVelocityTracking: Bool = false private var previousVelocityM1: CGFloat = 0.0 private var previousVelocity: CGFloat = 0.0 private var ignoreScrolling: Bool = false private var statsDisposable: Disposable? private var messagesDisposable: Disposable? private var cacheSettingsDisposable: Disposable? private var keepScreenActiveDisposable: Disposable? override init(frame: CGRect) { self.headerOffsetContainer = UIView() self.headerOffsetContainer.isUserInteractionEnabled = false self.navigationBackgroundView = BlurredBackgroundView(color: nil, enableBlur: true) self.navigationBackgroundView.alpha = 0.0 self.navigationSeparatorLayer = SimpleLayer() self.navigationSeparatorLayer.opacity = 0.0 self.navigationSeparatorLayerContainer = SimpleLayer() self.navigationSeparatorLayerContainer.opacity = 0.0 self.scrollContainerView = UIView() 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.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.scrollContainerView) self.scrollContainerView.addSubview(self.keepDurationSectionContainerView) self.scrollView.layer.addSublayer(self.headerProgressBackgroundLayer) self.scrollView.layer.addSublayer(self.headerProgressForegroundLayer) self.addSubview(self.navigationBackgroundView) self.navigationSeparatorLayerContainer.addSublayer(self.navigationSeparatorLayer) self.layer.addSublayer(self.navigationSeparatorLayerContainer) self.addSubview(self.headerOffsetContainer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.statsDisposable?.dispose() self.messagesDisposable?.dispose() self.keepScreenActiveDisposable?.dispose() } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { self.enableVelocityTracking = true } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { if self.enableVelocityTracking { self.previousVelocityM1 = self.previousVelocity if let value = (scrollView.value(forKey: (["_", "verticalVelocity"] as [String]).joined()) as? NSNumber)?.doubleValue { self.previousVelocity = CGFloat(value) } } self.updateScrolling(transition: .immediate) } } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { guard let _ = self.navigationMetrics else { return } let paneAreaExpansionDistance: CGFloat = 32.0 let paneAreaExpansionFinalPoint: CGFloat = scrollView.contentSize.height - scrollView.bounds.height if targetContentOffset.pointee.y > paneAreaExpansionFinalPoint - paneAreaExpansionDistance && targetContentOffset.pointee.y < paneAreaExpansionFinalPoint { targetContentOffset.pointee.y = paneAreaExpansionFinalPoint self.enableVelocityTracking = false self.previousVelocity = 0.0 self.previousVelocityM1 = 0.0 } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View { let _ = panelContainerView let paneAreaExpansionFinalPoint: CGFloat = scrollView.contentSize.height - scrollView.bounds.height if abs(scrollView.contentOffset.y - paneAreaExpansionFinalPoint) < .ulpOfOne { //panelContainerView.transferVelocity(self.previousVelocityM1) } } } private func updateScrolling(transition: Transition) { let scrollBounds = self.scrollView.bounds let isLockedAtPanels = scrollBounds.maxY == self.scrollView.contentSize.height 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)) let navigationBackgroundAlpha: CGFloat = abs(headerOffset - minOffset) < 4.0 ? 1.0 : 0.0 animatedTransition.setAlpha(view: self.navigationBackgroundView, alpha: navigationBackgroundAlpha) animatedTransition.setAlpha(layer: self.navigationSeparatorLayerContainer, alpha: navigationBackgroundAlpha) var buttonsMasterAlpha: CGFloat = 1.0 if let component = self.component, component.peer != nil { buttonsMasterAlpha = 0.0 } else { if self.currentSelectedPanelId == nil || self.currentSelectedPanelId == AnyHashable("peers") { buttonsMasterAlpha = 1.0 } else { buttonsMasterAlpha = 0.0 } } let isSelectingPeers = self.aggregatedData?.isSelectingPeers ?? false if let navigationEditButtonView = self.navigationEditButton.view { animatedTransition.setAlpha(view: navigationEditButtonView, alpha: (isSelectingPeers ? 0.0 : 1.0) * buttonsMasterAlpha * navigationBackgroundAlpha) } if let navigationDoneButtonView = self.navigationDoneButton.view { animatedTransition.setAlpha(view: navigationDoneButtonView, alpha: (isSelectingPeers ? 1.0 : 0.0) * buttonsMasterAlpha * navigationBackgroundAlpha) } let expansionDistance: CGFloat = 32.0 var expansionDistanceFactor: CGFloat = abs(scrollBounds.maxY - self.scrollView.contentSize.height) / expansionDistance expansionDistanceFactor = max(0.0, min(1.0, expansionDistanceFactor)) transition.setAlpha(layer: self.navigationSeparatorLayer, alpha: expansionDistanceFactor) if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View { panelContainerView.updateNavigationMergeFactor(value: 1.0 - expansionDistanceFactor, transition: transition) } 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)) } let _ = self.panelContainer.updateEnvironment( transition: transition, environment: { StorageUsagePanelContainerEnvironment(isScrollable: isLockedAtPanels) } ) } func update(component: StorageUsageScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state let environment = environment[ViewControllerComponentContainer.Environment.self].value if self.aggregatedData == nil { let loadingView: UIActivityIndicatorView if let current = self.loadingView { loadingView = current } else { let style: UIActivityIndicatorView.Style if environment.theme.overallDarkAppearance { style = .whiteLarge } else { if #available(iOS 13.0, *) { style = .large } else { style = .gray } } loadingView = UIActivityIndicatorView(style: style) self.loadingView = loadingView loadingView.sizeToFit() self.insertSubview(loadingView, belowSubview: self.scrollView) } let loadingViewSize = loadingView.bounds.size transition.setFrame(view: loadingView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - loadingViewSize.width) / 2.0), y: floor((availableSize.height - loadingViewSize.height) / 2.0)), size: loadingViewSize)) if !loadingView.isAnimating { loadingView.startAnimating() } } else { if let loadingView = self.loadingView { self.loadingView = nil if environment.isVisible { loadingView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak loadingView] _ in loadingView?.removeFromSuperview() }) } else { loadingView.removeFromSuperview() } } } if self.statsDisposable == nil { let context = component.context let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings])) let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = component.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 |> mapToSignal { accountSpecificSettings -> Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> in return context.engine.data.get( EngineDataMap(accountSpecificSettings.peerStorageTimeoutExceptions.map(\.key).map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) ) |> map { peers -> [CacheStorageSettings.PeerStorageCategory: Int32] in var result: [CacheStorageSettings.PeerStorageCategory: Int32] = [:] for (_, peer) in peers { guard let peer else { continue } switch peer { case .user, .secretChat: result[.privateChats, default: 0] += 1 case .legacyGroup: result[.groups, default: 0] += 1 case let .channel(channel): if case .group = channel.info { result[.groups, default: 0] += 1 } else { result[.channels, default: 0] += 1 } } } return result } } self.cacheSettingsDisposable = (combineLatest(queue: .mainQueue(), 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 }, cacheSettingsExceptionCount ) |> deliverOnMainQueue).start(next: { [weak self] cacheSettings, cacheSettingsExceptionCount in guard let self else { return } self.cacheSettings = cacheSettings self.cacheSettingsExceptionCount = cacheSettingsExceptionCount if self.aggregatedData != nil { self.state?.updated(transition: .immediate) } }) self.reloadStats(firstTime: true, completion: {}) } var wasLockedAtPanels = false if let panelContainerView = self.panelContainer.view, let navigationMetrics = self.navigationMetrics { if self.scrollView.bounds.minY > 0.0 && abs(self.scrollView.bounds.minY - (panelContainerView.frame.minY - navigationMetrics.navigationHeight)) <= UIScreenPixel { wasLockedAtPanels = true } } let animationHint = transition.userData(AnimationHint.self) if let animationHint { if case .firstStatsUpdate = animationHint.value { let alphaTransition: Transition if environment.isVisible { alphaTransition = .easeInOut(duration: 0.25) } else { alphaTransition = .immediate } alphaTransition.setAlpha(view: self.scrollView, alpha: self.aggregatedData != nil ? 1.0 : 0.0) alphaTransition.setAlpha(view: self.headerOffsetContainer, alpha: self.aggregatedData != nil ? 1.0 : 0.0) } else if case .clearedItems = animationHint.value { if let snapshotView = self.scrollContainerView.snapshotView(afterScreenUpdates: false) { snapshotView.frame = self.scrollContainerView.frame self.scrollView.insertSubview(snapshotView, aboveSubview: self.scrollContainerView) self.scrollContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } } } else { transition.setAlpha(view: self.scrollView, alpha: self.aggregatedData != nil ? 1.0 : 0.0) transition.setAlpha(view: self.headerOffsetContainer, alpha: self.aggregatedData != nil ? 1.0 : 0.0) } 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) let navigationSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationFrame.maxY), size: CGSize(width: availableSize.width, height: UIScreenPixel)) transition.setFrame(layer: self.navigationSeparatorLayerContainer, frame: navigationSeparatorFrame) transition.setFrame(layer: self.navigationSeparatorLayer, frame: CGRect(origin: CGPoint(), size: navigationSeparatorFrame.size)) let navigationEditButtonSize = self.navigationEditButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(Text(text: environment.strings.Common_Edit, font: Font.regular(17.0), color: environment.theme.rootController.navigationBar.accentTextColor)), action: { [weak self] in guard let self else { return } if let aggregatedData = self.aggregatedData, !aggregatedData.isSelectingPeers { aggregatedData.isSelectingPeers = true self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } } ).minSize(CGSize(width: 16.0, height: environment.navigationHeight - environment.statusBarHeight))), environment: {}, containerSize: CGSize(width: 150.0, height: environment.navigationHeight - environment.statusBarHeight) ) if let navigationEditButtonView = self.navigationEditButton.view { if navigationEditButtonView.superview == nil { self.addSubview(navigationEditButtonView) } transition.setFrame(view: navigationEditButtonView, frame: CGRect(origin: CGPoint(x: availableSize.width - 12.0 - environment.safeInsets.right - navigationEditButtonSize.width, y: environment.statusBarHeight), size: navigationEditButtonSize)) } let navigationDoneButtonSize = self.navigationDoneButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(Text(text: environment.strings.Common_Done, font: Font.semibold(17.0), color: environment.theme.rootController.navigationBar.accentTextColor)), action: { [weak self] in guard let self, let aggregatedData = self.aggregatedData else { return } aggregatedData.isSelectingPeers = false aggregatedData.clearPeerSelection() self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } ).minSize(CGSize(width: 16.0, height: environment.navigationHeight - environment.statusBarHeight))), environment: {}, containerSize: CGSize(width: 150.0, height: environment.navigationHeight - environment.statusBarHeight) ) if let navigationDoneButtonView = self.navigationDoneButton.view { if navigationDoneButtonView.superview == nil { self.addSubview(navigationDoneButtonView) } transition.setFrame(view: navigationDoneButtonView, frame: CGRect(origin: CGPoint(x: availableSize.width - 12.0 - environment.safeInsets.right - navigationDoneButtonSize.width, y: environment.statusBarHeight), size: navigationDoneButtonSize)) } let navigationRightButtonMaxWidth: CGFloat = max(navigationEditButtonSize.width, navigationDoneButtonSize.width) self.backgroundColor = environment.theme.list.blocksBackgroundColor var contentHeight: CGFloat = 0.0 let topInset: CGFloat = 19.0 let sideInset: CGFloat = 16.0 + environment.safeInsets.left var bottomInset: CGFloat = environment.safeInsets.bottom var bottomPanelSelectionData: (size: Int64, isComplete: Bool)? if let aggregatedData = self.aggregatedData { if let _ = component.peer { bottomPanelSelectionData = (aggregatedData.selectedSize, true) } else if !aggregatedData.selectionState.isEmpty { bottomPanelSelectionData = (aggregatedData.selectedSize, false) } } if let bottomPanelSelectionData { let selectionPanel: ComponentView var selectionPanelTransition = transition if let current = self.selectionPanel { selectionPanel = current } else { selectionPanelTransition = .immediate selectionPanel = ComponentView() self.selectionPanel = selectionPanel } let selectionPanelSize = selectionPanel.update( transition: selectionPanelTransition, component: AnyComponent(BottomButtonPanelComponent( theme: environment.theme, title: bottomPanelSelectionData.isComplete ? environment.strings.StorageManagement_ClearCache : environment.strings.StorageManagement_ClearSelected, label: bottomPanelSelectionData.size == 0 ? nil : dataSizeString(Int(bottomPanelSelectionData.size), formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")), isEnabled: bottomPanelSelectionData.size != 0, insets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: environment.safeInsets.bottom, right: sideInset), action: { [weak self] in guard let self else { return } self.requestClear(fromCategories: false) } )), environment: {}, containerSize: availableSize ) if let selectionPanelView = selectionPanel.view { var animateIn = false if selectionPanelView.superview == nil { self.addSubview(selectionPanelView) animateIn = true } selectionPanelTransition.setFrame(view: selectionPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - selectionPanelSize.height), size: selectionPanelSize)) if animateIn { transition.animatePosition(view: selectionPanelView, from: CGPoint(x: 0.0, y: selectionPanelSize.height), to: CGPoint(), additive: true) } } bottomInset = selectionPanelSize.height } else if let selectionPanel = self.selectionPanel { self.selectionPanel = nil if let selectionPanelView = selectionPanel.view { transition.setPosition(view: selectionPanelView, position: CGPoint(x: selectionPanelView.center.x, y: availableSize.height + selectionPanelView.bounds.height * 0.5), completion: { [weak selectionPanelView] _ in selectionPanelView?.removeFromSuperview() }) } } contentHeight += environment.statusBarHeight + topInset let allCategories: [Category] = [ .photos, .videos, .files, .music, .stickers, .avatars, .misc, .stories ] var listCategories: [StorageCategoriesComponent.CategoryData] = [] var totalSize: Int64 = 0 var totalSelectedCategorySize: Int64 = 0 if let aggregatedData = self.aggregatedData { for (key, value) in aggregatedData.contextStats.categories { totalSize += value.size if aggregatedData.selectedCategories.isEmpty || aggregatedData.selectedCategories.contains(Category(key)) { totalSelectedCategorySize += value.size } } for category in allCategories { 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 .stories: mappedCategory = .stories case .other: continue } var categorySize: Int64 = 0 if let categoryData = aggregatedData.contextStats.categories[mappedCategory] { categorySize = categoryData.size } let categoryFraction: Double if !aggregatedData.selectedCategories.isEmpty && !aggregatedData.selectedCategories.contains(category) { categoryFraction = 0.0 } else if categorySize == 0 || totalSelectedCategorySize == 0 { categoryFraction = 0.0 } else { categoryFraction = Double(categorySize) / Double(totalSelectedCategorySize) } if categorySize != 0 { listCategories.append(StorageCategoriesComponent.CategoryData( key: category, color: category.color, title: category.title(strings: environment.strings), size: categorySize, sizeFraction: categoryFraction, isSelected: aggregatedData.selectedCategories.contains(category), subcategories: [])) } } } listCategories.sort(by: { $0.size > $1.size }) var otherListCategories: [StorageCategoriesComponent.CategoryData] = [] if listCategories.count > 5 { for i in (4 ..< listCategories.count).reversed() { otherListCategories.insert(listCategories[i], at: 0) listCategories.remove(at: i) } } self.otherCategories = Set(otherListCategories.map(\.key)) var chartItems: [PieChartComponent.ChartData.Item] = [] for listCategory in listCategories { var categoryChartFraction: CGFloat = listCategory.sizeFraction if let aggregatedData = self.aggregatedData, !aggregatedData.selectedCategories.isEmpty && !aggregatedData.selectedCategories.contains(listCategory.key) { categoryChartFraction = 0.0 } chartItems.append(PieChartComponent.ChartData.Item(id: listCategory.key, displayValue: listCategory.sizeFraction, displaySize: listCategory.size, value: categoryChartFraction, color: listCategory.color, particle: listCategory.key.particle, title: listCategory.key.title(strings: environment.strings), mergeable: false, mergeFactor: 1.0)) } var totalOtherSize: Int64 = 0 for listCategory in otherListCategories { totalOtherSize += listCategory.size } if !otherListCategories.isEmpty { let categoryFraction: Double = otherListCategories.reduce(0.0, { $0 + $1.sizeFraction }) let isSelected = otherListCategories.allSatisfy { item in return self.aggregatedData?.selectedCategories.contains(item.key) ?? false } let listColor: UIColor if self.isOtherCategoryExpanded { listColor = Category.other.color } else { listColor = Category.misc.color } listCategories.append(StorageCategoriesComponent.CategoryData( key: Category.other, color: listColor, title: Category.other.title(strings: environment.strings), size: totalOtherSize, sizeFraction: categoryFraction, isSelected: isSelected, subcategories: otherListCategories)) } var otherSum: CGFloat = 0.0 var otherRealSum: CGFloat = 0.0 for listCategory in otherListCategories { var categoryChartFraction: CGFloat = listCategory.sizeFraction if let aggregatedData = self.aggregatedData, !aggregatedData.selectedCategories.isEmpty, !aggregatedData.selectedCategories.contains(listCategory.key) { categoryChartFraction = 0.0 } var chartItem = PieChartComponent.ChartData.Item(id: listCategory.key, displayValue: listCategory.sizeFraction, displaySize: listCategory.size, value: categoryChartFraction, color: listCategory.color, particle: listCategory.key.particle, title: listCategory.key.title(strings: environment.strings), mergeable: false, mergeFactor: 1.0) if chartItem.value > 0.00001 { chartItem.value = max(chartItem.value, 0.01) } otherSum += chartItem.value otherRealSum += chartItem.displayValue if !self.isOtherCategoryExpanded { chartItem.value = 0.0 } chartItems.append(chartItem) } if !listCategories.isEmpty { chartItems.append(PieChartComponent.ChartData.Item(id: AnyHashable(Category.other), displayValue: otherRealSum, displaySize: totalOtherSize, value: self.isOtherCategoryExpanded ? 0.0 : otherSum, color: Category.misc.color, particle: Category.misc.particle, title: Category.misc.title(strings: environment.strings), mergeable: false, mergeFactor: 1.0)) } let chartData = PieChartComponent.ChartData(items: chartItems) self.pieChartView.parentState = state var pieChartTransition = transition if transition.animation.isImmediate, let animationHint, case .clearedItems = animationHint.value { pieChartTransition = Transition(animation: .curve(duration: 0.4, curve: .spring)) } let pieChartSize = self.pieChartView.update( transition: pieChartTransition, component: AnyComponent(PieChartComponent( theme: environment.theme, strings: environment.strings, emptyColor: UIColor(rgb: 0x34C759), 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) } pieChartTransition.setFrame(view: pieChartComponentView, frame: pieChartFrame) } if let _ = self.aggregatedData, listCategories.isEmpty { let checkColor = UIColor(rgb: 0x34C759) let doneStatusNode: RadialStatusNode var animateIn = false if let current = self.doneStatusNode { doneStatusNode = current } else { doneStatusNode = RadialStatusNode(backgroundNodeColor: .clear) self.doneStatusNode = doneStatusNode self.scrollView.addSubnode(doneStatusNode) animateIn = true } let doneSize = CGSize(width: 100.0, height: 100.0) doneStatusNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - doneSize.width) / 2.0), y: contentHeight), size: doneSize) let doneStatusCircle: SimpleShapeLayer if let current = self.doneStatusCircle { doneStatusCircle = current } else { doneStatusCircle = SimpleShapeLayer() self.doneStatusCircle = doneStatusCircle //self.scrollView.layer.addSublayer(doneStatusCircle) doneStatusCircle.opacity = 0.0 } if animateIn { Queue.mainQueue().after(0.18, { doneStatusNode.transitionToState(.check(checkColor), animated: true) doneStatusCircle.opacity = 1.0 doneStatusCircle.animateAlpha(from: 0.0, to: 1.0, duration: 0.12) }) } doneStatusCircle.lineWidth = 6.0 doneStatusCircle.strokeColor = checkColor.cgColor doneStatusCircle.fillColor = nil doneStatusCircle.path = UIBezierPath(ovalIn: CGRect(origin: CGPoint(x: doneStatusCircle.lineWidth * 0.5, y: doneStatusCircle.lineWidth * 0.5), size: CGSize(width: doneSize.width - doneStatusCircle.lineWidth * 0.5, height: doneSize.height - doneStatusCircle.lineWidth * 0.5))).cgPath doneStatusCircle.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - doneSize.width) / 2.0), y: contentHeight), size: doneSize).insetBy(dx: -doneStatusCircle.lineWidth * 0.5, dy: -doneStatusCircle.lineWidth * 0.5) contentHeight += doneSize.height } else { contentHeight += pieChartSize.height if let doneStatusNode = self.doneStatusNode { self.doneStatusNode = nil doneStatusNode.removeFromSupernode() } if let doneStatusCircle = self.doneStatusCircle { self.doneStatusCircle = nil doneStatusCircle.removeFromSuperlayer() } } contentHeight += 23.0 let headerText: String if listCategories.isEmpty { headerText = environment.strings.StorageManagement_TitleCleared } else if let peer = component.peer { if peer.id == component.context.account.peerId { headerText = environment.strings.DialogList_SavedMessages } else { headerText = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast) } } else { headerText = environment.strings.StorageManagement_Title } let headerViewSize = self.headerView.update( transition: transition, component: AnyComponent(Text(text: headerText, font: Font.semibold(20.0), color: environment.theme.list.itemPrimaryTextColor)), environment: {}, containerSize: CGSize(width: floor((availableSize.width - navigationRightButtonMaxWidth * 2.0) / 0.8), 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 += 6.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) var usageFraction: Double = 0.0 let totalUsageText: String if listCategories.isEmpty { totalUsageText = environment.strings.StorageManagement_DescriptionCleared } else if let aggregatedData = self.aggregatedData { var totalStatsSize: Int64 = 0 for (_, value) in aggregatedData.contextStats.categories { totalStatsSize += value.size } if let _ = component.peer { var allStatsSize: Int64 = 0 for (_, value) in aggregatedData.stats.totalStats.categories { allStatsSize += value.size } let fraction: Double if allStatsSize != 0 { fraction = Double(totalStatsSize) / Double(allStatsSize) } else { fraction = 0.0 } usageFraction = fraction let fractionValue: Double = floor(fraction * 100.0 * 10.0) / 10.0 let fractionString: String if fractionValue < 0.1 { fractionString = "<0.1" } else if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 { fractionString = "\(Int(fractionValue))" } else { fractionString = "\(fractionValue)" } totalUsageText = environment.strings.StorageManagement_DescriptionChatUsage(fractionString).string } else { let fraction: Double if aggregatedData.stats.deviceFreeSpace != 0 && totalStatsSize != 0 { fraction = Double(totalStatsSize) / Double(aggregatedData.stats.deviceFreeSpace + totalStatsSize) } else { fraction = 0.0 } usageFraction = fraction let fractionValue: Double = floor(fraction * 100.0 * 10.0) / 10.0 let fractionString: String if fractionValue < 0.1 { fractionString = "<0.1" } else if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 { fractionString = "\(Int(fractionValue))" } else { fractionString = "\(fractionValue)" } totalUsageText = environment.strings.StorageManagement_DescriptionAppUsage(fractionString).string } } else { totalUsageText = " " } let headerDescriptionSize = self.headerDescriptionView.update( transition: transition, component: AnyComponent(MultilineTextComponent(text: .markdown(text: totalUsageText, attributes: MarkdownAttributes( body: body, bold: bold, link: body, linkAttribute: { _ in nil } )), horizontalAlignment: .center, 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.scrollContainerView.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 = usageFraction transition.setFrame(layer: self.headerProgressForegroundLayer, frame: CGRect(origin: headerProgressFrame.origin, size: CGSize(width: max(headerProgressFrame.height, 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 transition.setAlpha(layer: self.headerProgressBackgroundLayer, alpha: listCategories.isEmpty ? 0.0 : 1.0) transition.setAlpha(layer: self.headerProgressForegroundLayer, alpha: listCategories.isEmpty ? 0.0 : 1.0) contentHeight += 24.0 if let peer = component.peer { let avatarSize = CGSize(width: 72.0, height: 72.0) let avatarFrame: CGRect = CGRect(origin: CGPoint(x: pieChartFrame.minX + floor((pieChartFrame.width - avatarSize.width) / 2.0), y: pieChartFrame.minY + floor((pieChartFrame.height - avatarSize.height) / 2.0)), size: avatarSize) let chartAvatarNode: AvatarNode if let current = self.chartAvatarNode { chartAvatarNode = current transition.setFrame(view: chartAvatarNode.view, frame: avatarFrame) } else { chartAvatarNode = AvatarNode(font: avatarPlaceholderFont(size: 17.0)) self.chartAvatarNode = chartAvatarNode self.scrollContainerView.addSubview(chartAvatarNode.view) chartAvatarNode.frame = avatarFrame if peer.id == component.context.account.peerId { chartAvatarNode.setPeer(context: component.context, theme: environment.theme, peer: peer, overrideImage: .savedMessagesIcon, displayDimensions: avatarSize) } else { chartAvatarNode.setPeer(context: component.context, theme: environment.theme, peer: peer, displayDimensions: avatarSize) } } transition.setAlpha(view: chartAvatarNode.view, alpha: listCategories.isEmpty ? 0.0 : 1.0) } else { let sizeText = dataSizeString(Int(totalSelectedCategorySize), forceDecimal: true, formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")) var animatedTextItems: [AnimatedTextComponent.Item] = [] var remainingSizeText = sizeText if let index = remainingSizeText.firstIndex(of: ".") { animatedTextItems.append(AnimatedTextComponent.Item(id: "n-full", content: .text(String(remainingSizeText[remainingSizeText.startIndex ..< index])))) animatedTextItems.append(AnimatedTextComponent.Item(id: "dot", content: .text("."))) remainingSizeText = String(remainingSizeText[remainingSizeText.index(after: index)...]) } if let index = remainingSizeText.firstIndex(of: " ") { animatedTextItems.append(AnimatedTextComponent.Item(id: "n-fract", content: .text(String(remainingSizeText[remainingSizeText.startIndex ..< index])))) remainingSizeText = String(remainingSizeText[index...]) } if !remainingSizeText.isEmpty { animatedTextItems.append(AnimatedTextComponent.Item(id: "rest", isUnbreakable: true, content: .text(remainingSizeText))) } let chartTotalLabelSize = self.chartTotalLabel.update( transition: transition, /*component: AnyComponent(Text( text: dataSizeString(Int(totalSelectedCategorySize), formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")), font: Font.with(size: 20.0, design: .round, weight: .bold), color: environment.theme.list.itemPrimaryTextColor )),*/ component: AnyComponent(AnimatedTextComponent( font: Font.with(size: 20.0, design: .round, weight: .bold), color: environment.theme.list.itemPrimaryTextColor, items: animatedTextItems )), environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) ) if let chartTotalLabelView = self.chartTotalLabel.view { if chartTotalLabelView.superview == nil { self.scrollContainerView.addSubview(chartTotalLabelView) } let totalLabelFrame = 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) transition.setFrame(view: chartTotalLabelView, frame: totalLabelFrame) transition.setAlpha(view: chartTotalLabelView, alpha: listCategories.isEmpty ? 0.0 : 1.0) } } if !listCategories.isEmpty { self.categoriesView.parentState = state let categoriesSize = self.categoriesView.update( transition: transition, component: AnyComponent(StorageCategoriesComponent( theme: environment.theme, strings: environment.strings, categories: listCategories, isOtherExpanded: self.isOtherCategoryExpanded, displayAction: component.peer == nil, toggleCategorySelection: { [weak self] key in guard let self, let aggregatedData = self.aggregatedData else { return } if key == Category.other { let otherCategories = self.otherCategories.filter(aggregatedData.existingCategories.contains) if !otherCategories.isEmpty { if otherCategories.allSatisfy(aggregatedData.selectedCategories.contains) { for item in otherCategories { aggregatedData.setIsCategorySelected(category: item, isSelected: false) } } else { for item in otherCategories { aggregatedData.setIsCategorySelected(category: item, isSelected: true) } } } } else { if aggregatedData.selectedCategories.contains(key) { aggregatedData.setIsCategorySelected(category: key, isSelected: false) } else { aggregatedData.setIsCategorySelected(category: key, isSelected: true) } } self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }, toggleOtherExpanded: { [weak self] in guard let self else { return } self.isOtherCategoryExpanded = !self.isOtherCategoryExpanded self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }, clearAction: { [weak self] in guard let self else { return } self.requestClear(fromCategories: true) } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude) ) if let categoriesComponentView = self.categoriesView.view { if categoriesComponentView.superview == nil { self.scrollContainerView.addSubview(categoriesComponentView) } transition.setFrame(view: categoriesComponentView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: categoriesSize)) } contentHeight += categoriesSize.height contentHeight += 8.0 let categoriesDescriptionSize = self.categoriesDescriptionView.update( transition: transition, component: AnyComponent(MultilineTextComponent(text: .markdown(text: environment.strings.StorageManagement_SectionsDescription, 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.scrollContainerView.addSubview(categoriesDescriptionComponentView) } transition.setFrame(view: categoriesDescriptionComponentView, frame: categoriesDescriptionFrame) } contentHeight += categoriesDescriptionSize.height contentHeight += 40.0 } else { self.categoriesView.view?.removeFromSuperview() self.categoriesDescriptionView.view?.removeFromSuperview() } if component.peer == nil { let keepDurationTitleSize = self.keepDurationTitleView.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .markdown( text: environment.strings.StorageManagement_AutoremoveHeader, 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.scrollContainerView.addSubview(keepDurationTitleComponentView) } transition.setFrame(view: keepDurationTitleComponentView, frame: keepDurationTitleFrame) } contentHeight += keepDurationTitleSize.height contentHeight += 8.0 var keepContentHeight: CGFloat = 0.0 for i in 0 ..< 4 { let item: ComponentView if let current = self.keepDurationItems[i] { item = current } else { item = ComponentView() self.keepDurationItems[i] = item } let mappedCategory: CacheStorageSettings.PeerStorageCategory let iconName: String let title: String switch i { case 0: iconName = "Settings/Menu/EditProfile" title = environment.strings.Notifications_PrivateChats mappedCategory = .privateChats case 1: iconName = "Settings/Menu/GroupChats" title = environment.strings.Notifications_GroupChats mappedCategory = .groups case 3: iconName = "Settings/Menu/Stories" title = environment.strings.Notifications_Stories mappedCategory = .stories default: iconName = "Settings/Menu/Channels" title = environment.strings.Notifications_Channels mappedCategory = .channels } let value = self.cacheSettings?.categoryStorageTimeout[mappedCategory] ?? Int32.max let optionText: String if value == Int32.max { optionText = environment.strings.ClearCache_Never } else { optionText = timeIntervalString(strings: environment.strings, value: value) } var subtitle: String? if mappedCategory != .stories { if let cacheSettingsExceptionCount = self.cacheSettingsExceptionCount, let categoryCount = cacheSettingsExceptionCount[mappedCategory] { subtitle = environment.strings.CacheEvictionMenu_CategoryExceptions(Int32(categoryCount)) } } let itemSize = item.update( transition: transition, component: AnyComponent(StoragePeerTypeItemComponent( theme: environment.theme, iconName: iconName, title: title, subtitle: subtitle, value: optionText, hasNext: i != 4 - 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 let keepDurationDescriptionSize = self.keepDurationDescriptionView.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .markdown( text: environment.strings.StorageManagement_AutoremoveDescription, 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.scrollContainerView.addSubview(keepDurationDescriptionComponentView) } transition.setFrame(view: keepDurationDescriptionComponentView, frame: keepDurationDescriptionFrame) } contentHeight += keepDurationDescriptionSize.height contentHeight += 40.0 let keepSizeTitleSize = self.keepSizeTitleView.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .markdown( text: environment.strings.Cache_MaximumCacheSize.uppercased(), 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 keepSizeTitleFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepSizeTitleSize) if let keepSizeTitleComponentView = self.keepSizeTitleView.view { if keepSizeTitleComponentView.superview == nil { self.scrollContainerView.addSubview(keepSizeTitleComponentView) } transition.setFrame(view: keepSizeTitleComponentView, frame: keepSizeTitleFrame) } contentHeight += keepSizeTitleSize.height contentHeight += 8.0 let keepSizeSize = self.keepSizeView.update( transition: transition, component: AnyComponent(StorageKeepSizeComponent( theme: environment.theme, strings: environment.strings, value: cacheSettings?.defaultCacheStorageLimitGigabytes ?? 16, updateValue: { [weak self] value in guard let self, let component = self.component else { return } let value = max(5, value) let _ = updateCacheStorageSettingsInteractively(accountManager: component.context.sharedContext.accountManager, { current in var current = current current.defaultCacheStorageLimitGigabytes = value return current }).start() } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let keepSizeFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: keepSizeSize) if let keepSizeComponentView = self.keepSizeView.view { if keepSizeComponentView.superview == nil { self.scrollContainerView.addSubview(keepSizeComponentView) } transition.setFrame(view: keepSizeComponentView, frame: keepSizeFrame) } contentHeight += keepSizeSize.height contentHeight += 8.0 let keepSizeDescriptionSize = self.keepSizeDescriptionView.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .markdown( text: environment.strings.StorageManagement_AutoremoveSpaceDescription, 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 keepSizeDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepSizeDescriptionSize) if let keepSizeDescriptionComponentView = self.keepSizeDescriptionView.view { if keepSizeDescriptionComponentView.superview == nil { self.scrollContainerView.addSubview(keepSizeDescriptionComponentView) } transition.setFrame(view: keepSizeDescriptionComponentView, frame: keepSizeDescriptionFrame) } contentHeight += keepSizeDescriptionSize.height contentHeight += 40.0 } var panelItems: [StorageUsagePanelContainerComponent.Item] = [] if let aggregatedData = self.aggregatedData, let peerItems = aggregatedData.peerItems, !peerItems.items.isEmpty, !listCategories.isEmpty { panelItems.append(StorageUsagePanelContainerComponent.Item( id: "peers", title: environment.strings.StorageManagement_TabChats, panel: AnyComponent(StoragePeerListPanelComponent( context: component.context, items: peerItems, selectionState: aggregatedData.isSelectingPeers ? aggregatedData.selectionState : nil, peerAction: { [weak self] peer in guard let self, let aggregatedData = self.aggregatedData else { return } if aggregatedData.isSelectingPeers { aggregatedData.togglePeerSelection(id: peer.id) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } else { self.openPeer(peer: peer) } }, contextAction: { [weak self] peer, sourceView, gesture in guard let self, let component = self.component else { return } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } var itemList: [ContextMenuItem] = [] itemList.append(.action(ContextMenuActionItem( text: presentationData.strings.StorageManagement_PeerShowDetails, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c.dismiss(completion: { [weak self] in guard let self else { return } self.openPeer(peer: peer) }) }) )) itemList.append(.action(ContextMenuActionItem( text: presentationData.strings.StorageManagement_PeerOpenProfile, icon: { theme in if case .user = peer { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.contextMenu.primaryColor) } else { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Groups"), color: theme.contextMenu.primaryColor) } }, action: { [weak self] c, _ in c.dismiss(completion: { [weak self] in guard let self, let component = self.component, let controller = self.controller?() else { return } let peerInfoController = component.context.sharedContext.makePeerInfoController( context: component.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil ) if let peerInfoController { controller.push(peerInfoController) } }) }) )) itemList.append(.action(ContextMenuActionItem( text: presentationData.strings.StorageManagement_ContextSelect, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c.dismiss(completion: { }) guard let self, let aggregatedData = self.aggregatedData else { return } aggregatedData.togglePeerSelection(id: peer.id) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }) )) let items = ContextController.Items(content: .list(itemList)) let controller = ContextController( presentationData: presentationData, source: .extracted(StorageUsageListContextExtractedContentSource(contentView: sourceView)), items: .single(items), recognizer: nil, gesture: gesture) self.controller?()?.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismiss() } return true }) self.controller?()?.presentInGlobalOverlay(controller) } )) )) } if let aggregatedData = self.aggregatedData, let imageItems = aggregatedData.imageItems, !imageItems.items.isEmpty, !listCategories.isEmpty { panelItems.append(StorageUsagePanelContainerComponent.Item( id: "images", title: environment.strings.StorageManagement_TabMedia, panel: AnyComponent(StorageMediaGridPanelComponent( context: component.context, items: aggregatedData.imageItems, selectionState: aggregatedData.selectionState, action: { [weak self] messageId in guard let self, let aggregatedData = self.aggregatedData else { return } guard let _ = aggregatedData.messages[messageId] else { return } aggregatedData.toggleMessageSelection(id: messageId) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }, contextAction: { [weak self] messageId, containerView, sourceRect, gesture in guard let self else { return } self.messageGaleryContextAction(messageId: messageId, sourceView: containerView, sourceRect: sourceRect, gesture: gesture) } )) )) } if let aggregatedData = self.aggregatedData, let fileItems = aggregatedData.fileItems, !fileItems.items.isEmpty, !listCategories.isEmpty { panelItems.append(StorageUsagePanelContainerComponent.Item( id: "files", title: environment.strings.StorageManagement_TabFiles, panel: AnyComponent(StorageFileListPanelComponent( context: component.context, items: fileItems, selectionState: aggregatedData.selectionState, action: { [weak self] messageId in guard let self, let aggregatedData = self.aggregatedData else { return } guard let _ = aggregatedData.messages[messageId] else { return } aggregatedData.toggleMessageSelection(id: messageId) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }, contextAction: { [weak self] messageId, containerView, gesture in guard let self else { return } self.messageContextAction(messageId: messageId, sourceView: containerView, gesture: gesture) } )) )) } if let aggregatedData = self.aggregatedData, let musicItems = aggregatedData.musicItems, !musicItems.items.isEmpty, !listCategories.isEmpty { panelItems.append(StorageUsagePanelContainerComponent.Item( id: "music", title: environment.strings.StorageManagement_TabMusic, panel: AnyComponent(StorageFileListPanelComponent( context: component.context, items: musicItems, selectionState: aggregatedData.selectionState, action: { [weak self] messageId in guard let self, let aggregatedData = self.aggregatedData else { return } guard let _ = aggregatedData.messages[messageId] else { return } aggregatedData.toggleMessageSelection(id: messageId) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }, contextAction: { [weak self] messageId, containerView, gesture in guard let self else { return } self.messageContextAction(messageId: messageId, sourceView: containerView, gesture: gesture) } )) )) } if !panelItems.isEmpty { let panelContainerSize = self.panelContainer.update( transition: transition, component: AnyComponent(StorageUsagePanelContainerComponent( theme: environment.theme, strings: environment.strings, dateTimeFormat: environment.dateTimeFormat, insets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: bottomInset, right: environment.safeInsets.right), items: panelItems, currentPanelUpdated: { [weak self] id, transition in guard let self else { return } self.currentSelectedPanelId = id self.state?.updated(transition: transition) } )), environment: { StorageUsagePanelContainerEnvironment(isScrollable: wasLockedAtPanels) }, containerSize: CGSize(width: availableSize.width, height: availableSize.height - environment.navigationHeight) ) if let panelContainerView = self.panelContainer.view { if panelContainerView.superview == nil { self.scrollContainerView.addSubview(panelContainerView) } transition.setFrame(view: panelContainerView, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: panelContainerSize)) } contentHeight += panelContainerSize.height } else { self.panelContainer.view?.removeFromSuperview() } self.ignoreScrolling = true let contentOffset = self.scrollView.bounds.minY transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center) let contentSize = CGSize(width: availableSize.width, height: contentHeight) if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize } transition.setFrame(view: self.scrollContainerView, frame: CGRect(origin: CGPoint(), size: contentSize)) var scrollViewBounds = self.scrollView.bounds scrollViewBounds.size = availableSize if wasLockedAtPanels, let panelContainerView = self.panelContainer.view { scrollViewBounds.origin.y = panelContainerView.frame.minY - environment.navigationHeight } transition.setBounds(view: self.scrollView, bounds: scrollViewBounds) if !wasLockedAtPanels && !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) if self.isClearing { let clearingNode: StorageUsageClearProgressOverlayNode var animateIn = false if let current = self.clearingNode { clearingNode = current } else { animateIn = true clearingNode = StorageUsageClearProgressOverlayNode(presentationData: component.context.sharedContext.currentPresentationData.with { $0 }) self.clearingNode = clearingNode self.addSubnode(clearingNode) self.clearingDisplayTimestamp = CFAbsoluteTimeGetCurrent() } let clearingSize = CGSize(width: availableSize.width, height: availableSize.height) clearingNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - clearingSize.width) / 2.0), y: floor((availableSize.height - clearingSize.height) / 2.0)), size: clearingSize) clearingNode.updateLayout(size: clearingSize, bottomInset: environment.safeInsets.bottom, transition: .immediate) if animateIn { clearingNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, delay: 0.4) } } else { if let clearingNode = self.clearingNode { self.clearingNode = nil var delay: Double = 0.0 if let clearingDisplayTimestamp = self.clearingDisplayTimestamp { let timeDelta = CFAbsoluteTimeGetCurrent() - clearingDisplayTimestamp if timeDelta < 0.4 { delay = 0.0 } else if timeDelta < 1.0 { delay = 1.0 } } if delay == 0.0 { let animationTransition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut)) animationTransition.setAlpha(view: clearingNode.view, alpha: 0.0, completion: { [weak clearingNode] _ in clearingNode?.removeFromSupernode() }) } else { clearingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, delay: delay, removeOnCompletion: false, completion: { [weak clearingNode] _ in clearingNode?.removeFromSupernode() }) } } } return availableSize } private func reportClearedStorage(size: Int64) { guard let component = self.component else { return } guard let controller = self.controller?() else { return } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } controller.present(UndoOverlayController(presentationData: presentationData, content: .succeed(text: presentationData.strings.ClearCache_Success("\(dataSizeString(size, formatting: DataSizeStringFormatting(presentationData: presentationData)))", stringForDeviceType()).string, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) } private func reloadStats(firstTime: Bool, completion: @escaping () -> Void) { guard let component = self.component else { completion() return } self.statsDisposable = (component.context.engine.resources.collectStorageUsageStats() |> deliverOnMainQueue).start(next: { [weak self] stats in guard let self, let component = self.component else { completion() return } var peerItems: [StoragePeerListPanelComponent.Item] = [] if component.peer == nil { for item in stats.peers.values.sorted(by: { lhs, rhs in let lhsSize: Int64 = lhs.stats.categories.values.reduce(0, { $0 + $1.size }) let rhsSize: Int64 = rhs.stats.categories.values.reduce(0, { $0 + $1.size }) return lhsSize > rhsSize }) { let itemSize: Int64 = item.stats.categories.values.reduce(0, { $0 + $1.size }) peerItems.append(StoragePeerListPanelComponent.Item( peer: item.peer, size: itemSize )) } } let initialAggregatedData = AggregatedData( peerId: component.peer?.id, stats: stats, messages: [:], peerItems: StoragePeerListPanelComponent.Items(items: peerItems), imageItems: nil, fileItems: nil, musicItems: nil ) let contextStats = initialAggregatedData.contextStats if firstTime { self.aggregatedData = initialAggregatedData self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(value: .firstStatsUpdate))) self.component?.ready.set(.single(true)) } class RenderResult { var messages: [MessageId: Message] = [:] var imageItems: [StorageMediaGridPanelComponent.Item] = [] var fileItems: [StorageFileListPanelComponent.Item] = [] var musicItems: [StorageFileListPanelComponent.Item] = [] } self.messagesDisposable = (component.context.engine.resources.renderStorageUsageStatsMessages(stats: contextStats, categories: [.files, .photos, .videos, .music], existingMessages: self.aggregatedData?.messages ?? [:]) |> deliverOn(Queue()) |> map { messages -> RenderResult in let result = RenderResult() result.messages = messages var mergedMedia: [MessageId: Int64] = [:] if let categoryStats = contextStats.categories[.photos] { mergedMedia = categoryStats.messages } if let categoryStats = contextStats.categories[.videos] { for (id, value) in categoryStats.messages { mergedMedia[id] = value } } if !mergedMedia.isEmpty { for (id, messageSize) in mergedMedia.sorted(by: { $0.value > $1.value }) { if let message = messages[id] { var matches = false for media in message.media { if media is TelegramMediaImage { matches = true break } else if let file = media as? TelegramMediaFile { if file.isVideo { matches = true break } } } if matches { result.imageItems.append(StorageMediaGridPanelComponent.Item( message: EngineMessage(message), size: messageSize )) } } } } if let categoryStats = contextStats.categories[.files] { for (id, messageSize) in categoryStats.messages.sorted(by: { $0.value > $1.value }) { if let message = messages[id] { var matches = false for media in message.media { if let file = media as? TelegramMediaFile { if file.isSticker || file.isCustomEmoji { } else { matches = true } } } if matches { result.fileItems.append(StorageFileListPanelComponent.Item( message: message, size: messageSize )) } } } } if let categoryStats = contextStats.categories[.music] { for (id, messageSize) in categoryStats.messages.sorted(by: { $0.value > $1.value }) { if let message = messages[id] { var matches = false for media in message.media { if media is TelegramMediaFile { matches = true } } if matches { result.musicItems.append(StorageFileListPanelComponent.Item( message: message, size: messageSize )) } } } } return result } |> deliverOnMainQueue).start(next: { [weak self] result in guard let self, let component = self.component else { completion() return } if !firstTime { if let peer = component.peer, let controller = self.controller?() as? StorageUsageScreen, let childCompleted = controller.childCompleted { let contextStats: StorageUsageStats = stats.peers[peer.id]?.stats ?? StorageUsageStats(categories: [:]) var totalSize: Int64 = 0 for (_, value) in contextStats.categories { totalSize += value.size } if totalSize == 0 { childCompleted({ [weak self] in completion() if let self { self.controller?()?.dismiss(animated: true) } }) return } else { childCompleted({}) } } } self.aggregatedData = AggregatedData( peerId: component.peer?.id, stats: stats, messages: result.messages, peerItems: initialAggregatedData.peerItems, imageItems: StorageMediaGridPanelComponent.Items(items: result.imageItems), fileItems: StorageFileListPanelComponent.Items(items: result.fileItems), musicItems: StorageFileListPanelComponent.Items(items: result.musicItems) ) self.isClearing = false if !firstTime { self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(value: .clearedItems))) } else { self.state?.updated(transition: Transition(animation: .none)) } completion() }) }) } private func openPeer(peer: EnginePeer) { guard let component = self.component else { return } guard let controller = self.controller?() else { return } let childController = StorageUsageScreen(context: component.context, makeStorageUsageExceptionsScreen: component.makeStorageUsageExceptionsScreen, peer: peer) childController.childCompleted = { [weak self] completed in guard let self else { return } self.reloadStats(firstTime: false, completion: { completed() }) } controller.push(childController) } private func messageGaleryContextAction(messageId: EngineMessage.Id, sourceView: UIView, sourceRect: CGRect, gesture: ContextGesture) { guard let component = self.component, let aggregatedData = self.aggregatedData, let message = aggregatedData.messages[messageId] else { gesture.cancel() return } let _ = (chatMediaListPreviewControllerData( context: component.context, chatLocation: .peer(id: message.id.peerId), chatLocationContextHolder: nil, message: message, standalone: true, reverseMessageGalleryOrder: false, navigationController: self.controller?()?.navigationController as? NavigationController ) |> deliverOnMainQueue).start(next: { [weak self] previewData in guard let self, let component = self.component, let previewData else { gesture.cancel() return } let context = component.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let strings = presentationData.strings var items: [ContextMenuItem] = [] var openTitle: String = presentationData.strings.StorageManagement_OpenPhoto for media in message.media { if let _ = media as? TelegramMediaImage { openTitle = presentationData.strings.StorageManagement_OpenPhoto } else if let file = media as? TelegramMediaFile { if file.isVideo { openTitle = presentationData.strings.StorageManagement_OpenVideo } else { openTitle = presentationData.strings.StorageManagement_OpenFile } } } items.append(.action(ContextMenuActionItem( text: openTitle, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Expand"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c.dismiss(completion: { [weak self] in guard let self else { return } self.openMessage(message: message) }) }) )) items.append(.action(ContextMenuActionItem(text: strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in c.dismiss(completion: { [weak self] in guard let self, let component = self.component, let controller = self.controller?(), let navigationController = controller.navigationController as? NavigationController else { return } guard let peer = message.peers[message.id.peerId].flatMap(EnginePeer.init) else { return } var chatLocation: NavigateToChatControllerParams.Location = .peer(peer) if case let .channel(channel) = peer, channel.flags.contains(.isForum), let threadId = message.threadId { chatLocation = .replyThread(ChatReplyThreadMessage(messageId: MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: Int32(clamping: threadId)), channelMessageId: nil, isChannelPost: false, isForumPost: true, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false)) } component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, context: component.context, chatLocation: chatLocation, subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil), keepStack: .always )) }) }))) items.append(.action(ContextMenuActionItem(text: strings.Conversation_ContextMenuSelect, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] c, _ in c.dismiss(completion: { }) guard let self, let aggregatedData = self.aggregatedData else { return } aggregatedData.toggleMessageSelection(id: message.id) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }))) switch previewData { case let .gallery(gallery): gallery.setHintWillBePresentedInPreviewingContext(true) let contextController = ContextController( presentationData: presentationData, source: .controller(StorageUsageListContextGalleryContentSourceImpl( controller: gallery, sourceView: sourceView, sourceRect: sourceRect )), items: .single(ContextController.Items(content: .list(items))), gesture: gesture ) self.controller?()?.presentInGlobalOverlay(contextController) case .instantPage: break } }) } private func messageContextAction(messageId: EngineMessage.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture) { guard let component = self.component, let aggregatedData = self.aggregatedData else { return } guard let message = aggregatedData.messages[messageId] else { return } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } var openTitle: String = presentationData.strings.Conversation_LinkDialogOpen var isAudio: Bool = false for media in message.media { if let _ = media as? TelegramMediaImage { openTitle = presentationData.strings.StorageManagement_OpenPhoto } else if let file = media as? TelegramMediaFile { if file.isVideo { openTitle = presentationData.strings.StorageManagement_OpenVideo } else { openTitle = presentationData.strings.StorageManagement_OpenFile } isAudio = file.isMusic || file.isVoice } } var itemList: [ContextMenuItem] = [] if !isAudio { itemList.append(.action(ContextMenuActionItem( text: openTitle, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Expand"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c.dismiss(completion: { [weak self] in guard let self else { return } self.openMessage(message: message) }) }) )) } itemList.append(.action(ContextMenuActionItem( text: presentationData.strings.SharedMedia_ViewInChat, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c.dismiss(completion: { [weak self] in guard let self, let component = self.component, let controller = self.controller?(), let navigationController = controller.navigationController as? NavigationController else { return } guard let peer = message.peers[message.id.peerId].flatMap(EnginePeer.init) else { return } var chatLocation: NavigateToChatControllerParams.Location = .peer(peer) if case let .channel(channel) = peer, channel.flags.contains(.isForum), let threadId = message.threadId { chatLocation = .replyThread(ChatReplyThreadMessage(messageId: MessageId(peerId: peer.id, namespace: Namespaces.Message.Cloud, id: Int32(clamping: threadId)), channelMessageId: nil, isChannelPost: false, isForumPost: true, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false)) } component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, context: component.context, chatLocation: chatLocation, subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil), keepStack: .always )) }) }) )) itemList.append(.action(ContextMenuActionItem( text: aggregatedData.selectionState.selectedMessages.contains(messageId) ? presentationData.strings.StorageManagement_ContextDeselect : presentationData.strings.StorageManagement_ContextSelect, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in c.dismiss(completion: { }) guard let self, let aggregatedData = self.aggregatedData else { return } aggregatedData.toggleMessageSelection(id: message.id) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }) )) let items = ContextController.Items(content: .list(itemList)) let controller = ContextController( presentationData: presentationData, source: .extracted(StorageUsageListContextExtractedContentSource(contentView: sourceView)), items: .single(items), recognizer: nil, gesture: gesture) self.controller?()?.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismiss() } return true }) self.controller?()?.presentInGlobalOverlay(controller) } private func openMessage(message: Message) { guard let component = self.component else { return } guard let controller = self.controller?(), let navigationController = controller.navigationController as? NavigationController else { return } let foundGalleryMessage: Message? = message guard let galleryMessage = foundGalleryMessage else { return } self.endEditing(true) let _ = component.context.sharedContext.openChatMessage(OpenChatMessageParams( context: component.context, chatLocation: .peer(id: message.id.peerId), chatLocationContextHolder: nil, message: galleryMessage, standalone: true, reverseMessageGalleryOrder: true, navigationController: navigationController, dismissInput: { [weak self] in self?.endEditing(true) }, present: { [weak self] c, a in guard let self else { return } self.controller?()?.present(c, in: .window(.root), with: a, blockInteraction: true) }, transitionNode: { [weak self] messageId, media, _ in guard let self else { return nil } if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View { if let currentPanelView = panelContainerView.currentPanelView as? StorageMediaGridPanelComponent.View { return currentPanelView.transitionNodeForGallery(messageId: messageId, media: EngineMedia(media)) } } return nil }, addToTransitionSurface: { [weak self] view in guard let self else { return } if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View { panelContainerView.currentPanelView?.addSubview(view) } }, openUrl: { [weak self] url in guard let self else { return } let _ = self }, openPeer: { [weak self] peer, navigation in guard let self else { return } let _ = self }, callPeer: { _, _ in //self?.controllerInteraction?.callPeer(peerId) }, enqueueMessage: { _ in }, sendSticker: nil, sendEmoji: nil, setupTemporaryHiddenMedia: { _, _, _ in }, chatAvatarHiddenMedia: { _, _ in }, actionInteraction: GalleryControllerActionInteraction(openUrl: { [weak self] url, concealed in guard let self else { return } let _ = self //strongSelf.openUrl(url: url, concealed: false, external: false) }, openUrlIn: { [weak self] url in guard let self else { return } let _ = self }, openPeerMention: { [weak self] mention in guard let self else { return } let _ = self }, openPeer: { [weak self] peer in guard let self else { return } let _ = self }, openHashtag: { [weak self] peerName, hashtag in guard let self else { return } let _ = self }, openBotCommand: { _ in }, addContact: { _ in }, storeMediaPlaybackState: { [weak self] messageId, timestamp, playbackRate in guard let self else { return } let _ = self }, editMedia: { _, _, _ in }, updateCanReadHistory: { _ in }), centralItemUpdated: { [weak self] messageId in //let _ = self?.paneContainerNode.requestExpandTabs?() //self?.paneContainerNode.currentPane?.node.ensureMessageIsVisible(id: messageId) guard let self else { return } let _ = self } )) } private func requestClear(fromCategories: Bool) { guard let component = self.component, let aggregatedData = self.aggregatedData else { return } let context = component.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let actionSheet = ActionSheetController(presentationData: presentationData) let clearTitle: String if let _ = aggregatedData.peerId { clearTitle = presentationData.strings.StorageManagement_ClearSelected } else { if aggregatedData.selectedCategories == aggregatedData.existingCategories, fromCategories { clearTitle = presentationData.strings.StorageManagement_ClearAll } else { clearTitle = presentationData.strings.StorageManagement_ClearSelected } } actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: presentationData.strings.StorageManagement_ClearConfirmationText, parseMarkdown: true), ActionSheetButtonItem(title: clearTitle, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() self?.commitClear(fromCategories: fromCategories) }) ]), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ])]) self.controller?()?.present(actionSheet, in: .window(.root)) } private func commitClear(fromCategories: Bool) { guard let component = self.component, let aggregatedData = self.aggregatedData else { return } if let _ = aggregatedData.peerId { var mappedCategories: [StorageUsageStats.CategoryKey] = [] for category in aggregatedData.selectedCategories { switch category { case .photos: mappedCategories.append(.photos) case .videos: mappedCategories.append(.videos) case .files: mappedCategories.append(.files) case .music: mappedCategories.append(.music) case .other: break case .stickers: mappedCategories.append(.stickers) case .avatars: mappedCategories.append(.avatars) case .misc: mappedCategories.append(.misc) case .stories: mappedCategories.append(.stories) } } self.isClearing = true self.state?.updated(transition: .immediate) let totalSize = aggregatedData.selectedSize let _ = (component.context.engine.resources.clearStorage(peerId: component.peer?.id, categories: mappedCategories, includeMessages: aggregatedData.clearIncludeMessages, excludeMessages: aggregatedData.clearExcludeMessages) |> deliverOnMainQueue).start(next: { [weak self] progress in guard let self else { return } self.updateClearProgress(progress: progress) }, completed: { [weak self] in guard let self, let _ = self.component else { return } self.reloadStats(firstTime: false, completion: { [weak self] in guard let self else { return } if totalSize != 0 { self.reportClearedStorage(size: totalSize) } }) }) } else { if fromCategories { var mappedCategories: [StorageUsageStats.CategoryKey] = [] for category in aggregatedData.selectedCategories { switch category { case .photos: mappedCategories.append(.photos) case .videos: mappedCategories.append(.videos) case .files: mappedCategories.append(.files) case .music: mappedCategories.append(.music) case .other: break case .stickers: mappedCategories.append(.stickers) case .avatars: mappedCategories.append(.avatars) case .misc: mappedCategories.append(.misc) case .stories: mappedCategories.append(.stories) } } self.isClearing = true self.state?.updated(transition: .immediate) var totalSize: Int64 = 0 let contextStats = aggregatedData.contextStats for category in aggregatedData.selectedCategories { let mappedCategory: StorageUsageStats.CategoryKey switch category { case .photos: mappedCategory = .photos case .videos: mappedCategory = .videos case .files: mappedCategory = .files case .music: mappedCategory = .music case .other: continue case .stickers: mappedCategory = .stickers case .avatars: mappedCategory = .avatars case .misc: mappedCategory = .misc case .stories: mappedCategory = .stories } if let value = contextStats.categories[mappedCategory] { totalSize += value.size } } let _ = (component.context.engine.resources.clearStorage(peerId: component.peer?.id, categories: mappedCategories, includeMessages: [], excludeMessages: []) |> deliverOnMainQueue).start(next: { [weak self] progress in guard let self else { return } self.updateClearProgress(progress: progress) }, completed: { [weak self] in guard let self else { return } self.reloadStats(firstTime: false, completion: { [weak self] in guard let self else { return } if totalSize != 0 { self.reportClearedStorage(size: totalSize) } }) }) } else { self.isClearing = true self.state?.updated(transition: .immediate) var totalSize: Int64 = 0 if let peerItems = aggregatedData.peerItems { for item in peerItems.items { if aggregatedData.selectionState.selectedPeers.contains(item.peer.id) { totalSize += item.size } } } var includeMessages: [Message] = [] var excludeMessages: [Message] = [] for (id, message) in aggregatedData.messages { if aggregatedData.selectionState.selectedPeers.contains(id.peerId) { if !aggregatedData.selectionState.selectedMessages.contains(id) { excludeMessages.append(message) } } else { if aggregatedData.selectionState.selectedMessages.contains(id) { includeMessages.append(message) } } } let _ = (component.context.engine.resources.clearStorage(peerIds: aggregatedData.selectionState.selectedPeers, includeMessages: includeMessages, excludeMessages: excludeMessages) |> deliverOnMainQueue).start(next: { [weak self] progress in guard let self else { return } self.updateClearProgress(progress: progress) }, completed: { [weak self] in guard let self else { return } self.reloadStats(firstTime: false, completion: { [weak self] in guard let self else { return } if totalSize != 0 { self.reportClearedStorage(size: totalSize) } }) }) } } } private func updateClearProgress(progress: Float) { if let clearingNode = self.clearingNode { clearingNode.setProgress(progress) } } 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] if case .stories = mappedCategory { presetValues = [ 7 * 24 * 60 * 60, 2 * 24 * 60 * 60, 1 * 24 * 60 * 60 ] } else { presetValues = [ 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_Never } 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 mappedCategory != .stories { 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) }, totalCount: peerExceptions.count, action: { c, _ in c.dismiss(completion: { }) if let exceptionsController = makeStorageUsageExceptionsScreen(mappedCategory) { pushControllerImpl?(exceptionsController) } }), false)) } } if case .separator = subItems.last { subItems.removeLast() } if let sourceLabelView = sourceView.labelView { let items: Signal = .single(ContextController.Items(content: .list(subItems))) let source: ContextContentSource = .reference(StorageUsageContextReferenceContentSource(sourceView: sourceLabelView)) let contextController = ContextController( 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 private let readyValue = Promise() override public var ready: Promise { return self.readyValue } fileprivate var childCompleted: ((@escaping () -> Void) -> Void)? public init(context: AccountContext, makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController?, peer: EnginePeer? = nil) { self.context = context let componentReady = Promise() super.init(context: context, component: StorageUsageScreenComponent(context: context, makeStorageUsageExceptionsScreen: makeStorageUsageExceptionsScreen, peer: peer, ready: componentReady), navigationBarAppearance: .transparent) if peer != nil { self.navigationPresentation = .modal } self.readyValue.set(componentReady.get() |> timeout(0.3, queue: .mainQueue(), alternate: .single(true))) } 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 totalCount: Int fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void init(context: AccountContext, peers: [EnginePeer], totalCount: Int, action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void) { self.context = context self.peers = peers self.totalCount = totalCount 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.totalCount)) 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 } } private class StorageUsageClearProgressOverlayNode: ASDisplayNode { private let presentationData: PresentationData private let blurredView: BlurredBackgroundView private let animationNode: AnimatedStickerNode private let progressTextNode: ImmediateTextNode private let descriptionTextNode: ImmediateTextNode private let progressBackgroundNode: ASDisplayNode private let progressForegroundNode: ASDisplayNode private let progressDisposable = MetaDisposable() private var validLayout: (CGSize, CGFloat)? init(presentationData: PresentationData) { self.presentationData = presentationData self.blurredView = BlurredBackgroundView(color: presentationData.theme.list.plainBackgroundColor.withMultipliedAlpha(0.7), enableBlur: true) self.animationNode = DefaultAnimatedStickerNodeImpl() self.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: "ClearCache"), width: 256, height: 256, playbackMode: .loop, mode: .direct(cachePathPrefix: nil)) self.animationNode.visibility = true self.progressTextNode = ImmediateTextNode() self.progressTextNode.textAlignment = .center self.descriptionTextNode = ImmediateTextNode() self.descriptionTextNode.textAlignment = .center self.descriptionTextNode.maximumNumberOfLines = 0 self.progressBackgroundNode = ASDisplayNode() self.progressBackgroundNode.backgroundColor = self.presentationData.theme.actionSheet.controlAccentColor.withMultipliedAlpha(0.2) self.progressBackgroundNode.cornerRadius = 3.0 self.progressForegroundNode = ASDisplayNode() self.progressForegroundNode.backgroundColor = self.presentationData.theme.actionSheet.controlAccentColor self.progressForegroundNode.cornerRadius = 3.0 super.init() self.view.addSubview(self.blurredView) self.addSubnode(self.animationNode) self.addSubnode(self.progressTextNode) self.addSubnode(self.descriptionTextNode) self.addSubnode(self.progressBackgroundNode) self.addSubnode(self.progressForegroundNode) } deinit { self.progressDisposable.dispose() } func setProgressSignal(_ signal: Signal) { self.progressDisposable.set((signal |> deliverOnMainQueue).start(next: { [weak self] progress in if let strongSelf = self { strongSelf.setProgress(progress) } })) } private var progress: Float = 0.0 func setProgress(_ progress: Float) { self.progress = progress if let (size, bottomInset) = self.validLayout { self.updateLayout(size: size, bottomInset: bottomInset, transition: .animated(duration: 0.5, curve: .linear)) } } func updateLayout(size: CGSize, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) { self.validLayout = (size, bottomInset) transition.updateFrame(view: self.blurredView, frame: CGRect(origin: CGPoint(), size: size)) self.blurredView.update(size: size, transition: transition) let inset: CGFloat = 24.0 let progressHeight: CGFloat = 6.0 let spacing: CGFloat = 16.0 let imageSide = min(160.0, size.height - 30.0) let imageSize = CGSize(width: imageSide, height: imageSide) let animationFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floorToScreenPixels((size.height - imageSize.height) / 2.0) - 50.0), size: imageSize) self.animationNode.frame = animationFrame self.animationNode.updateLayout(size: imageSize) var bottomInset = bottomInset if bottomInset.isZero { bottomInset = inset } let progressFrame = CGRect(x: inset, y: size.height - bottomInset - progressHeight, width: size.width - inset * 2.0, height: progressHeight) self.progressBackgroundNode.frame = progressFrame let progressForegroundFrame = CGRect(x: inset, y: size.height - bottomInset - progressHeight, width: floorToScreenPixels(progressFrame.width * CGFloat(self.progress)), height: progressHeight) if !self.progressForegroundNode.frame.origin.x.isZero { transition.updateFrame(node: self.progressForegroundNode, frame: progressForegroundFrame, beginWithCurrentState: true) } else { self.progressForegroundNode.frame = progressForegroundFrame } self.descriptionTextNode.attributedText = NSAttributedString(string: self.presentationData.strings.ClearCache_KeepOpenedDescription, font: Font.regular(15.0), textColor: self.presentationData.theme.actionSheet.secondaryTextColor) let descriptionTextSize = self.descriptionTextNode.updateLayout(CGSize(width: size.width - inset * 3.0, height: size.height)) var descriptionTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - descriptionTextSize.width) / 2.0), y: animationFrame.maxY + 52.0), size: descriptionTextSize) let progressText: String = "\(Int(self.progress * 100.0))%" self.progressTextNode.attributedText = NSAttributedString(string: progressText, font: Font.with(size: 17.0, design: .regular, weight: .semibold, traits: [.monospacedNumbers]), textColor: self.presentationData.theme.actionSheet.primaryTextColor) let progressTextSize = self.progressTextNode.updateLayout(size) var progressTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - progressTextSize.width) / 2.0), y: descriptionTextFrame.minY - spacing - progressTextSize.height), size: progressTextSize) let availableHeight = progressTextFrame.minY if availableHeight < 100.0 { let offset = availableHeight / 2.0 - spacing descriptionTextFrame = descriptionTextFrame.offsetBy(dx: 0.0, dy: -offset) progressTextFrame = progressTextFrame.offsetBy(dx: 0.0, dy: -offset) self.animationNode.alpha = 0.0 } else { self.animationNode.alpha = 1.0 } self.progressTextNode.frame = progressTextFrame self.descriptionTextNode.frame = descriptionTextFrame } } private final class StorageUsageListContextGalleryContentSourceImpl: ContextControllerContentSource { let controller: ViewController weak var sourceView: UIView? let sourceRect: CGRect let navigationController: NavigationController? = nil let passthroughTouches: Bool init(controller: ViewController, sourceView: UIView?, sourceRect: CGRect = CGRect(origin: CGPoint(), size: CGSize()), passthroughTouches: Bool = false) { self.controller = controller self.sourceView = sourceView self.sourceRect = sourceRect self.passthroughTouches = passthroughTouches } func transitionInfo() -> ContextControllerTakeControllerInfo? { let sourceView = self.sourceView let sourceRect = self.sourceRect return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceView] in if let sourceView = sourceView { let rect = sourceRect.isEmpty ? sourceView.bounds : sourceRect return (sourceView, rect) } else { return nil } }) } func animatedIn() { self.controller.didAppearInContextPreview() } } private final class StorageUsageListContextExtractedContentSource: ContextExtractedContentSource { let keepInPlace: Bool = false let ignoreContentTouches: Bool = false let blurBackground: Bool = true //let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center private let contentView: ContextExtractedContentContainingView init(contentView: ContextExtractedContentContainingView) { self.contentView = contentView } func takeView() -> ContextControllerTakeViewInfo? { return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds) } func putBack() -> ContextControllerPutBackViewInfo? { return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) } }