diff --git a/submodules/PhotoResources/Sources/PhotoResources.swift b/submodules/PhotoResources/Sources/PhotoResources.swift index 7e3202b4cd..a2d02b5424 100644 --- a/submodules/PhotoResources/Sources/PhotoResources.swift +++ b/submodules/PhotoResources/Sources/PhotoResources.swift @@ -1371,7 +1371,7 @@ public func mediaGridMessagePhoto(account: Account, userLocation: MediaResourceU let fullSizeData = value._1 let fullSizeComplete = value._3 return { arguments in - guard let context = DrawingContext(size: arguments.drawingSize, clear: true) else { + guard let context = DrawingContext(size: arguments.drawingSize, opaque: arguments.corners.isEmpty && arguments.intrinsicInsets == .zero, clear: true) else { return nil } @@ -1951,7 +1951,7 @@ public func chatWebpageSnippetFile(account: Account, userLocation: MediaResource } if let fullSizeImage = fullSizeImage ?? (blurredImage?.cgImage) { - guard let context = DrawingContext(size: arguments.drawingSize, clear: true) else { + guard let context = DrawingContext(size: arguments.drawingSize, opaque: arguments.corners.isEmpty && arguments.intrinsicInsets == .zero, clear: true) else { return nil } @@ -1980,7 +1980,7 @@ public func chatWebpageSnippetFile(account: Account, userLocation: MediaResource return context } else { if let emptyColor = arguments.emptyColor { - guard let context = DrawingContext(size: arguments.drawingSize, clear: true) else { + guard let context = DrawingContext(size: arguments.drawingSize, opaque: arguments.corners.isEmpty && arguments.intrinsicInsets == .zero, clear: true) else { return nil } diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageMediaGridPanelComponent.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageMediaGridPanelComponent.swift new file mode 100644 index 0000000000..78ce1d92dd --- /dev/null +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageMediaGridPanelComponent.swift @@ -0,0 +1,550 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import ViewControllerComponent +import ComponentDisplayAdapters +import TelegramPresentationData +import AccountContext +import TelegramCore +import MultilineTextComponent +import EmojiStatusComponent +import Postbox +import TelegramStringFormatting +import CheckNode +import AvatarNode +import PhotoResources +import SemanticStatusNode + +private let badgeFont = Font.regular(12.0) +private let videoIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat List/MiniThumbnailPlay"), color: .white) + +private final class MediaGridLayer: SimpleLayer { + enum SelectionState: Equatable { + case none + case editing(isSelected: Bool) + } + + private(set) var message: Message? + private var disposable: Disposable? + + private var size: CGSize? + private var selectionState: SelectionState = .none + private var theme: PresentationTheme? + private var checkLayer: CheckLayer? + private let badgeOverlay: SimpleLayer + + override init() { + self.badgeOverlay = SimpleLayer() + self.badgeOverlay.contentsScale = UIScreenScale + self.badgeOverlay.contentsGravity = .topRight + + super.init() + + self.isOpaque = true + self.masksToBounds = true + self.contentsGravity = .resizeAspectFill + + self.addSublayer(self.badgeOverlay) + } + + override init(layer: Any) { + self.badgeOverlay = SimpleLayer() + + guard let other = layer as? MediaGridLayer else { + preconditionFailure() + } + + super.init(layer: other) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable?.dispose() + } + + func prepareForReuse() { + self.message = nil + + if let disposable = self.disposable { + self.disposable = nil + disposable.dispose() + } + } + + func setup(context: AccountContext, strings: PresentationStrings, message: Message, size: Int64) { + self.message = message + + var isVideo = false + var dimensions: CGSize? + var signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? + for media in message.media { + if let file = media as? TelegramMediaFile, let representation = file.previewRepresentations.last { + isVideo = file.isVideo + signal = chatWebpageSnippetFile( + account: context.account, + userLocation: .peer(message.id.peerId), + mediaReference: FileMediaReference.standalone(media: file).abstract, + representation: representation, + automaticFetch: false + ) + dimensions = representation.dimensions.cgSize + } else if let image = media as? TelegramMediaImage, let representation = image.representations.last { + signal = mediaGridMessagePhoto( + account: context.account, + userLocation: .peer(message.id.peerId), + photoReference: ImageMediaReference.standalone(media: image), + automaticFetch: false + ) + dimensions = representation.dimensions.cgSize + } + } + + if let signal, let dimensions { + self.disposable = (signal + |> map { generator -> UIImage? in + return generator(TransformImageArguments(corners: ImageCorners(radius: 0.0), imageSize: dimensions, boundingSize: CGSize(width: 100.0, height: 100.0), intrinsicInsets: UIEdgeInsets()))?.generateImage() + } + |> deliverOnMainQueue).start(next: { [weak self] image in + guard let self, let image else { + return + } + self.contents = image.cgImage + }) + } + + let text: String = dataSizeString(Int(size), formatting: DataSizeStringFormatting(strings: strings, decimalSeparator: ".")) + let attributedText = NSAttributedString(string: text, font: badgeFont, textColor: .white) + let textBounds = attributedText.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil) + let textSize = CGSize(width: ceil(textBounds.width), height: ceil(textBounds.height)) + let textLeftInset: CGFloat + let textRightInset: CGFloat = 6.0 + if isVideo { + textLeftInset = 18.0 + } else { + textLeftInset = textRightInset + } + let badgeSize = CGSize(width: textLeftInset + textRightInset + textSize.width, height: 18.0) + self.badgeOverlay.contents = generateImage(badgeSize, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(white: 0.0, alpha: 0.5).cgColor) + context.setBlendMode(.copy) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.height, height: size.height))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height))) + context.fill(CGRect(origin: CGPoint(x: size.height * 0.5, y: 0.0), size: CGSize(width: size.width - size.height, height: size.height))) + context.setBlendMode(.normal) + + UIGraphicsPushContext(context) + + if isVideo, let videoIcon { + videoIcon.draw(at: CGPoint(x: 2.0, y: floor((size.height - videoIcon.size.height) / 2.0))) + } + + attributedText.draw(in: textBounds.offsetBy(dx: textLeftInset, dy: UIScreenPixel + floor((size.height - textSize.height) * 0.5))) + + UIGraphicsPopContext() + })?.cgImage + } + + func updateSelection(size: CGSize, selectionState: SelectionState, theme: PresentationTheme, transition: Transition) { + if self.size == size && self.selectionState == selectionState && self.theme === theme { + return + } + + self.selectionState = selectionState + self.size = size + + let themeUpdated = self.theme !== theme + self.theme = theme + + switch selectionState { + case .none: + if let checkLayer = self.checkLayer { + self.checkLayer = nil + if !transition.animation.isImmediate { + checkLayer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false) + checkLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak checkLayer] _ in + checkLayer?.removeFromSuperlayer() + }) + } else { + checkLayer.removeFromSuperlayer() + } + } + case let .editing(isSelected): + let checkWidth: CGFloat + if size.width <= 60.0 { + checkWidth = 22.0 + } else { + checkWidth = 28.0 + } + let checkSize = CGSize(width: checkWidth, height: checkWidth) + let checkFrame = CGRect(origin: CGPoint(x: self.bounds.size.width - checkSize.width - 2.0, y: 2.0), size: checkSize) + + if let checkLayer = self.checkLayer { + if checkLayer.bounds.size != checkFrame.size { + checkLayer.setNeedsDisplay() + } + transition.setFrame(layer: checkLayer, frame: checkFrame) + if themeUpdated { + checkLayer.theme = CheckNodeTheme(theme: theme, style: .overlay) + } + checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate) + } else { + let checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .overlay)) + self.checkLayer = checkLayer + self.addSublayer(checkLayer) + checkLayer.frame = checkFrame + checkLayer.setSelected(isSelected, animated: false) + checkLayer.setNeedsDisplay() + + if !transition.animation.isImmediate { + checkLayer.animateScale(from: 0.001, to: 1.0, duration: 0.2) + checkLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + + self.badgeOverlay.frame = CGRect(origin: CGPoint(x: size.width - 3.0, y: size.height - 3.0), size: CGSize(width: 0.0, height: 0.0)) + } +} + +private final class MediaGridLayerDataContext { + +} + +final class StorageMediaGridPanelComponent: Component { + typealias EnvironmentType = StorageUsagePanelEnvironment + + final class Item: Equatable { + let message: Message + let size: Int64 + + init( + message: Message, + size: Int64 + ) { + self.message = message + self.size = size + } + + static func ==(lhs: Item, rhs: Item) -> Bool { + if lhs.message.id != rhs.message.id { + return false + } + if lhs.size != rhs.size { + return false + } + return true + } + } + + final class Items: Equatable { + let items: [Item] + + init(items: [Item]) { + self.items = items + } + + static func ==(lhs: Items, rhs: Items) -> Bool { + if lhs === rhs { + return true + } + return lhs.items == rhs.items + } + } + + let context: AccountContext + let items: Items? + let selectionState: StorageUsageScreenComponent.SelectionState? + let peerAction: (EngineMessage.Id) -> Void + + init( + context: AccountContext, + items: Items?, + selectionState: StorageUsageScreenComponent.SelectionState?, + peerAction: @escaping (EngineMessage.Id) -> Void + ) { + self.context = context + self.items = items + self.selectionState = selectionState + self.peerAction = peerAction + } + + static func ==(lhs: StorageMediaGridPanelComponent, rhs: StorageMediaGridPanelComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.items != rhs.items { + return false + } + if lhs.selectionState != rhs.selectionState { + return false + } + return true + } + + private struct ItemLayout: Equatable { + var width: CGFloat + var itemCount: Int + var nativeItemSize: CGFloat + let visibleItemSize: CGFloat + + var itemInsets: UIEdgeInsets + var itemSpacing: CGFloat + var itemsPerRow: Int + var contentSize: CGSize + + init( + width: CGFloat, + containerInsets: UIEdgeInsets, + itemCount: Int + ) { + self.width = width + self.itemCount = itemCount + + let minItemsPerRow: Int = 3 + let itemSpacing: CGFloat = UIScreenPixel + self.itemSpacing = itemSpacing + let itemInsets: UIEdgeInsets = UIEdgeInsets(top: containerInsets.top, left: containerInsets.left, bottom: containerInsets.bottom, right: containerInsets.right) + self.nativeItemSize = 120.0 + + self.itemInsets = itemInsets + let itemHorizontalSpace = width - self.itemInsets.left - self.itemInsets.right + self.itemsPerRow = max(minItemsPerRow, Int((itemHorizontalSpace + itemSpacing) / (self.nativeItemSize + itemSpacing))) + let proposedItemSize = floor((itemHorizontalSpace - itemSpacing * (CGFloat(self.itemsPerRow) - 1.0)) / CGFloat(self.itemsPerRow)) + self.visibleItemSize = proposedItemSize + + let numRows = (itemCount + (self.itemsPerRow - 1)) / self.itemsPerRow + + self.contentSize = CGSize( + width: width, + height: self.itemInsets.top + self.itemInsets.bottom + CGFloat(numRows) * self.visibleItemSize + CGFloat(max(0, numRows - 1)) * self.itemSpacing + ) + } + + func frame(itemIndex: Int) -> CGRect { + let row = itemIndex / self.itemsPerRow + let column = itemIndex % self.itemsPerRow + + var result = CGRect( + origin: CGPoint( + x: self.itemInsets.left + CGFloat(column) * (self.visibleItemSize + self.itemSpacing), + y: self.itemInsets.top + CGFloat(row) * (self.visibleItemSize + self.itemSpacing) + ), + size: CGSize( + width: self.visibleItemSize, + height: self.visibleItemSize + ) + ) + if column == self.itemsPerRow - 1 { + result.size.width = max(result.size.width, self.width - self.itemInsets.right - result.minX) + } + return result + } + + func visibleItems(for rect: CGRect) -> Range? { + let offsetRect = rect.offsetBy(dx: -self.itemInsets.left, dy: -self.itemInsets.top) + var minVisibleRow = Int(floor((offsetRect.minY - self.itemSpacing) / (self.visibleItemSize + self.itemSpacing))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((offsetRect.maxY - self.itemSpacing) / (self.visibleItemSize + self.itemSpacing))) + + let minVisibleIndex = minVisibleRow * self.itemsPerRow + let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) * self.itemsPerRow - 1) + + return maxVisibleIndex >= minVisibleIndex ? (minVisibleIndex ..< (maxVisibleIndex + 1)) : nil + } + } + + class View: UIView, UIScrollViewDelegate { + private let scrollView: UIScrollView + + private var visibleLayers: [EngineMessage.Id: MediaGridLayer] = [:] + private var layersAvailableForReuse: [MediaGridLayer] = [] + + private var ignoreScrolling: Bool = false + + private var component: StorageMediaGridPanelComponent? + private var environment: StorageUsagePanelEnvironment? + private var itemLayout: ItemLayout? + + override init(frame: CGRect) { + self.scrollView = UIScrollView() + + 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 = true + 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.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + guard let component = self.component else { + return + } + let point = recognizer.location(in: self.scrollView) + for (id, itemLayer) in self.visibleLayers { + if itemLayer.frame.contains(point) { + component.peerAction(id) + break + } + } + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + private func updateScrolling(transition: Transition) { + guard let component = self.component, let environment = self.environment, let items = component.items, let itemLayout = self.itemLayout else { + return + } + + let _ = environment + + var validIds = Set() + + let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -100.0) + if let visibleItems = itemLayout.visibleItems(for: visibleBounds) { + for index in visibleItems.lowerBound ..< visibleItems.upperBound { + if index >= items.items.count { + continue + } + + let item = items.items[index] + let id = item.message.id + validIds.insert(id) + } + + var removeIds: [EngineMessage.Id] = [] + for (id, itemLayer) in self.visibleLayers { + if !validIds.contains(id) { + removeIds.append(id) + itemLayer.isHidden = true + self.layersAvailableForReuse.append(itemLayer) + itemLayer.prepareForReuse() + } + } + for id in removeIds { + self.visibleLayers.removeValue(forKey: id) + } + + for index in visibleItems.lowerBound ..< visibleItems.upperBound { + if index >= items.items.count { + continue + } + + let item = items.items[index] + let id = item.message.id + + var setupItemLayer = false + + let itemLayer: MediaGridLayer + if let current = self.visibleLayers[id] { + itemLayer = current + } else if !self.layersAvailableForReuse.isEmpty { + setupItemLayer = true + itemLayer = self.layersAvailableForReuse.removeLast() + itemLayer.isHidden = false + self.visibleLayers[id] = itemLayer + } else { + setupItemLayer = true + itemLayer = MediaGridLayer() + self.visibleLayers[id] = itemLayer + self.scrollView.layer.addSublayer(itemLayer) + } + + let itemFrame = itemLayout.frame(itemIndex: index) + itemLayer.frame = itemFrame + + if setupItemLayer { + itemLayer.setup(context: component.context, strings: environment.strings, message: item.message, size: item.size) + } + + let itemSelectionState: MediaGridLayer.SelectionState + if let selectionState = component.selectionState { + itemSelectionState = .editing(isSelected: selectionState.selectedMessages.contains(id)) + } else { + itemSelectionState = .none + } + + itemLayer.updateSelection(size: itemFrame.size, selectionState: itemSelectionState, theme: environment.theme, transition: transition) + } + } + } + + func update(component: StorageMediaGridPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + + let environment = environment[StorageUsagePanelEnvironment.self].value + self.environment = environment + + + let itemLayout = ItemLayout( + width: availableSize.width, + containerInsets: environment.containerInsets, + itemCount: component.items?.items.count ?? 0 + ) + self.itemLayout = itemLayout + + self.ignoreScrolling = true + let contentOffset = self.scrollView.bounds.minY + transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center) + var scrollBounds = self.scrollView.bounds + scrollBounds.size = availableSize + if !environment.isScrollable { + scrollBounds.origin = CGPoint() + } + transition.setBounds(view: self.scrollView, bounds: scrollBounds) + self.scrollView.isScrollEnabled = environment.isScrollable + let contentSize = CGSize(width: availableSize.width, height: itemLayout.contentSize.height) + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + self.scrollView.scrollIndicatorInsets = environment.containerInsets + 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 + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift index 03648eb999..4645266da0 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift @@ -22,6 +22,70 @@ import AnimatedStickerNode import TelegramAnimatedStickerNode import TelegramStringFormatting +#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 { @@ -225,7 +289,7 @@ final class StorageUsageScreenComponent: Component { private var cacheSettingsExceptionCount: [CacheStorageSettings.PeerStorageCategory: Int32]? private var peerItems: StoragePeerListPanelComponent.Items? - private var imageItems: StorageFileListPanelComponent.Items? + private var imageItems: StorageMediaGridPanelComponent.Items? private var fileItems: StorageFileListPanelComponent.Items? private var musicItems: StorageFileListPanelComponent.Items? @@ -629,11 +693,21 @@ final class StorageUsageScreenComponent: Component { return } if self.selectionState == nil { + #if DEBUG + let signpostState = SignpostContext.shared?.begin(name: "edit") + #endif + self.selectionState = SelectionState( selectedPeers: Set(), selectedMessages: Set() ) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) + + #if DEBUG + if let signpostState { + SignpostContext.shared?.end(name: "edit", data: signpostState) + } + #endif } } ).minSize(CGSize(width: 16.0, height: environment.navigationHeight - environment.statusBarHeight))), @@ -1483,7 +1557,7 @@ final class StorageUsageScreenComponent: Component { panelItems.append(StorageUsagePanelContainerComponent.Item( id: "images", title: environment.strings.StorageManagement_TabMedia, - panel: AnyComponent(StorageFileListPanelComponent( + panel: AnyComponent(StorageMediaGridPanelComponent( context: component.context, items: self.imageItems, selectionState: self.selectionState, @@ -1716,7 +1790,7 @@ final class StorageUsageScreenComponent: Component { class RenderResult { var messages: [MessageId: Message] = [:] - var imageItems: [StorageFileListPanelComponent.Item] = [] + var imageItems: [StorageMediaGridPanelComponent.Item] = [] var fileItems: [StorageFileListPanelComponent.Item] = [] var musicItems: [StorageFileListPanelComponent.Item] = [] } @@ -1755,7 +1829,7 @@ final class StorageUsageScreenComponent: Component { } if matches { - result.imageItems.append(StorageFileListPanelComponent.Item( + result.imageItems.append(StorageMediaGridPanelComponent.Item( message: message, size: messageSize )) @@ -1846,7 +1920,7 @@ final class StorageUsageScreenComponent: Component { self.currentMessages = result.messages - self.imageItems = StorageFileListPanelComponent.Items(items: result.imageItems) + self.imageItems = StorageMediaGridPanelComponent.Items(items: result.imageItems) self.fileItems = StorageFileListPanelComponent.Items(items: result.fileItems) self.musicItems = StorageFileListPanelComponent.Items(items: result.musicItems) @@ -1905,6 +1979,7 @@ final class StorageUsageScreenComponent: Component { } 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()