import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import ViewControllerComponent import ComponentDisplayAdapters import TelegramPresentationData import AccountContext import TelegramCore import MultilineTextComponent import EmojiStatusComponent import Postbox import Markdown import ContextUI import AnimatedAvatarSetNode private final class StorageUsageScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let makeStorageUsageExceptionsScreen: (CacheStorageSettings.PeerStorageCategory) -> ViewController? init( context: AccountContext, makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController? ) { self.context = context self.makeStorageUsageExceptionsScreen = makeStorageUsageExceptionsScreen } static func ==(lhs: StorageUsageScreenComponent, rhs: StorageUsageScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } return true } private final class ScrollViewImpl: UIScrollView { override func touchesShouldCancel(in view: UIView) -> Bool { return true } } private final class AnimationHint { let isFirstStatsUpdate: Bool init(isFirstStatsUpdate: Bool) { self.isFirstStatsUpdate = isFirstStatsUpdate } } class View: UIView, UIScrollViewDelegate { private let scrollView: ScrollViewImpl private var currentStats: AllStorageUsageStats? private var cacheSettings: CacheStorageSettings? private var selectedCategories: Set = Set() private let navigationBackgroundView: BlurredBackgroundView private let navigationSeparatorLayer: SimpleLayer private let headerView = ComponentView() private let headerOffsetContainer: UIView private let headerDescriptionView = ComponentView() private let headerProgressBackgroundLayer: SimpleLayer private let headerProgressForegroundLayer: SimpleLayer private let pieChartView = ComponentView() private let chartTotalLabel = ComponentView() private let categoriesView = ComponentView() private let categoriesDescriptionView = ComponentView() private let keepDurationTitleView = ComponentView() private let keepDurationDescriptionView = ComponentView() private var keepDurationSectionContainerView: UIView private var keepDurationItems: [AnyHashable: ComponentView] = [:] private var component: StorageUsageScreenComponent? private weak var state: EmptyComponentState? private var navigationMetrics: (navigationHeight: CGFloat, statusBarHeight: CGFloat)? private var controller: (() -> ViewController?)? private var ignoreScrolling: Bool = false private var statsDisposable: Disposable? private var cacheSettingsDisposable: Disposable? override init(frame: CGRect) { self.headerOffsetContainer = UIView() self.headerOffsetContainer.isUserInteractionEnabled = false self.navigationBackgroundView = BlurredBackgroundView(color: nil, enableBlur: true) self.navigationSeparatorLayer = SimpleLayer() self.scrollView = ScrollViewImpl() self.keepDurationSectionContainerView = UIView() self.keepDurationSectionContainerView.clipsToBounds = true self.keepDurationSectionContainerView.layer.cornerRadius = 10.0 self.headerProgressBackgroundLayer = SimpleLayer() self.headerProgressForegroundLayer = SimpleLayer() super.init(frame: frame) self.scrollView.layer.anchorPoint = CGPoint() self.scrollView.delaysContentTouches = true self.scrollView.canCancelContentTouches = true self.scrollView.clipsToBounds = false if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.scrollView.contentInsetAdjustmentBehavior = .never } if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false self.scrollView.scrollsToTop = false self.scrollView.delegate = self self.scrollView.clipsToBounds = true self.addSubview(self.scrollView) self.scrollView.addSubview(self.keepDurationSectionContainerView) self.scrollView.layer.addSublayer(self.headerProgressBackgroundLayer) self.scrollView.layer.addSublayer(self.headerProgressForegroundLayer) self.addSubview(self.navigationBackgroundView) self.layer.addSublayer(self.navigationSeparatorLayer) self.addSubview(self.headerOffsetContainer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.statsDisposable?.dispose() } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate) } } private func updateScrolling(transition: Transition) { let scrollBounds = self.scrollView.bounds if let headerView = self.headerView.view, let navigationMetrics = self.navigationMetrics { var headerOffset: CGFloat = scrollBounds.minY let minY = navigationMetrics.statusBarHeight + floor((navigationMetrics.navigationHeight - navigationMetrics.statusBarHeight) / 2.0) let minOffset = headerView.center.y - minY headerOffset = min(headerOffset, minOffset) let animatedTransition = Transition(animation: .curve(duration: 0.18, curve: .easeInOut)) if abs(headerOffset - minOffset) < 4.0 { animatedTransition.setAlpha(view: self.navigationBackgroundView, alpha: 1.0) animatedTransition.setAlpha(layer: self.navigationSeparatorLayer, alpha: 1.0) } else { animatedTransition.setAlpha(view: self.navigationBackgroundView, alpha: 0.0) animatedTransition.setAlpha(layer: self.navigationSeparatorLayer, alpha: 0.0) } var offsetFraction: CGFloat = abs(headerOffset - minOffset) / 60.0 offsetFraction = min(1.0, max(0.0, offsetFraction)) transition.setScale(view: headerView, scale: 1.0 * offsetFraction + 0.8 * (1.0 - offsetFraction)) transition.setBounds(view: self.headerOffsetContainer, bounds: CGRect(origin: CGPoint(x: 0.0, y: headerOffset), size: self.headerOffsetContainer.bounds.size)) } } func update(component: StorageUsageScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state if self.statsDisposable == nil { self.cacheSettingsDisposable = (component.context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings]) |> map { sharedData -> CacheStorageSettings in let cacheSettings: CacheStorageSettings if let value = sharedData.entries[SharedDataKeys.cacheStorageSettings]?.get(CacheStorageSettings.self) { cacheSettings = value } else { cacheSettings = CacheStorageSettings.defaultSettings } return cacheSettings } |> deliverOnMainQueue).start(next: { [weak self] cacheSettings in guard let self else { return } self.cacheSettings = cacheSettings if self.currentStats != nil { self.state?.updated(transition: .immediate) } }) self.statsDisposable = (component.context.engine.resources.collectStorageUsageStats() |> deliverOnMainQueue).start(next: { [weak self] stats in guard let self else { return } self.currentStats = stats self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(isFirstStatsUpdate: true))) }) } let animationHint = transition.userData(AnimationHint.self) if let animationHint, animationHint.isFirstStatsUpdate { var alphaTransition = transition if animationHint.isFirstStatsUpdate { alphaTransition = .easeInOut(duration: 0.25) } alphaTransition.setAlpha(view: self.scrollView, alpha: self.currentStats != nil ? 1.0 : 0.0) } else { transition.setAlpha(view: self.scrollView, alpha: self.currentStats != nil ? 1.0 : 0.0) } let environment = environment[ViewControllerComponentContainer.Environment.self].value self.controller = environment.controller self.navigationMetrics = (environment.navigationHeight, environment.statusBarHeight) self.navigationSeparatorLayer.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor let navigationFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: environment.navigationHeight)) self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) self.navigationBackgroundView.update(size: navigationFrame.size, transition: transition.containedViewLayoutTransition) transition.setFrame(view: self.navigationBackgroundView, frame: navigationFrame) transition.setFrame(layer: self.navigationSeparatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationFrame.maxY), size: CGSize(width: availableSize.width, height: UIScreenPixel))) enum Category: Hashable { case photos case videos case files case music case other case stickers case avatars case misc var color: UIColor { switch self { case .photos: return UIColor(rgb: 0x5AC8FA) case .videos: return UIColor(rgb: 0x3478F6) case .files: return UIColor(rgb: 0x34C759) case .music: return UIColor(rgb: 0xFF2D55) case .other: return UIColor(rgb: 0xC4C4C6) case .stickers: return UIColor(rgb: 0x5856D6) case .avatars: return UIColor(rgb: 0xAF52DE) case .misc: return UIColor(rgb: 0xFF9500) } } func title(strings: PresentationStrings) -> String { switch self { case .photos: return "Photos" case .videos: return "Videos" case .files: return "Files" case .music: return "Music" case .other: return "Other" case .stickers: return "Stickers" case .avatars: return "Avatars" case .misc: return "Miscellaneous" } } } self.backgroundColor = environment.theme.list.blocksBackgroundColor var contentHeight: CGFloat = 0.0 let topInset: CGFloat = 19.0 let sideInset: CGFloat = 16.0 contentHeight += environment.statusBarHeight + topInset let chartOrder: [Category] = [ .photos, .videos, .files, .music, .stickers, .avatars, .misc ] if let animationHint, animationHint.isFirstStatsUpdate { for category in chartOrder { let _ = self.selectedCategories.insert(category) } } var chartItems: [PieChartComponent.ChartData.Item] = [] var listCategories: [StorageCategoriesComponent.CategoryData] = [] var totalSize: Int64 = 0 if let currentStats = self.currentStats { for (_, value) in currentStats.totalStats.categories { totalSize += value.size } for category in chartOrder { let mappedCategory: StorageUsageStats.CategoryKey switch category { case .photos: mappedCategory = .photos case .videos: mappedCategory = .videos case .files: mappedCategory = .files case .music: mappedCategory = .music case .stickers: mappedCategory = .stickers case .avatars: mappedCategory = .avatars case .misc: mappedCategory = .misc case .other: continue } if let categoryData = currentStats.totalStats.categories[mappedCategory] { let categoryFraction: Double if categoryData.size == 0 || totalSize == 0 { categoryFraction = 0.0 } else { categoryFraction = Double(categoryData.size) / Double(totalSize) } chartItems.append(PieChartComponent.ChartData.Item(id: category, value: categoryFraction, color: category.color)) listCategories.append(StorageCategoriesComponent.CategoryData( key: category, color: category.color, title: category.title(strings: environment.strings), size: categoryData.size, sizeFraction: categoryFraction, isSelected: self.selectedCategories.contains(category), subcategories: [])) } } } let otherCategories: [Category] = [ .stickers, .avatars, .misc ] var otherListCategories: [StorageCategoriesComponent.CategoryData] = [] for listCategory in listCategories { if otherCategories.contains(where: { AnyHashable($0) == listCategory.key }) { otherListCategories.append(listCategory) } } listCategories = listCategories.filter { item in return !otherCategories.contains(where: { AnyHashable($0) == item.key }) } if !otherListCategories.isEmpty { var totalOtherSize: Int64 = 0 for listCategory in otherListCategories { totalOtherSize += listCategory.size } let categoryFraction: Double if totalOtherSize == 0 || totalSize == 0 { categoryFraction = 0.0 } else { categoryFraction = Double(totalOtherSize) / Double(totalSize) } let isSelected = otherListCategories.allSatisfy { item in return self.selectedCategories.contains(item.key) } listCategories.append(StorageCategoriesComponent.CategoryData( key: Category.other, color: Category.other.color, title: Category.other.title(strings: environment.strings), size: totalOtherSize, sizeFraction: categoryFraction, isSelected: isSelected, subcategories: otherListCategories)) } let chartData = PieChartComponent.ChartData(items: chartItems) self.pieChartView.parentState = state let pieChartSize = self.pieChartView.update( transition: transition, component: AnyComponent(PieChartComponent( theme: environment.theme, chartData: chartData )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 60.0) ) let pieChartFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: pieChartSize) if let pieChartComponentView = self.pieChartView.view { if pieChartComponentView.superview == nil { self.scrollView.addSubview(pieChartComponentView) } transition.setFrame(view: pieChartComponentView, frame: pieChartFrame) } contentHeight += pieChartSize.height contentHeight += 26.0 let headerViewSize = self.headerView.update( transition: transition, component: AnyComponent(Text(text: "Storage Usage", font: Font.semibold(22.0), color: environment.theme.list.itemPrimaryTextColor)), environment: {}, containerSize: CGSize(width: availableSize.width - 40.0 * 2.0, height: 100.0) ) let headerViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerViewSize.width) / 2.0), y: contentHeight), size: headerViewSize) if let headerComponentView = self.headerView.view { if headerComponentView.superview == nil { self.headerOffsetContainer.addSubview(headerComponentView) } transition.setPosition(view: headerComponentView, position: headerViewFrame.center) transition.setBounds(view: headerComponentView, bounds: CGRect(origin: CGPoint(), size: headerViewFrame.size)) } contentHeight += headerViewSize.height contentHeight += 4.0 let body = MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor) let bold = MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.freeTextColor) //TODO:localize let headerDescriptionSize = self.headerDescriptionView.update( transition: transition, component: AnyComponent(MultilineTextComponent(text: .markdown(text: "Telegram users 9.7% of your free disk space.", attributes: MarkdownAttributes( body: body, bold: bold, link: body, linkAttribute: { _ in nil } )), maximumNumberOfLines: 0)), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) ) let headerDescriptionFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerDescriptionSize.width) / 2.0), y: contentHeight), size: headerDescriptionSize) if let headerDescriptionComponentView = self.headerDescriptionView.view { if headerDescriptionComponentView.superview == nil { self.scrollView.addSubview(headerDescriptionComponentView) } transition.setFrame(view: headerDescriptionComponentView, frame: headerDescriptionFrame) } contentHeight += headerDescriptionSize.height contentHeight += 8.0 let headerProgressWidth: CGFloat = min(200.0, availableSize.width - sideInset * 2.0) let headerProgressFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerProgressWidth) / 2.0), y: contentHeight), size: CGSize(width: headerProgressWidth, height: 4.0)) transition.setFrame(layer: self.headerProgressBackgroundLayer, frame: headerProgressFrame) transition.setCornerRadius(layer: self.headerProgressBackgroundLayer, cornerRadius: headerProgressFrame.height * 0.5) self.headerProgressBackgroundLayer.backgroundColor = environment.theme.list.itemAccentColor.withMultipliedAlpha(0.2).cgColor let headerProgress: CGFloat = 0.097 transition.setFrame(layer: self.headerProgressForegroundLayer, frame: CGRect(origin: headerProgressFrame.origin, size: CGSize(width: floorToScreenPixels(headerProgress * headerProgressFrame.width), height: headerProgressFrame.height))) transition.setCornerRadius(layer: self.headerProgressForegroundLayer, cornerRadius: headerProgressFrame.height * 0.5) self.headerProgressForegroundLayer.backgroundColor = environment.theme.list.itemAccentColor.cgColor contentHeight += 4.0 contentHeight += 24.0 let chartTotalLabelSize = self.chartTotalLabel.update( transition: transition, component: AnyComponent(Text(text: dataSizeString(Int(totalSize), formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")), font: Font.with(size: 20.0, design: .round, weight: .bold), color: environment.theme.list.itemPrimaryTextColor)), environment: {}, containerSize: CGSize(width: 200.0, height: 200.0) ) if let chartTotalLabelView = self.chartTotalLabel.view { if chartTotalLabelView.superview == nil { self.scrollView.addSubview(chartTotalLabelView) } transition.setFrame(view: chartTotalLabelView, frame: CGRect(origin: CGPoint(x: pieChartFrame.minX + floor((pieChartFrame.width - chartTotalLabelSize.width) / 2.0), y: pieChartFrame.minY + floor((pieChartFrame.height - chartTotalLabelSize.height) / 2.0)), size: chartTotalLabelSize)) } self.categoriesView.parentState = state let categoriesSize = self.categoriesView.update( transition: transition, component: AnyComponent(StorageCategoriesComponent( theme: environment.theme, strings: environment.strings, categories: listCategories, toggleCategorySelection: { [weak self] key in guard let self else { return } if key == AnyHashable(Category.other) { let otherCategories: [Category] = [.stickers, .avatars, .misc] if otherCategories.allSatisfy(self.selectedCategories.contains) { for item in otherCategories { self.selectedCategories.remove(item) } self.selectedCategories.remove(Category.other) } else { for item in otherCategories { let _ = self.selectedCategories.insert(item) } let _ = self.selectedCategories.insert(Category.other) } } else { if self.selectedCategories.contains(key) { self.selectedCategories.remove(key) } else { self.selectedCategories.insert(key) } } self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude) ) if let categoriesComponentView = self.categoriesView.view { if categoriesComponentView.superview == nil { self.scrollView.addSubview(categoriesComponentView) } transition.setFrame(view: categoriesComponentView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: categoriesSize)) } contentHeight += categoriesSize.height contentHeight += 8.0 //TODO:localize let categoriesDescriptionSize = self.categoriesDescriptionView.update( transition: transition, component: AnyComponent(MultilineTextComponent(text: .markdown(text: "All media will stay in the Telegram cloud and can be re-downloaded if you need it again.", attributes: MarkdownAttributes( body: body, bold: bold, link: body, linkAttribute: { _ in nil } )), maximumNumberOfLines: 0)), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) ) let categoriesDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: categoriesDescriptionSize) if let categoriesDescriptionComponentView = self.categoriesDescriptionView.view { if categoriesDescriptionComponentView.superview == nil { self.scrollView.addSubview(categoriesDescriptionComponentView) } transition.setFrame(view: categoriesDescriptionComponentView, frame: categoriesDescriptionFrame) } contentHeight += categoriesDescriptionSize.height contentHeight += 40.0 //TODO:localize let keepDurationTitleSize = self.keepDurationTitleView.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .markdown( text: "KEEP MEDIA", attributes: MarkdownAttributes( body: body, bold: bold, link: body, linkAttribute: { _ in nil } ) ), maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) ) let keepDurationTitleFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepDurationTitleSize) if let keepDurationTitleComponentView = self.keepDurationTitleView.view { if keepDurationTitleComponentView.superview == nil { self.scrollView.addSubview(keepDurationTitleComponentView) } transition.setFrame(view: keepDurationTitleComponentView, frame: keepDurationTitleFrame) } contentHeight += keepDurationTitleSize.height contentHeight += 8.0 var keepContentHeight: CGFloat = 0.0 for i in 0 ..< 3 { let item: ComponentView if let current = self.keepDurationItems[i] { item = current } else { item = ComponentView() self.keepDurationItems[i] = item } let mappedCategory: CacheStorageSettings.PeerStorageCategory //TODO:localize let iconName: String let title: String switch i { case 0: iconName = "Settings/Menu/EditProfile" title = "Private Chats" mappedCategory = .privateChats case 1: iconName = "Settings/Menu/GroupChats" title = "Group Chats" mappedCategory = .groups default: iconName = "Settings/Menu/Channels" title = "Channels" mappedCategory = .channels } let value = self.cacheSettings?.categoryStorageTimeout[mappedCategory] ?? Int32.max let optionText: String if value == Int32.max { optionText = environment.strings.ClearCache_Forever } else { optionText = timeIntervalString(strings: environment.strings, value: value) } let itemSize = item.update( transition: transition, component: AnyComponent(StoragePeerTypeItemComponent( theme: environment.theme, iconName: iconName, title: title, value: optionText, hasNext: i != 3 - 1, action: { [weak self] sourceView in guard let self else { return } self.openKeepMediaCategory(mappedCategory: mappedCategory, sourceView: sourceView) } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: keepContentHeight), size: itemSize) if let itemView = item.view { if itemView.superview == nil { self.keepDurationSectionContainerView.addSubview(itemView) } transition.setFrame(view: itemView, frame: itemFrame) } keepContentHeight += itemSize.height } self.keepDurationSectionContainerView.backgroundColor = environment.theme.list.itemBlocksBackgroundColor transition.setFrame(view: self.keepDurationSectionContainerView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: keepContentHeight))) contentHeight += keepContentHeight contentHeight += 8.0 //TODO:localize let keepDurationDescriptionSize = self.keepDurationDescriptionView.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .markdown( text: "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.", attributes: MarkdownAttributes( body: body, bold: bold, link: body, linkAttribute: { _ in nil } ) ), maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0) ) let keepDurationDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepDurationDescriptionSize) if let keepDurationDescriptionComponentView = self.keepDurationDescriptionView.view { if keepDurationDescriptionComponentView.superview == nil { self.scrollView.addSubview(keepDurationDescriptionComponentView) } transition.setFrame(view: keepDurationDescriptionComponentView, frame: keepDurationDescriptionFrame) } contentHeight += keepDurationDescriptionSize.height contentHeight += 40.0 contentHeight += availableSize.height self.ignoreScrolling = true let contentOffset = self.scrollView.bounds.minY transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize)) let contentSize = CGSize(width: availableSize.width, height: contentHeight) if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize } if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset { let deltaOffset = self.scrollView.bounds.minY - contentOffset transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true) } self.ignoreScrolling = false self.updateScrolling(transition: transition) return availableSize } private func openKeepMediaCategory(mappedCategory: CacheStorageSettings.PeerStorageCategory, sourceView: StoragePeerTypeItemComponent.View) { guard let component = self.component else { return } let context = component.context let makeStorageUsageExceptionsScreen = component.makeStorageUsageExceptionsScreen let pushControllerImpl: ((ViewController) -> Void)? = { [weak self] c in guard let self else { return } self.controller?()?.push(c) } let presentInGlobalOverlay: ((ViewController) -> Void)? = { [weak self] c in guard let self else { return } self.controller?()?.presentInGlobalOverlay(c, with: nil) } let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings])) let accountSpecificSettings: Signal = context.account.postbox.combinedView(keys: [viewKey]) |> map { views -> AccountSpecificCacheStorageSettings in let cacheSettings: AccountSpecificCacheStorageSettings if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) { cacheSettings = value } else { cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings } return cacheSettings } |> distinctUntilChanged let peerExceptions: Signal<[(peer: FoundPeer, value: Int32)], NoError> = accountSpecificSettings |> mapToSignal { accountSpecificSettings -> Signal<[(peer: FoundPeer, value: Int32)], NoError> in return context.account.postbox.transaction { transaction -> [(peer: FoundPeer, value: Int32)] in var result: [(peer: FoundPeer, value: Int32)] = [] for item in accountSpecificSettings.peerStorageTimeoutExceptions { let peerId = item.key let value = item.value guard let peer = transaction.getPeer(peerId) else { continue } let peerCategory: CacheStorageSettings.PeerStorageCategory var subscriberCount: Int32? if peer is TelegramUser { peerCategory = .privateChats } else if peer is TelegramGroup { peerCategory = .groups if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedGroupData { subscriberCount = (cachedData.participants?.participants.count).flatMap(Int32.init) } } else if let channel = peer as? TelegramChannel { if case .group = channel.info { peerCategory = .groups } else { peerCategory = .channels } if peerCategory == mappedCategory { if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedChannelData { subscriberCount = cachedData.participantsSummary.memberCount } } } else { continue } if peerCategory != mappedCategory { continue } result.append((peer: FoundPeer(peer: peer, subscribers: subscriberCount), value: value)) } return result.sorted(by: { lhs, rhs in if lhs.value != rhs.value { return lhs.value < rhs.value } return lhs.peer.peer.debugDisplayTitle < rhs.peer.peer.debugDisplayTitle }) } } let cacheSettings = context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings]) |> map { sharedData -> CacheStorageSettings in let cacheSettings: CacheStorageSettings if let value = sharedData.entries[SharedDataKeys.cacheStorageSettings]?.get(CacheStorageSettings.self) { cacheSettings = value } else { cacheSettings = CacheStorageSettings.defaultSettings } return cacheSettings } let _ = (combineLatest( cacheSettings |> take(1), peerExceptions |> take(1) ) |> deliverOnMainQueue).start(next: { cacheSettings, peerExceptions in let currentValue: Int32 = cacheSettings.categoryStorageTimeout[mappedCategory] ?? Int32.max let applyValue: (Int32) -> Void = { value in let _ = updateCacheStorageSettingsInteractively(accountManager: context.sharedContext.accountManager, { cacheSettings in var cacheSettings = cacheSettings cacheSettings.categoryStorageTimeout[mappedCategory] = value return cacheSettings }).start() } var subItems: [ContextMenuItem] = [] let presentationData = context.sharedContext.currentPresentationData.with { $0 } var presetValues: [Int32] = [ Int32.max, 31 * 24 * 60 * 60, 7 * 24 * 60 * 60, 1 * 24 * 60 * 60 ] if currentValue != 0 && !presetValues.contains(currentValue) { presetValues.append(currentValue) presetValues.sort(by: >) } for value in presetValues { let optionText: String if value == Int32.max { optionText = presentationData.strings.ClearCache_Forever } else { optionText = timeIntervalString(strings: presentationData.strings, value: value) } subItems.append(.action(ContextMenuActionItem(text: optionText, icon: { theme in if currentValue == value { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) } else { return nil } }, action: { _, f in applyValue(value) f(.default) }))) } subItems.append(.separator) if peerExceptions.isEmpty { let exceptionsText = presentationData.strings.GroupInfo_Permissions_AddException subItems.append(.action(ContextMenuActionItem(text: exceptionsText, icon: { theme in if case .privateChats = mappedCategory { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor) } else { return generateTintedImage(image: UIImage(bundleImageName: "Location/CreateGroupIcon"), color: theme.contextMenu.primaryColor) } }, action: { _, f in f(.default) if let exceptionsController = makeStorageUsageExceptionsScreen(mappedCategory) { pushControllerImpl?(exceptionsController) } }))) } else { subItems.append(.custom(MultiplePeerAvatarsContextItem(context: context, peers: peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }, action: { c, _ in c.dismiss(completion: { }) if let exceptionsController = makeStorageUsageExceptionsScreen(mappedCategory) { pushControllerImpl?(exceptionsController) } }), false)) } if let sourceLabelView = sourceView.labelView { let items: Signal = .single(ContextController.Items(content: .list(subItems))) let source: ContextContentSource = .reference(StorageUsageContextReferenceContentSource(sourceView: sourceLabelView)) let contextController = ContextController( account: context.account, presentationData: presentationData, source: source, items: items, gesture: nil ) sourceView.setHasAssociatedMenu(true) contextController.dismissed = { [weak sourceView] in sourceView?.setHasAssociatedMenu(false) } presentInGlobalOverlay?(contextController) } }) } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public final class StorageUsageScreen: ViewControllerComponentContainer { private let context: AccountContext public init(context: AccountContext, makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController?) { self.context = context super.init(context: context, component: StorageUsageScreenComponent(context: context, makeStorageUsageExceptionsScreen: makeStorageUsageExceptionsScreen), navigationBarAppearance: .transparent) //self.navigationPresentation = .modal } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override public func viewDidLoad() { super.viewDidLoad() } } private final class StorageUsageContextReferenceContentSource: ContextReferenceContentSource { private let sourceView: UIView init(sourceView: UIView) { self.sourceView = sourceView } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, insets: UIEdgeInsets(top: -4.0, left: 0.0, bottom: -4.0, right: 0.0)) } } final class MultiplePeerAvatarsContextItem: ContextMenuCustomItem { fileprivate let context: AccountContext fileprivate let peers: [EnginePeer] fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void init(context: AccountContext, peers: [EnginePeer], action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void) { self.context = context self.peers = peers self.action = action } func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode { return MultiplePeerAvatarsContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected) } } private final class MultiplePeerAvatarsContextItemNode: ASDisplayNode, ContextMenuCustomNode, ContextActionNodeProtocol { private let item: MultiplePeerAvatarsContextItem private var presentationData: PresentationData private let getController: () -> ContextControllerProtocol? private let actionSelected: (ContextMenuActionResult) -> Void private let backgroundNode: ASDisplayNode private let highlightedBackgroundNode: ASDisplayNode private let textNode: ImmediateTextNode private let avatarsNode: AnimatedAvatarSetNode private let avatarsContext: AnimatedAvatarSetContext private let buttonNode: HighlightTrackingButtonNode private var pointerInteraction: PointerInteraction? init(presentationData: PresentationData, item: MultiplePeerAvatarsContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) { self.item = item self.presentationData = presentationData self.getController = getController self.actionSelected = actionSelected let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) self.backgroundNode = ASDisplayNode() self.backgroundNode.isAccessibilityElement = false self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isAccessibilityElement = false self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor self.highlightedBackgroundNode.alpha = 0.0 self.textNode = ImmediateTextNode() self.textNode.isAccessibilityElement = false self.textNode.isUserInteractionEnabled = false self.textNode.displaysAsynchronously = false self.textNode.attributedText = NSAttributedString(string: " ", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor) self.textNode.maximumNumberOfLines = 1 self.buttonNode = HighlightTrackingButtonNode() self.buttonNode.isAccessibilityElement = true self.buttonNode.accessibilityLabel = presentationData.strings.VoiceChat_StopRecording self.avatarsNode = AnimatedAvatarSetNode() self.avatarsContext = AnimatedAvatarSetContext() super.init() self.addSubnode(self.backgroundNode) self.addSubnode(self.highlightedBackgroundNode) self.addSubnode(self.textNode) self.addSubnode(self.avatarsNode) self.addSubnode(self.buttonNode) self.buttonNode.highligthedChanged = { [weak self] highligted in guard let strongSelf = self else { return } if highligted { strongSelf.highlightedBackgroundNode.alpha = 1.0 } else { strongSelf.highlightedBackgroundNode.alpha = 0.0 strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) } } self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) self.buttonNode.isUserInteractionEnabled = true } deinit { } override func didLoad() { super.didLoad() self.pointerInteraction = PointerInteraction(node: self.buttonNode, style: .hover, willEnter: { [weak self] in if let strongSelf = self { strongSelf.highlightedBackgroundNode.alpha = 0.75 } }, willExit: { [weak self] in if let strongSelf = self { strongSelf.highlightedBackgroundNode.alpha = 0.0 } }) } private var validLayout: (calculatedWidth: CGFloat, size: CGSize)? func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) { let sideInset: CGFloat = 14.0 let verticalInset: CGFloat = 12.0 let rightTextInset: CGFloat = sideInset + 36.0 let calculatedWidth = min(constrainedWidth, 250.0) let textFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize) let text: String = self.presentationData.strings.CacheEvictionMenu_CategoryExceptions(Int32(self.item.peers.count)) self.textNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: self.presentationData.theme.contextMenu.primaryColor) let textSize = self.textNode.updateLayout(CGSize(width: calculatedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude)) let combinedTextHeight = textSize.height return (CGSize(width: calculatedWidth, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in self.validLayout = (calculatedWidth: calculatedWidth, size: size) let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0) let textFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalOrigin), size: textSize) transition.updateFrameAdditive(node: self.textNode, frame: textFrame) let avatarsContent: AnimatedAvatarSetContext.Content let avatarsPeers: [EnginePeer] = self.item.peers avatarsContent = self.avatarsContext.update(peers: avatarsPeers, animated: false) let avatarsSize = self.avatarsNode.update(context: self.item.context, content: avatarsContent, itemSize: CGSize(width: 24.0, height: 24.0), customSpacing: 10.0, animated: false, synchronousLoad: true) self.avatarsNode.frame = CGRect(origin: CGPoint(x: size.width - sideInset - 12.0 - avatarsSize.width, y: floor((size.height - avatarsSize.height) / 2.0)), size: avatarsSize) transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) }) } func updateTheme(presentationData: PresentationData) { self.presentationData = presentationData self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor) } @objc private func buttonPressed() { self.performAction() } private var actionTemporarilyDisabled: Bool = false func canBeHighlighted() -> Bool { return self.isActionEnabled } func updateIsHighlighted(isHighlighted: Bool) { self.setIsHighlighted(isHighlighted) } func performAction() { if self.actionTemporarilyDisabled { return } self.actionTemporarilyDisabled = true Queue.mainQueue().async { [weak self] in self?.actionTemporarilyDisabled = false } guard let controller = self.getController() else { return } self.item.action(controller, { [weak self] result in self?.actionSelected(result) }) } var isActionEnabled: Bool { return true } func setIsHighlighted(_ value: Bool) { if value { self.highlightedBackgroundNode.alpha = 1.0 } else { self.highlightedBackgroundNode.alpha = 0.0 } } func actionNode(at point: CGPoint) -> ContextActionNodeProtocol { return self } }