diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 78f4bd1a59..1c3121229c 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -8609,3 +8609,16 @@ Sorry for the inconvenience."; "GroupMembers.HideMembers" = "Hide Members"; "GroupMembers.MembersHiddenOn" = "Switch this off to show the list of members in this group."; "GroupMembers.MembersHiddenOff" = "Switch this on to hide the list of members in this group. Admins will remain visible."; + +"StorageManagement.ClearCache" = "Clear Cache"; + +"ChatList.StorageHintTitle" = "Free up to %@"; +"ChatList.StorageHintText" = "Clear storage space on your iPhone"; + +"StorageManagement.PeerShowDetails" = "Show Details"; +"StorageManagement.PeerOpenProfile" = "Open Profile"; +"StorageManagement.ContextSelect" = "Select"; +"StorageManagement.ContextDeselect" = "Deselect"; +"StorageManagement.OpenPhoto" = "Open Photo"; +"StorageManagement.OpenVideo" = "Open Video"; +"StorageManagement.OpenFile" = "Open File"; diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 0d139dc0fd..706d2a2559 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -791,6 +791,7 @@ public protocol SharedAccountContext: AnyObject { func makeCreateGroupController(context: AccountContext, peerIds: [PeerId], initialTitle: String?, mode: CreateGroupMode, completion: ((PeerId, @escaping () -> Void) -> Void)?) -> ViewController func makeChatRecentActionsController(context: AccountContext, peer: Peer, adminPeerId: PeerId?) -> ViewController func makePrivacyAndSecurityController(context: AccountContext) -> ViewController + func makeStorageManagementController(context: AccountContext) -> ViewController func navigateToChatController(_ params: NavigateToChatControllerParams) func navigateToForumChannel(context: AccountContext, peerId: EnginePeer.Id, navigationController: NavigationController) func navigateToForumThread(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, messageId: EngineMessage.Id?, navigationController: NavigationController, activateInput: ChatControllerActivateInput?, keepStack: NavigateToChatKeepStack) -> Signal diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index 4a098de8ec..ac4db22ac0 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -185,7 +185,7 @@ private final class ChatListShimmerNode: ASDisplayNode { let interaction = ChatListNodeInteraction(context: context, animationCache: animationCache, animationRenderer: animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, setPeerThreadHidden: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, _, gesture, _ in gesture?.cancel() - }, present: { _ in }, openForumThread: { _, _ in }) + }, present: { _ in }, openForumThread: { _, _ in }, openStorageManagement: {}) interaction.isInlineMode = isInlineMode let items = (0 ..< 2).map { _ -> ChatListItem in diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 7e1ad8f506..239cae747b 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -2004,6 +2004,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { interaction.openPeer(peer, peer, threadId, false) self.listNode.clearHighlightAnimated(true) }) + }, openStorageManagement: { }) chatListInteraction.isSearchMode = true @@ -3205,7 +3206,7 @@ private final class ChatListSearchShimmerNode: ASDisplayNode { let interaction = ChatListNodeInteraction(context: context, animationCache: animationCache, animationRenderer: animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, setPeerThreadHidden: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, _, gesture, _ in gesture?.cancel() - }, present: { _ in }, openForumThread: { _, _ in }) + }, present: { _ in }, openForumThread: { _, _ in }, openStorageManagement: {}) var isInlineMode = false if case .topics = key { isInlineMode = false diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index a672e32274..dbed4fef69 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -91,6 +91,7 @@ public final class ChatListNodeInteraction { let activateChatPreview: (ChatListItem, Int64?, ASDisplayNode, ContextGesture?, CGPoint?) -> Void let present: (ViewController) -> Void let openForumThread: (EnginePeer.Id, Int64) -> Void + let openStorageManagement: () -> Void public var searchTextHighightState: String? var highlightedChatLocation: ChatListHighlightedLocation? @@ -132,7 +133,8 @@ public final class ChatListNodeInteraction { hidePsa: @escaping (EnginePeer.Id) -> Void, activateChatPreview: @escaping (ChatListItem, Int64?, ASDisplayNode, ContextGesture?, CGPoint?) -> Void, present: @escaping (ViewController) -> Void, - openForumThread: @escaping (EnginePeer.Id, Int64) -> Void + openForumThread: @escaping (EnginePeer.Id, Int64) -> Void, + openStorageManagement: @escaping () -> Void ) { self.activateSearch = activateSearch self.peerSelected = peerSelected @@ -162,6 +164,7 @@ public final class ChatListNodeInteraction { self.animationCache = animationCache self.animationRenderer = animationRenderer self.openForumThread = openForumThread + self.openStorageManagement = openStorageManagement } } @@ -567,6 +570,10 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL ), directionHint: entry.directionHint) case let .ArchiveIntro(presentationData): return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListArchiveInfoItem(theme: presentationData.theme, strings: presentationData.strings), directionHint: entry.directionHint) + case let .StorageInfo(presentationData, sizeFraction): + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListStorageInfoItem(theme: presentationData.theme, strings: presentationData.strings, sizeFraction: sizeFraction, action: { [weak nodeInteraction] in + nodeInteraction?.openStorageManagement() + }), directionHint: entry.directionHint) } } } @@ -778,6 +785,10 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL ), directionHint: entry.directionHint) case let .ArchiveIntro(presentationData): return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListArchiveInfoItem(theme: presentationData.theme, strings: presentationData.strings), directionHint: entry.directionHint) + case let .StorageInfo(presentationData, sizeFraction): + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListStorageInfoItem(theme: presentationData.theme, strings: presentationData.strings, sizeFraction: sizeFraction, action: { [weak nodeInteraction] in + nodeInteraction?.openStorageManagement() + }), directionHint: entry.directionHint) case .HeaderEntry: return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListEmptyHeaderItem(), directionHint: entry.directionHint) case let .AdditionalCategory(index: _, id, title, image, appearance, selected, presentationData): @@ -1258,6 +1269,12 @@ public final class ChatListNode: ListView { } self.peerSelected?(peer, threadId, true, true, nil) }) + }, openStorageManagement: { [weak self] in + guard let self else { + return + } + let controller = self.context.sharedContext.makeStorageManagementController(context: self.context) + self.push?(controller) }) nodeInteraction.isInlineMode = isInlineMode @@ -1323,15 +1340,94 @@ public final class ChatListNode: ListView { displayArchiveIntro = .single(false) } + let storageInfo: Signal + if "".isEmpty, case .chatList(groupId: .root) = location, chatListFilter == nil { + let storageBox = context.account.postbox.mediaBox.storageBox + storageInfo = storageBox.totalSize() + |> take(1) + |> mapToSignal { initialSize -> Signal in + let fractionLimit: Double = 0.3 + + let systemAttributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) + let deviceFreeSpace = (systemAttributes?[FileAttributeKey.systemFreeSize] as? NSNumber)?.int64Value ?? 0 + + let initialFraction: Double + if deviceFreeSpace != 0 && initialSize != 0 { + initialFraction = Double(initialSize) / Double(deviceFreeSpace + initialSize) + } else { + initialFraction = 0.0 + } + + let initialReportSize: Double? + if initialFraction > fractionLimit { + initialReportSize = Double(initialSize) + } else { + initialReportSize = nil + } + + final class ReportState { + var lastSize: Int64 + + init(lastSize: Int64) { + self.lastSize = lastSize + } + } + + let state = Atomic(value: ReportState(lastSize: initialSize)) + let updatedReportSize: Signal = Signal { subscriber in + let disposable = storageBox.totalSize().start(next: { size in + let updatedSize = state.with { state -> Int64 in + if abs(initialSize - size) > 50 * 1024 * 1024 { + state.lastSize = size + return size + } else { + return -1 + } + } + if updatedSize >= 0 { + let deviceFreeSpace = (systemAttributes?[FileAttributeKey.systemFreeSize] as? NSNumber)?.int64Value ?? 0 + + let updatedFraction: Double + if deviceFreeSpace != 0 && updatedSize != 0 { + updatedFraction = Double(updatedSize) / Double(deviceFreeSpace + updatedSize) + } else { + updatedFraction = 0.0 + } + + let updatedReportSize: Double? + if updatedFraction > fractionLimit { + updatedReportSize = Double(updatedSize) + } else { + updatedReportSize = nil + } + + subscriber.putNext(updatedReportSize) + } + }) + + return ActionDisposable { + disposable.dispose() + } + } + + return .single(initialReportSize) + |> then( + updatedReportSize + ) + } + } else { + storageInfo = .single(nil) + } + let currentPeerId: EnginePeer.Id = context.account.peerId - let chatListNodeViewTransition = combineLatest(queue: viewProcessingQueue, hideArchivedFolderByDefault, displayArchiveIntro, savedMessagesPeer, chatListViewUpdate, self.statePromise.get()) - |> mapToQueue { (hideArchivedFolderByDefault, displayArchiveIntro, savedMessagesPeer, updateAndFilter, state) -> Signal in + let chatListNodeViewTransition = combineLatest(queue: viewProcessingQueue, hideArchivedFolderByDefault, displayArchiveIntro, storageInfo, savedMessagesPeer, chatListViewUpdate, self.statePromise.get()) + |> mapToQueue { (hideArchivedFolderByDefault, displayArchiveIntro, storageInfo, savedMessagesPeer, updateAndFilter, state) -> Signal in let (update, filter) = updateAndFilter let previousHideArchivedFolderByDefaultValue = previousHideArchivedFolderByDefault.swap(hideArchivedFolderByDefault) - let (rawEntries, isLoading) = chatListNodeEntriesForView(update.list, state: state, savedMessagesPeer: savedMessagesPeer, foundPeers: state.foundPeers, hideArchivedFolderByDefault: hideArchivedFolderByDefault, displayArchiveIntro: displayArchiveIntro, mode: mode, chatListLocation: location) + let (rawEntries, isLoading) = chatListNodeEntriesForView(update.list, state: state, savedMessagesPeer: savedMessagesPeer, foundPeers: state.foundPeers, hideArchivedFolderByDefault: hideArchivedFolderByDefault, displayArchiveIntro: displayArchiveIntro, storageInfo: storageInfo, mode: mode, chatListLocation: location) let entries = rawEntries.filter { entry in switch entry { case let .PeerEntry(peerEntry): @@ -2361,7 +2457,7 @@ public final class ChatListNode: ListView { } else { break loop } - case .ArchiveIntro, .HeaderEntry, .AdditionalCategory: + case .ArchiveIntro, .StorageInfo, .HeaderEntry, .AdditionalCategory: break } } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift index 571ccd6a23..d9de6b9510 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift @@ -13,6 +13,7 @@ enum ChatListNodeEntryId: Hashable { case ThreadId(Int64) case GroupId(EngineChatList.Group) case ArchiveIntro + case StorageInfo case additionalCategory(Int) } @@ -234,6 +235,7 @@ enum ChatListNodeEntry: Comparable, Identifiable { case HoleEntry(EngineMessage.Index, theme: PresentationTheme) case GroupReferenceEntry(index: EngineChatList.Item.Index, presentationData: ChatListPresentationData, groupId: EngineChatList.Group, peers: [EngineChatList.GroupItem.Item], message: EngineMessage?, editing: Bool, unreadCount: Int, revealed: Bool, hiddenByDefault: Bool) case ArchiveIntro(presentationData: ChatListPresentationData) + case StorageInfo(presentationData: ChatListPresentationData, sizeFraction: Double) case AdditionalCategory(index: Int, id: Int, title: String, image: UIImage?, appearance: ChatListNodeAdditionalCategory.Appearance, selected: Bool, presentationData: ChatListPresentationData) var sortIndex: ChatListNodeEntrySortIndex { @@ -248,6 +250,8 @@ enum ChatListNodeEntry: Comparable, Identifiable { return .index(index) case .ArchiveIntro: return .index(.chatList(EngineChatList.Item.Index.ChatList.absoluteUpperBound.successor)) + case .StorageInfo: + return .index(.chatList(EngineChatList.Item.Index.ChatList.absoluteUpperBound.successor.successor)) case let .AdditionalCategory(index, _, _, _, _, _, _): return .additionalCategory(index) } @@ -270,6 +274,8 @@ enum ChatListNodeEntry: Comparable, Identifiable { return .GroupId(groupId) case .ArchiveIntro: return .ArchiveIntro + case .StorageInfo: + return .StorageInfo case let .AdditionalCategory(_, id, _, _, _, _, _): return .additionalCategory(id) } @@ -342,6 +348,18 @@ enum ChatListNodeEntry: Comparable, Identifiable { } else { return false } + case let .StorageInfo(lhsPresentationData, lhsInfo): + if case let .StorageInfo(rhsPresentationData, rhsInfo) = rhs { + if lhsPresentationData !== rhsPresentationData { + return false + } + if lhsInfo != rhsInfo { + return false + } + return true + } else { + return false + } case let .AdditionalCategory(lhsIndex, lhsId, lhsTitle, lhsImage, lhsAppearance, lhsSelected, lhsPresentationData): if case let .AdditionalCategory(rhsIndex, rhsId, rhsTitle, rhsImage, rhsAppearance, rhsSelected, rhsPresentationData) = rhs { if lhsIndex != rhsIndex { @@ -381,7 +399,7 @@ private func offsetPinnedIndex(_ index: EngineChatList.Item.Index, offset: UInt1 } } -func chatListNodeEntriesForView(_ view: EngineChatList, state: ChatListNodeState, savedMessagesPeer: EnginePeer?, foundPeers: [(EnginePeer, EnginePeer?)], hideArchivedFolderByDefault: Bool, displayArchiveIntro: Bool, mode: ChatListNodeMode, chatListLocation: ChatListControllerLocation) -> (entries: [ChatListNodeEntry], loading: Bool) { +func chatListNodeEntriesForView(_ view: EngineChatList, state: ChatListNodeState, savedMessagesPeer: EnginePeer?, foundPeers: [(EnginePeer, EnginePeer?)], hideArchivedFolderByDefault: Bool, displayArchiveIntro: Bool, storageInfo: Double?, mode: ChatListNodeMode, chatListLocation: ChatListControllerLocation) -> (entries: [ChatListNodeEntry], loading: Bool) { var result: [ChatListNodeEntry] = [] var pinnedIndexOffset: UInt16 = 0 @@ -643,6 +661,9 @@ func chatListNodeEntriesForView(_ view: EngineChatList, state: ChatListNodeState if displayArchiveIntro { result.append(.ArchiveIntro(presentationData: state.presentationData)) } + if let storageInfo { + result.append(.StorageInfo(presentationData: state.presentationData, sizeFraction: storageInfo)) + } result.append(.HeaderEntry) } diff --git a/submodules/ChatListUI/Sources/Node/ChatListStorageInfoItem.swift b/submodules/ChatListUI/Sources/Node/ChatListStorageInfoItem.swift new file mode 100644 index 0000000000..4b00cfe93c --- /dev/null +++ b/submodules/ChatListUI/Sources/Node/ChatListStorageInfoItem.swift @@ -0,0 +1,164 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Postbox +import Display +import SwiftSignalKit +import TelegramPresentationData +import ListSectionHeaderNode +import AppBundle + +class ChatListStorageInfoItem: ListViewItem { + let theme: PresentationTheme + let strings: PresentationStrings + let sizeFraction: Double + let action: () -> Void + + let selectable: Bool = true + + init(theme: PresentationTheme, strings: PresentationStrings, sizeFraction: Double, action: @escaping () -> Void) { + self.theme = theme + self.strings = strings + self.sizeFraction = sizeFraction + self.action = action + } + + func selected(listView: ListView) { + listView.clearHighlightAnimated(true) + + self.action() + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = ChatListStorageInfoItemNode() + + let (nodeLayout, apply) = node.asyncLayout()(self, params, false) + + node.insets = nodeLayout.insets + node.contentSize = nodeLayout.contentSize + + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in + apply() + }) + }) + } + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + assert(node() is ChatListStorageInfoItemNode) + if let nodeValue = node() as? ChatListStorageInfoItemNode { + + let layout = nodeValue.asyncLayout() + async { + let (nodeLayout, apply) = layout(self, params, nextItem == nil) + Queue.mainQueue().async { + completion(nodeLayout, { _ in + apply() + }) + } + } + } + } + } +} + +private let separatorHeight = 1.0 / UIScreen.main.scale + +private let titleFont = Font.semibold(15.0) +private let textFont = Font.regular(15.0) + +class ChatListStorageInfoItemNode: ListViewItemNode { + private let titleNode: TextNode + private let textNode: TextNode + private let arrowNode: ASImageNode + private let separatorNode: ASDisplayNode + + private var item: ChatListStorageInfoItem? + + required init() { + self.titleNode = TextNode() + self.textNode = TextNode() + self.arrowNode = ASImageNode() + self.separatorNode = ASDisplayNode() + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.separatorNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.textNode) + self.addSubnode(self.arrowNode) + } + + override func didLoad() { + super.didLoad() + } + + override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + let layout = self.asyncLayout() + let (_, apply) = layout(item as! ChatListStorageInfoItem, params, nextItem == nil) + apply() + } + + func asyncLayout() -> (_ item: ChatListStorageInfoItem, _ params: ListViewItemLayoutParams, _ isLast: Bool) -> (ListViewItemNodeLayout, () -> Void) { + let previousItem = self.item + + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeTextLayout = TextNode.asyncLayout(self.textNode) + + return { item, params, last in + let baseWidth = params.width - params.leftInset - params.rightInset + let _ = baseWidth + + let sideInset: CGFloat = params.leftInset + 16.0 + let height: CGFloat = 54.0 + let rightInset: CGFloat = sideInset + 24.0 + + let themeUpdated = item.theme !== previousItem?.theme + + let sizeString = dataSizeString(Int64(item.sizeFraction), formatting: DataSizeStringFormatting(strings: item.strings, decimalSeparator: ".")) + let rawTitleString = item.strings.ChatList_StorageHintTitle(sizeString) + let titleString = NSMutableAttributedString(attributedString: NSAttributedString(string: rawTitleString.string, font: titleFont, textColor: item.theme.rootController.navigationBar.primaryTextColor)) + if let range = rawTitleString.ranges.first { + titleString.addAttribute(.foregroundColor, value: item.theme.rootController.navigationBar.accentTextColor, range: range.range) + } + + let titleLayout = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleString, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - sideInset - rightInset, height: 100.0))) + + let textLayout = makeTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.strings.ChatList_StorageHintText, font: textFont, textColor: item.theme.rootController.navigationBar.secondaryTextColor), maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - sideInset - rightInset, height: 100.0))) + + let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: height), insets: UIEdgeInsets()) + + return (layout, { [weak self] in + if let strongSelf = self { + strongSelf.item = item + + if themeUpdated { + strongSelf.backgroundColor = item.theme.chatList.pinnedItemBackgroundColor + strongSelf.separatorNode.backgroundColor = item.theme.chatList.itemSeparatorColor + strongSelf.arrowNode.image = PresentationResourcesItemList.disclosureArrowImage(item.theme) + } + + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - UIScreenPixel), size: CGSize(width: layout.size.width, height: UIScreenPixel)) + + let _ = titleLayout.1() + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: sideInset, y: 9.0), size: titleLayout.0.size) + + let _ = textLayout.1() + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: sideInset, y: strongSelf.titleNode.frame.maxY - 0.0), size: textLayout.0.size) + + if let image = strongSelf.arrowNode.image { + strongSelf.arrowNode.frame = CGRect(origin: CGPoint(x: layout.size.width - sideInset - image.size.width + 8.0, y: floor((layout.size.height - image.size.height) / 2.0)), size: image.size) + } + + strongSelf.contentSize = layout.contentSize + strongSelf.insets = layout.insets + } + }) + } + } +} diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift index e2cc9fd261..4269a0d8e4 100644 --- a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift @@ -91,6 +91,7 @@ public final class HashtagSearchController: TelegramBaseController { gesture?.cancel() }, present: { _ in }, openForumThread: { _, _ in + }, openStorageManagement: { }) let previousSearchItems = Atomic<[ChatListSearchEntry]?>(value: nil) diff --git a/submodules/LegacyComponents/Sources/TGMediaAvatarMenuMixin.m b/submodules/LegacyComponents/Sources/TGMediaAvatarMenuMixin.m index ee5bb62838..2b1c3c8771 100644 --- a/submodules/LegacyComponents/Sources/TGMediaAvatarMenuMixin.m +++ b/submodules/LegacyComponents/Sources/TGMediaAvatarMenuMixin.m @@ -193,24 +193,24 @@ }]; [itemViews addObject:galleryItem]; - if (_hasSearchButton) - { - TGMenuSheetButtonItemView *viewItem = [[TGMenuSheetButtonItemView alloc] initWithTitle:TGLocalized(@"ProfilePhoto.SearchWeb") type:TGMenuSheetButtonTypeDefault fontSize:20.0 action:^ - { - __strong TGMediaAvatarMenuMixin *strongSelf = weakSelf; - if (strongSelf == nil) - return; - - __strong TGMenuSheetController *strongController = weakController; - if (strongController == nil) - return; - - [strongController dismissAnimated:true]; - if (strongSelf != nil) - strongSelf.requestSearchController(nil); - }]; - [itemViews addObject:viewItem]; - } +// if (_hasSearchButton) +// { +// TGMenuSheetButtonItemView *viewItem = [[TGMenuSheetButtonItemView alloc] initWithTitle:TGLocalized(@"ProfilePhoto.SearchWeb") type:TGMenuSheetButtonTypeDefault fontSize:20.0 action:^ +// { +// __strong TGMediaAvatarMenuMixin *strongSelf = weakSelf; +// if (strongSelf == nil) +// return; +// +// __strong TGMenuSheetController *strongController = weakController; +// if (strongController == nil) +// return; +// +// [strongController dismissAnimated:true]; +// if (strongSelf != nil) +// strongSelf.requestSearchController(nil); +// }]; +// [itemViews addObject:viewItem]; +// } if (_hasViewButton) { diff --git a/submodules/Postbox/Sources/StorageBox/StorageBox.swift b/submodules/Postbox/Sources/StorageBox/StorageBox.swift index b70f733cfa..930b8933a2 100644 --- a/submodules/Postbox/Sources/StorageBox/StorageBox.swift +++ b/submodules/Postbox/Sources/StorageBox/StorageBox.swift @@ -158,6 +158,9 @@ public final class StorageBox { let contentTypeStatsTable: ValueBoxTable let metadataTable: ValueBoxTable + let totalSizeSubscribers = Bag<(Int64) -> Void>() + private var totalSize: Int64 = 0 + init(queue: Queue, logger: StorageBox.Logger, basePath: String) { self.queue = queue self.logger = logger @@ -183,6 +186,8 @@ public final class StorageBox { self.metadataTable = ValueBoxTable(id: 21, keyType: .binary, compactValuesOnCreation: true) self.performUpdatesIfNeeded() + + self.updateTotalSize() } private func performUpdatesIfNeeded() { @@ -230,6 +235,35 @@ public final class StorageBox { }) } + func updateTotalSize() { + self.valueBox.begin() + + var totalSize: Int64 = 0 + self.valueBox.scan(self.contentTypeStatsTable, values: { key, value in + var size: Int64 = 0 + value.read(&size, offset: 0, length: 8) + totalSize += size + + return true + }) + + self.valueBox.commit() + + if self.totalSize != totalSize { + self.totalSize = totalSize + + for f in self.totalSizeSubscribers.copyItems() { + f(totalSize) + } + } + } + + func incrementalUpdateTotalSize() { + for f in self.totalSizeSubscribers.copyItems() { + f(totalSize) + } + } + func reset() { self.valueBox.begin() @@ -241,6 +275,8 @@ public final class StorageBox { self.valueBox.removeAllFromTable(self.metadataTable) self.valueBox.commit() + + self.updateTotalSize() } private func internalAddSize(contentType: UInt8, delta: Int64) { @@ -260,6 +296,8 @@ public final class StorageBox { } self.valueBox.set(self.contentTypeStatsTable, key: key, value: MemoryBuffer(memory: ¤tSize, capacity: 8, length: 8, freeWhenDone: false)) + + self.totalSize += delta } private func internalAddSize(peerId: Int64, contentType: UInt8, delta: Int64) { @@ -390,6 +428,8 @@ public final class StorageBox { } self.valueBox.commit() + + self.incrementalUpdateTotalSize() } private func peerIdsReferencing(hashId: HashId) -> Set { @@ -435,6 +475,8 @@ public final class StorageBox { } self.valueBox.commit() + + self.incrementalUpdateTotalSize() } func addEmptyReferencesIfNotReferenced(ids: [(id: Data, size: Int64)], contentType: UInt8) -> Int { @@ -508,6 +550,8 @@ public final class StorageBox { } self.valueBox.commit() + + self.incrementalUpdateTotalSize() } func allPeerIds() -> [PeerId] { @@ -615,6 +659,22 @@ public final class StorageBox { return (ids, nextId) } + func subscribeTotalSize(next: @escaping (Int64) -> Void) -> Disposable { + let index = self.totalSizeSubscribers.add(next) + + next(self.totalSize) + + let queue = self.queue + return ActionDisposable { [weak self] in + queue.async { + guard let self else { + return + } + self.totalSizeSubscribers.remove(index) + } + } + } + func all() -> [Entry] { var result: [Entry] = [] @@ -825,6 +885,8 @@ public final class StorageBox { self.valueBox.commit() + self.incrementalUpdateTotalSize() + return Array(resultIds) } @@ -854,6 +916,8 @@ public final class StorageBox { self.valueBox.commit() + self.incrementalUpdateTotalSize() + return Array(resultIds) } } @@ -973,4 +1037,12 @@ public final class StorageBox { return EmptyDisposable } } + + public func totalSize() -> Signal { + return self.impl.signalWith { impl, subscriber in + return impl.subscribeTotalSize(next: { value in + subscriber.putNext(value) + }) + } + } } diff --git a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift index c6228fbbd4..b0722eba61 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/DataAndStorageSettingsController.swift @@ -598,7 +598,6 @@ public func dataAndStorageController(context: AccountContext, focusOnItemTag: Da pushControllerImpl?(StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { category in return storageUsageExceptionsScreen(context: context, category: category) })) - //pushControllerImpl?(storageUsageController(context: context, cacheUsagePromise: cacheUsagePromise)) }, openNetworkUsage: { pushControllerImpl?(networkUsageStatsController(context: context)) }, openProxy: { diff --git a/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift b/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift index 8321e35880..b5fa7159d1 100644 --- a/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift +++ b/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift @@ -222,7 +222,7 @@ private final class TextSizeSelectionControllerNode: ASDisplayNode, UIScrollView }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, setPeerThreadHidden: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, _, gesture, _ in gesture?.cancel() - }, present: { _ in }, openForumThread: { _, _ in }) + }, present: { _ in }, openForumThread: { _, _ in }, openStorageManagement: {}) let chatListPresentationData = ChatListPresentationData(theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, disableAnimations: true) diff --git a/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift b/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift index 1fda96884f..2741d9e64c 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift @@ -843,7 +843,8 @@ final class ThemeAccentColorControllerNode: ASDisplayNode, UIScrollViewDelegate }, activateChatPreview: { _, _, _, gesture, _ in gesture?.cancel() }, present: { _ in - }, openForumThread: { _, _ in }) + }, openForumThread: { _, _ in }, + openStorageManagement: {}) let chatListPresentationData = ChatListPresentationData(theme: self.presentationData.theme, fontSize: self.presentationData.listsFontSize, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameSortOrder: self.presentationData.nameSortOrder, nameDisplayOrder: self.presentationData.nameDisplayOrder, disableAnimations: true) func makeChatListItem( diff --git a/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift b/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift index c1724ac735..a29dc13b84 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift @@ -367,7 +367,7 @@ final class ThemePreviewControllerNode: ASDisplayNode, UIScrollViewDelegate { }, activateChatPreview: { _, _, _, gesture, _ in gesture?.cancel() }, present: { _ in - }, openForumThread: { _, _ in }) + }, openForumThread: { _, _ in }, openStorageManagement: {}) func makeChatListItem( peer: EnginePeer, diff --git a/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift b/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift index 714829923b..060e5dd801 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift @@ -20,6 +20,7 @@ enum PendingMessageReuploadInfo { struct PendingMessageUploadedContentAndReuploadInfo { let content: PendingMessageUploadedContent let reuploadInfo: PendingMessageReuploadInfo? + let cacheReferenceKey: CachedSentMediaReferenceKey? } enum PendingMessageUploadedContentResult { @@ -81,25 +82,25 @@ func messageContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods } if let media = media.first as? TelegramMediaAction, media.action == .historyScreenshot { - return .immediate(.content(PendingMessageUploadedContentAndReuploadInfo(content: .messageScreenshot, reuploadInfo: nil)), .none) + return .immediate(.content(PendingMessageUploadedContentAndReuploadInfo(content: .messageScreenshot, reuploadInfo: nil, cacheReferenceKey: nil)), .none) } else if let forwardInfo = forwardInfo { - return .immediate(.content(PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil)), .text) + return .immediate(.content(PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)), .text) } else if let contextResult = contextResult { - return .immediate(.content(PendingMessageUploadedContentAndReuploadInfo(content: .chatContextResult(contextResult), reuploadInfo: nil)), .text) + return .immediate(.content(PendingMessageUploadedContentAndReuploadInfo(content: .chatContextResult(contextResult), reuploadInfo: nil, cacheReferenceKey: nil)), .text) } else if let media = media.first, let mediaResult = mediaContentToUpload(network: network, postbox: postbox, auxiliaryMethods: auxiliaryMethods, transformOutgoingMessageMedia: transformOutgoingMessageMedia, messageMediaPreuploadManager: messageMediaPreuploadManager, revalidationContext: revalidationContext, forceReupload: forceReupload, isGrouped: isGrouped, peerId: peerId, media: media, text: text, autoremoveMessageAttribute: autoremoveMessageAttribute, autoclearMessageAttribute: autoclearMessageAttribute, messageId: messageId, attributes: attributes) { return .signal(mediaResult, .media) } else { - return .signal(.single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .text(text), reuploadInfo: nil))), .text) + return .signal(.single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .text(text), reuploadInfo: nil, cacheReferenceKey: nil))), .text) } } func mediaContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods: AccountAuxiliaryMethods, transformOutgoingMessageMedia: TransformOutgoingMessageMedia?, messageMediaPreuploadManager: MessageMediaPreuploadManager, revalidationContext: MediaReferenceRevalidationContext, forceReupload: Bool, isGrouped: Bool, peerId: PeerId, media: Media, text: String, autoremoveMessageAttribute: AutoremoveTimeoutMessageAttribute?, autoclearMessageAttribute: AutoclearTimeoutMessageAttribute?, messageId: MessageId?, attributes: [MessageAttribute]) -> Signal? { if let image = media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) { if peerId.namespace == Namespaces.Peer.SecretChat, let resource = largest.resource as? SecretFileMediaResource { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .secretMedia(.inputEncryptedFile(id: resource.fileId, accessHash: resource.accessHash), resource.decryptedSize, resource.key), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .secretMedia(.inputEncryptedFile(id: resource.fileId, accessHash: resource.accessHash), resource.decryptedSize, resource.key), reuploadInfo: nil, cacheReferenceKey: nil))) } if peerId.namespace != Namespaces.Peer.SecretChat, let reference = image.reference, case let .cloud(id, accessHash, maybeFileReference) = reference, let fileReference = maybeFileReference { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaPhoto(flags: 0, id: Api.InputPhoto.inputPhoto(id: id, accessHash: accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: nil), text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaPhoto(flags: 0, id: Api.InputPhoto.inputPhoto(id: id, accessHash: accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: nil), text), reuploadInfo: nil, cacheReferenceKey: nil))) } else { return uploadedMediaImageContent(network: network, postbox: postbox, transformOutgoingMessageMedia: transformOutgoingMessageMedia, forceReupload: forceReupload, isGrouped: isGrouped, peerId: peerId, image: image, messageId: messageId, text: text, attributes: attributes, autoremoveMessageAttribute: autoremoveMessageAttribute, autoclearMessageAttribute: autoclearMessageAttribute) } @@ -109,7 +110,7 @@ func mediaContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods: for attribute in file.attributes { if case let .Sticker(_, packReferenceValue, _) = attribute { if let _ = packReferenceValue { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: PendingMessageUploadedContent.text(text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: PendingMessageUploadedContent.text(text), reuploadInfo: nil, cacheReferenceKey: nil))) } } } @@ -128,7 +129,7 @@ func mediaContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods: } |> mapToSignal { validatedResource -> Signal in if let validatedResource = validatedResource.updatedResource as? TelegramCloudMediaResourceWithFileReference, let reference = validatedResource.fileReference { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaDocument(flags: 0, id: Api.InputDocument.inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: reference)), ttlSeconds: nil, query: nil), text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaDocument(flags: 0, id: Api.InputDocument.inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: reference)), ttlSeconds: nil, query: nil), text), reuploadInfo: nil, cacheReferenceKey: nil))) } else { return .fail(.generic) } @@ -144,14 +145,14 @@ func mediaContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods: } } - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaDocument(flags: flags, id: Api.InputDocument.inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), ttlSeconds: nil, query: emojiSearchQuery), text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaDocument(flags: flags, id: Api.InputDocument.inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: resource.fileReference ?? Data())), ttlSeconds: nil, query: emojiSearchQuery), text), reuploadInfo: nil, cacheReferenceKey: nil))) } } else { return uploadedMediaFileContent(network: network, postbox: postbox, auxiliaryMethods: auxiliaryMethods, transformOutgoingMessageMedia: transformOutgoingMessageMedia, messageMediaPreuploadManager: messageMediaPreuploadManager, forceReupload: forceReupload, isGrouped: isGrouped, peerId: peerId, messageId: messageId, text: text, attributes: attributes, file: file) } } else if let contact = media as? TelegramMediaContact { let input = Api.InputMedia.inputMediaContact(phoneNumber: contact.phoneNumber, firstName: contact.firstName, lastName: contact.lastName, vcard: contact.vCardData ?? "") - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(input, text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(input, text), reuploadInfo: nil, cacheReferenceKey: nil))) } else if let map = media as? TelegramMediaMap { let input: Api.InputMedia var flags: Int32 = 1 << 1 @@ -172,7 +173,7 @@ func mediaContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods: } else { input = .inputMediaGeoPoint(geoPoint: Api.InputGeoPoint.inputGeoPoint(flags: geoFlags, lat: map.latitude, long: map.longitude, accuracyRadius: map.accuracyRadius.flatMap({ Int32($0) }))) } - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(input, text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(input, text), reuploadInfo: nil, cacheReferenceKey: nil))) } else if let poll = media as? TelegramMediaPoll { if peerId.namespace == Namespaces.Peer.SecretChat { return .fail(.generic) @@ -209,10 +210,10 @@ func mediaContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods: pollMediaFlags |= 1 << 1 } let inputPoll = Api.InputMedia.inputMediaPoll(flags: pollMediaFlags, poll: Api.Poll.poll(id: 0, flags: pollFlags, question: poll.text, answers: poll.options.map({ $0.apiOption }), closePeriod: poll.deadlineTimeout, closeDate: nil), correctAnswers: correctAnswers, solution: mappedSolution, solutionEntities: mappedSolutionEntities) - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(inputPoll, text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(inputPoll, text), reuploadInfo: nil, cacheReferenceKey: nil))) } else if let media = media as? TelegramMediaDice { let input = Api.InputMedia.inputMediaDice(emoticon: media.emoji) - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(input, text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(input, text), reuploadInfo: nil, cacheReferenceKey: nil))) } else { return nil } @@ -220,7 +221,7 @@ func mediaContentToUpload(network: Network, postbox: Postbox, auxiliaryMethods: private enum PredownloadedResource { case localReference(CachedSentMediaReferenceKey?) - case media(Media) + case media(Media, CachedSentMediaReferenceKey?) case none } @@ -256,7 +257,7 @@ private func maybePredownloadedImageResource(postbox: Postbox, peerId: PeerId, r subscriber.putNext(cachedSentMediaReference(postbox: postbox, key: reference) |> mapError { _ -> PendingMessageUploadError in } |> map { media -> PredownloadedResource in if let media = media { - return .media(media) + return .media(media, reference) } else { return .localReference(reference) } @@ -295,7 +296,7 @@ private func maybePredownloadedFileResource(postbox: Postbox, auxiliaryMethods: } return cachedSentMediaReference(postbox: postbox, key: reference) |> map { media -> PredownloadedResource in if let media = media { - return .media(media) + return .media(media, reference) } else { return .localReference(reference) } @@ -320,7 +321,7 @@ private func maybeCacheUploadedResource(postbox: Postbox, key: CachedSentMediaRe private func uploadedMediaImageContent(network: Network, postbox: Postbox, transformOutgoingMessageMedia: TransformOutgoingMessageMedia?, forceReupload: Bool, isGrouped: Bool, peerId: PeerId, image: TelegramMediaImage, messageId: MessageId?, text: String, attributes: [MessageAttribute], autoremoveMessageAttribute: AutoremoveTimeoutMessageAttribute?, autoclearMessageAttribute: AutoclearTimeoutMessageAttribute?) -> Signal { guard let largestRepresentation = largestImageRepresentation(image.representations) else { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .text(text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .text(text), reuploadInfo: nil, cacheReferenceKey: nil))) } let predownloadedResource: Signal = maybePredownloadedImageResource(postbox: postbox, peerId: peerId, resource: largestRepresentation.resource, forceRefresh: forceReupload) @@ -328,7 +329,7 @@ private func uploadedMediaImageContent(network: Network, postbox: Postbox, trans |> mapToSignal { result -> Signal in var referenceKey: CachedSentMediaReferenceKey? switch result { - case let .media(media): + case let .media(media, key): if !forceReupload, let image = media as? TelegramMediaImage, let reference = image.reference, case let .cloud(id, accessHash, maybeFileReference) = reference, let fileReference = maybeFileReference { var flags: Int32 = 0 var ttlSeconds: Int32? @@ -336,11 +337,18 @@ private func uploadedMediaImageContent(network: Network, postbox: Postbox, trans flags |= 1 << 0 ttlSeconds = autoclearMessageAttribute.timeout } + + for attribute in attributes { + if let _ = attribute as? MediaSpoilerMessageAttribute { + flags |= 1 << 1 + } + } return .single(.progress(1.0)) |> then( - .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaPhoto(flags: flags, id: .inputPhoto(id: id, accessHash: accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: ttlSeconds), text), reuploadInfo: nil))) + .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaPhoto(flags: flags, id: .inputPhoto(id: id, accessHash: accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: ttlSeconds), text), reuploadInfo: nil, cacheReferenceKey: nil))) ) } + referenceKey = key case let .localReference(key): referenceKey = key case .none: @@ -448,7 +456,7 @@ private func uploadedMediaImageContent(network: Network, postbox: Postbox, trans |> mapToSignal { inputPeer -> Signal in if let inputPeer = inputPeer { if autoclearMessageAttribute != nil { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaUploadedPhoto(flags: flags, file: file, stickers: stickers, ttlSeconds: ttlSeconds), text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaUploadedPhoto(flags: flags, file: file, stickers: stickers, ttlSeconds: ttlSeconds), text), reuploadInfo: nil, cacheReferenceKey: nil))) } return network.request(Api.functions.messages.uploadMedia(peer: inputPeer, media: Api.InputMedia.inputMediaUploadedPhoto(flags: flags, file: file, stickers: stickers, ttlSeconds: ttlSeconds))) @@ -466,7 +474,7 @@ private func uploadedMediaImageContent(network: Network, postbox: Postbox, trans if hasSpoiler { flags |= 1 << 1 } - return maybeCacheUploadedResource(postbox: postbox, key: referenceKey, result: .content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaPhoto(flags: flags, id: .inputPhoto(id: id, accessHash: accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: ttlSeconds), text), reuploadInfo: nil)), media: mediaImage) + return maybeCacheUploadedResource(postbox: postbox, key: referenceKey, result: .content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaPhoto(flags: flags, id: .inputPhoto(id: id, accessHash: accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: ttlSeconds), text), reuploadInfo: nil, cacheReferenceKey: nil)), media: mediaImage) } default: break @@ -478,7 +486,7 @@ private func uploadedMediaImageContent(network: Network, postbox: Postbox, trans } } case let .inputSecretFile(file, size, key): - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .secretMedia(file, size, key), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .secretMedia(file, size, key), reuploadInfo: nil, cacheReferenceKey: nil))) } } } @@ -606,13 +614,21 @@ private func uploadedMediaFileContent(network: Network, postbox: Postbox, auxili |> mapToSignal { result -> Signal in var referenceKey: CachedSentMediaReferenceKey? switch result { - case let .media(media): + case let .media(media, key): if !forceReupload, let file = media as? TelegramMediaFile, let resource = file.resource as? CloudDocumentMediaResource, let fileReference = resource.fileReference { + var flags: Int32 = 0 + for attribute in attributes { + if let _ = attribute as? MediaSpoilerMessageAttribute { + flags |= 1 << 2 + } + } + return .single(.progress(1.0)) |> then( - .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaDocument(flags: 0, id: Api.InputDocument.inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: nil, query: nil), text), reuploadInfo: nil))) + .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(Api.InputMedia.inputMediaDocument(flags: flags, id: Api.InputDocument.inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: nil, query: nil), text), reuploadInfo: nil, cacheReferenceKey: nil))) ) } + referenceKey = key case let .localReference(key): referenceKey = key case .none: @@ -779,11 +795,13 @@ private func uploadedMediaFileContent(network: Network, postbox: Postbox, auxili } if ttlSeconds != nil { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaUploadedDocument(flags: flags, file: inputFile, thumb: thumbnailFile, mimeType: file.mimeType, attributes: inputDocumentAttributesFromFileAttributes(file.attributes), stickers: stickers, ttlSeconds: ttlSeconds), text), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaUploadedDocument(flags: flags, file: inputFile, thumb: thumbnailFile, mimeType: file.mimeType, attributes: inputDocumentAttributesFromFileAttributes(file.attributes), stickers: stickers, ttlSeconds: ttlSeconds), text), reuploadInfo: nil, cacheReferenceKey: referenceKey))) } if !isGrouped { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaUploadedDocument(flags: flags, file: inputFile, thumb: thumbnailFile, mimeType: file.mimeType, attributes: inputDocumentAttributesFromFileAttributes(file.attributes), stickers: stickers, ttlSeconds: ttlSeconds), text), reuploadInfo: nil))) + let resultInfo = PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaUploadedDocument(flags: flags, file: inputFile, thumb: thumbnailFile, mimeType: file.mimeType, attributes: inputDocumentAttributesFromFileAttributes(file.attributes), stickers: stickers, ttlSeconds: ttlSeconds), text), reuploadInfo: nil, cacheReferenceKey: referenceKey) + + return .single(.content(resultInfo)) } return postbox.transaction { transaction -> Api.InputPeer? in @@ -800,9 +818,9 @@ private func uploadedMediaFileContent(network: Network, postbox: Postbox, auxili if let document = document, let mediaFile = telegramMediaFileFromApiDocument(document), let resource = mediaFile.resource as? CloudDocumentMediaResource, let fileReference = resource.fileReference { var flags: Int32 = 0 if hasSpoiler { - flags |= (1 << 1) + flags |= (1 << 2) } - return maybeCacheUploadedResource(postbox: postbox, key: referenceKey, result: .content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaDocument(flags: flags, id: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: nil, query: nil), text), reuploadInfo: nil)), media: mediaFile) + return maybeCacheUploadedResource(postbox: postbox, key: referenceKey, result: .content(PendingMessageUploadedContentAndReuploadInfo(content: .media(.inputMediaDocument(flags: flags, id: .inputDocument(id: resource.fileId, accessHash: resource.accessHash, fileReference: Buffer(data: fileReference)), ttlSeconds: nil, query: nil), text), reuploadInfo: nil, cacheReferenceKey: nil)), media: mediaFile) } default: break @@ -818,7 +836,7 @@ private func uploadedMediaFileContent(network: Network, postbox: Postbox, auxili } case let .inputSecretFile(file, size, key): if case .done = fileAndThumbnailResult { - return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .secretMedia(file, size, key), reuploadInfo: nil))) + return .single(.content(PendingMessageUploadedContentAndReuploadInfo(content: .secretMedia(file, size, key), reuploadInfo: nil, cacheReferenceKey: nil))) } else { return .complete() } diff --git a/submodules/TelegramCore/Sources/State/ApplyUpdateMessage.swift b/submodules/TelegramCore/Sources/State/ApplyUpdateMessage.swift index 2fcbf53e45..bfa4f25f75 100644 --- a/submodules/TelegramCore/Sources/State/ApplyUpdateMessage.swift +++ b/submodules/TelegramCore/Sources/State/ApplyUpdateMessage.swift @@ -41,7 +41,7 @@ func applyMediaResourceChanges(from: Media, to: Media, postbox: Postbox, force: } } -func applyUpdateMessage(postbox: Postbox, stateManager: AccountStateManager, message: Message, result: Api.Updates, accountPeerId: PeerId) -> Signal { +func applyUpdateMessage(postbox: Postbox, stateManager: AccountStateManager, message: Message, cacheReferenceKey: CachedSentMediaReferenceKey?, result: Api.Updates, accountPeerId: PeerId) -> Signal { return postbox.transaction { transaction -> Void in let messageId: Int32? var apiMessage: Api.Message? @@ -284,6 +284,27 @@ func applyUpdateMessage(postbox: Postbox, stateManager: AccountStateManager, mes } } } + + if updatedMessage.id.namespace == Namespaces.Message.Cloud, let cacheReferenceKey = cacheReferenceKey { + var storeMedia: Media? + var mediaCount = 0 + for media in updatedMessage.media { + if let image = media as? TelegramMediaImage { + storeMedia = image + mediaCount += 1 + } else if let file = media as? TelegramMediaFile { + storeMedia = file + mediaCount += 1 + } + } + if mediaCount > 1 { + storeMedia = nil + } + + if let storeMedia = storeMedia { + storeCachedSentMediaReference(transaction: transaction, key: cacheReferenceKey, media: storeMedia) + } + } } for file in sentStickers { if let entry = CodableEntry(RecentMediaItem(file)) { diff --git a/submodules/TelegramCore/Sources/State/CachedSentMediaReferences.swift b/submodules/TelegramCore/Sources/State/CachedSentMediaReferences.swift index 0d56508966..da48daf815 100644 --- a/submodules/TelegramCore/Sources/State/CachedSentMediaReferences.swift +++ b/submodules/TelegramCore/Sources/State/CachedSentMediaReferences.swift @@ -28,13 +28,27 @@ enum CachedSentMediaReferenceKey { } } +private struct CachedMediaReferenceEntry: Codable { + var data: Data +} + func cachedSentMediaReference(postbox: Postbox, key: CachedSentMediaReferenceKey) -> Signal { - return .single(nil) - /*return postbox.transaction { transaction -> Media? in - return transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedSentMediaReferences, key: key.key)) as? Media - }*/ + return postbox.transaction { transaction -> Media? in + guard let entry = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedSentMediaReferences, key: key.key))?.get(CachedMediaReferenceEntry.self) else { + return nil + } + + return PostboxDecoder(buffer: MemoryBuffer(data: entry.data)).decodeRootObject() as? Media + } } func storeCachedSentMediaReference(transaction: Transaction, key: CachedSentMediaReferenceKey, media: Media) { - //transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedSentMediaReferences, key: key.key), entry: media) + let encoder = PostboxEncoder() + encoder.encodeRootObject(media) + let mediaData = encoder.makeData() + + guard let entry = CodableEntry(CachedMediaReferenceEntry(data: mediaData)) else { + return + } + transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.cachedSentMediaReferences, key: key.key), entry: entry) } diff --git a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift index b48949a095..94978e384b 100644 --- a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift +++ b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift @@ -450,7 +450,7 @@ public final class PendingMessageManager { } let sendMessage: Signal = strongSelf.sendGroupMessagesContent(network: strongSelf.network, postbox: strongSelf.postbox, stateManager: strongSelf.stateManager, accountPeerId: strongSelf.accountPeerId, group: messages.map { data in let (_, message, forwardInfo) = data - return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil)) + return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) }) |> map { next -> PendingMessageResult in return .progress(1.0) @@ -1149,7 +1149,7 @@ public final class PendingMessageManager { |> mapError { _ -> MTRpcError in } case let .result(result): - return strongSelf.applySentMessage(postbox: postbox, stateManager: stateManager, message: message, result: result) + return strongSelf.applySentMessage(postbox: postbox, stateManager: stateManager, message: message, content: content, result: result) |> mapError { _ -> MTRpcError in } } @@ -1232,7 +1232,7 @@ public final class PendingMessageManager { } } - private func applySentMessage(postbox: Postbox, stateManager: AccountStateManager, message: Message, result: Api.Updates) -> Signal { + private func applySentMessage(postbox: Postbox, stateManager: AccountStateManager, message: Message, content: PendingMessageUploadedContentAndReuploadInfo, result: Api.Updates) -> Signal { var apiMessage: Api.Message? for resultMessage in result.messages { if let id = resultMessage.id(namespace: Namespaces.Message.allScheduled.contains(message.id.namespace) ? Namespaces.Message.ScheduledCloud : Namespaces.Message.Cloud) { @@ -1249,7 +1249,7 @@ public final class PendingMessageManager { namespace = id.namespace } - return applyUpdateMessage(postbox: postbox, stateManager: stateManager, message: message, result: result, accountPeerId: self.accountPeerId) + return applyUpdateMessage(postbox: postbox, stateManager: stateManager, message: message, cacheReferenceKey: content.cacheReferenceKey, result: result, accountPeerId: self.accountPeerId) |> afterDisposed { [weak self] in if let strongSelf = self { strongSelf.queue.async { diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 5e24fa5ba7..81ee929b36 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -351,6 +351,7 @@ swift_library( "//submodules/TelegramUI/Components/ChatListHeaderComponent", "//submodules/TelegramUI/Components/ChatInputNode", "//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode", + "//submodules/TelegramUI/Components/StorageUsageScreen", "//submodules/MediaPasteboardUI:MediaPasteboardUI", "//submodules/DrawingUI:DrawingUI", "//submodules/FeaturedStickersScreen:FeaturedStickersScreen", diff --git a/submodules/TelegramUI/Components/AnimatedTextComponent/BUILD b/submodules/TelegramUI/Components/AnimatedTextComponent/BUILD new file mode 100644 index 0000000000..53ec1414a6 --- /dev/null +++ b/submodules/TelegramUI/Components/AnimatedTextComponent/BUILD @@ -0,0 +1,20 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "AnimatedTextComponent", + module_name = "AnimatedTextComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/AnimatedTextComponent/Sources/AnimatedTextComponent.swift b/submodules/TelegramUI/Components/AnimatedTextComponent/Sources/AnimatedTextComponent.swift new file mode 100644 index 0000000000..fae7c25777 --- /dev/null +++ b/submodules/TelegramUI/Components/AnimatedTextComponent/Sources/AnimatedTextComponent.swift @@ -0,0 +1,190 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class AnimatedTextComponent: Component { + public struct Item: Equatable { + public enum Content: Equatable { + case text(String) + case number(Int) + } + + public var id: AnyHashable + public var isUnbreakable: Bool + public var content: Content + + public init(id: AnyHashable, isUnbreakable: Bool = false, content: Content) { + self.id = id + self.isUnbreakable = isUnbreakable + self.content = content + } + } + + public let font: UIFont + public let color: UIColor + public let items: [Item] + + public init( + font: UIFont, + color: UIColor, + items: [Item] + ) { + self.font = font + self.color = color + self.items = items + } + + public static func ==(lhs: AnimatedTextComponent, rhs: AnimatedTextComponent) -> Bool { + if lhs.font != rhs.font { + return false + } + if lhs.color != rhs.color { + return false + } + if lhs.items != rhs.items { + return false + } + return true + } + + private struct CharacterKey: Hashable { + var itemId: AnyHashable + var index: Int + var value: String + } + + public final class View: UIView { + private var characters: [CharacterKey: ComponentView] = [:] + + private var component: AnimatedTextComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func update(component: AnimatedTextComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + var size = CGSize() + + let delayNorm: CGFloat = 0.002 + + var validKeys: [CharacterKey] = [] + for item in component.items { + var itemText: [String] = [] + switch item.content { + case let .text(text): + if item.isUnbreakable { + itemText = [text] + } else { + itemText = text.map(String.init) + } + case let .number(value): + if item.isUnbreakable { + itemText = ["\(value)"] + } else { + itemText = "\(value)".map(String.init) + } + } + var index = 0 + for character in itemText { + let characterKey = CharacterKey(itemId: item.id, index: index, value: character) + index += 1 + + validKeys.append(characterKey) + + var characterTransition = transition + let characterView: ComponentView + if let current = self.characters[characterKey] { + characterView = current + } else { + characterTransition = .immediate + characterView = ComponentView() + self.characters[characterKey] = characterView + } + + let characterSize = characterView.update( + transition: characterTransition, + component: AnyComponent(Text( + text: String(character), + font: component.font, + color: component.color + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let characterFrame = CGRect(origin: CGPoint(x: size.width, y: 0.0), size: characterSize) + if let characterComponentView = characterView.view { + var animateIn = false + if characterComponentView.superview == nil { + self.addSubview(characterComponentView) + animateIn = true + } + + if characterComponentView.frame != characterFrame { + if characterTransition.animation.isImmediate { + characterComponentView.frame = characterFrame + } else { + characterComponentView.bounds = CGRect(origin: CGPoint(), size: characterFrame.size) + let deltaPosition = CGPoint(x: characterFrame.midX - characterComponentView.frame.midX, y: characterFrame.midY - characterComponentView.frame.midY) + characterComponentView.center = characterFrame.center + characterComponentView.layer.animatePosition(from: CGPoint(x: -deltaPosition.x, y: -deltaPosition.y), to: CGPoint(), duration: 0.4, delay: delayNorm * size.width, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + } + } + characterTransition.setFrame(view: characterComponentView, frame: characterFrame) + + + if animateIn, !transition.animation.isImmediate { + characterComponentView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.4, delay: delayNorm * size.width, timingFunction: kCAMediaTimingFunctionSpring) + //characterComponentView.layer.animateSpring(from: (characterSize.height * 0.5) as NSNumber, to: 0.0 as NSNumber, keyPath: "position.y", duration: 0.5, additive: true) + characterComponentView.layer.animatePosition(from: CGPoint(x: 0.0, y: characterSize.height * 0.5), to: CGPoint(), duration: 0.4, delay: delayNorm * size.width, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + characterComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18, delay: delayNorm * size.width) + } + } + + size.height = max(size.height, characterSize.height) + size.width += max(0.0, characterSize.width - UIScreenPixel * 2.0) + } + } + + var removedKeys: [CharacterKey] = [] + for (key, characterView) in self.characters { + if !validKeys.contains(key) { + removedKeys.append(key) + + if let characterComponentView = characterView.view { + if !transition.animation.isImmediate { + characterComponentView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.4, delay: delayNorm * characterComponentView.frame.minX, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + characterComponentView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -characterComponentView.bounds.height * 0.4), duration: 0.4, delay: delayNorm * characterComponentView.frame.minX, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + characterComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, delay: delayNorm * characterComponentView.frame.minX, removeOnCompletion: false, completion: { [weak characterComponentView] _ in + characterComponentView?.removeFromSuperview() + }) + } else { + characterComponentView.removeFromSuperview() + } + } + } + } + for removedKey in removedKeys { + self.characters.removeValue(forKey: removedKey) + } + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public 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/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index e2086001ce..2e5710d15e 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -6663,7 +6663,7 @@ public final class EmojiPagerContentComponent: Component { if hasPremium { maxTopLineCount = 2 } else { - maxTopLineCount = 5 + maxTopLineCount = 6 } for reactionItem in topReactionItems { @@ -6721,7 +6721,7 @@ public final class EmojiPagerContentComponent: Component { if hasPremium { maxRecentLineCount = 10 } else { - maxRecentLineCount = 5 + maxRecentLineCount = 10 } let popularTitle = hasRecent ? strings.Chat_ReactionSection_Recent : strings.Chat_ReactionSection_Popular diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/BUILD b/submodules/TelegramUI/Components/StorageUsageScreen/BUILD index 5a3d6578ef..a801b5c688 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/BUILD +++ b/submodules/TelegramUI/Components/StorageUsageScreen/BUILD @@ -26,6 +26,7 @@ swift_library( "//submodules/TelegramStringFormatting:TelegramStringFormatting", "//submodules/PresentationDataUtils:PresentationDataUtils", "//submodules/TelegramUI/Components/EmojiStatusComponent", + "//submodules/TelegramUI/Components/AnimatedTextComponent", "//submodules/CheckNode", "//submodules/Markdown", "//submodules/ContextUI", diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift index 00aa1b8386..ad48e6b6da 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/PieChartComponent.swift @@ -13,21 +13,6 @@ import MultilineTextComponent import EmojiStatusComponent import Postbox -private func interpolateChartData(start: PieChartComponent.ChartData, end: PieChartComponent.ChartData, progress: CGFloat) -> PieChartComponent.ChartData { - if start.items.count != end.items.count { - return start - } - - var result = end - for i in 0 ..< result.items.count { - result.items[i].value = (1.0 - progress) * start.items[i].value + progress * end.items[i].value - result.items[i].color = start.items[i].color.interpolateTo(end.items[i].color, fraction: progress) ?? end.items[i].color - result.items[i].mergeFactor = (1.0 - progress) * start.items[i].mergeFactor + progress * end.items[i].mergeFactor - } - - return result -} - private func processChartData(data: PieChartComponent.ChartData) -> PieChartComponent.ChartData { var data = data @@ -87,6 +72,95 @@ private func processChartData(data: PieChartComponent.ChartData) -> PieChartComp private let chartLabelFont = Font.with(size: 16.0, design: .round, weight: .semibold) +private final class ChartSelectionTooltip: Component { + let theme: PresentationTheme + let fractionText: String + let title: String + let sizeText: String + + init( + theme: PresentationTheme, + fractionText: String, + title: String, + sizeText: String + ) { + self.theme = theme + self.fractionText = fractionText + self.title = title + self.sizeText = sizeText + } + + static func ==(lhs: ChartSelectionTooltip, rhs: ChartSelectionTooltip) -> Bool { + return true + } + + final class View: UIView { + private let backgroundView: BlurredBackgroundView + private let title = ComponentView() + + override init(frame: CGRect) { + self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + + self.backgroundView.layer.shadowOpacity = 0.12 + self.backgroundView.layer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor + self.backgroundView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0) + self.backgroundView.layer.shadowRadius = 8.0 + + super.init(frame: frame) + + self.addSubview(self.backgroundView) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func update(component: ChartSelectionTooltip, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let sideInset: CGFloat = 10.0 + let height: CGFloat = 24.0 + + let text = NSMutableAttributedString() + text.append(NSAttributedString(string: component.fractionText + " ", font: Font.semibold(12.0), textColor: component.theme.list.itemPrimaryTextColor)) + text.append(NSAttributedString(string: component.title + " ", font: Font.regular(12.0), textColor: component.theme.list.itemPrimaryTextColor)) + text.append(NSAttributedString(string: component.sizeText, font: Font.semibold(12.0), textColor: component.theme.list.itemAccentColor)) + + let titleSize = self.title.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: .plain(text) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setFrame(view: titleView, frame: titleFrame) + } + + let size = CGSize(width: sideInset * 2.0 + titleSize.width, height: height) + + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size)) + self.backgroundView.updateColor(color: component.theme.list.plainBackgroundColor.withMultipliedAlpha(0.88), transition: .immediate) + self.backgroundView.update(size: size, cornerRadius: 10.0, transition: transition.containedViewLayoutTransition) + + self.backgroundView.layer.shadowPath = UIBezierPath(roundedRect: self.backgroundView.bounds, cornerRadius: 10.0).cgPath + + return size + } + } + + 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) + } +} + private final class ChartLabel: UIView { private let label: ImmediateTextView private var currentText: String? @@ -138,14 +212,16 @@ final class PieChartComponent: Component { struct Item: Equatable { var id: StorageUsageScreenComponent.Category var displayValue: Double + var displaySize: Int64 var value: Double var color: UIColor var mergeable: Bool var mergeFactor: CGFloat - init(id: StorageUsageScreenComponent.Category, displayValue: Double, value: Double, color: UIColor, mergeable: Bool, mergeFactor: CGFloat) { + init(id: StorageUsageScreenComponent.Category, displayValue: Double, displaySize: Int64, value: Double, color: UIColor, mergeable: Bool, mergeFactor: CGFloat) { self.id = id self.displayValue = displayValue + self.displaySize = displaySize self.value = value self.color = color self.mergeable = mergeable @@ -161,13 +237,16 @@ final class PieChartComponent: Component { } let theme: PresentationTheme + let strings: PresentationStrings let chartData: ChartData init( theme: PresentationTheme, + strings: PresentationStrings, chartData: ChartData ) { self.theme = theme + self.strings = strings self.chartData = chartData } @@ -175,83 +254,127 @@ final class PieChartComponent: Component { if lhs.theme !== rhs.theme { return false } + if lhs.strings !== rhs.strings { + return false + } if lhs.chartData != rhs.chartData { return false } return true } - private final class ChartDataView: UIView { - private(set) var theme: PresentationTheme? - private(set) var data: ChartData? - private(set) var selectedKey: AnyHashable? + private struct CalculatedLabel { + var image: UIImage + var alpha: CGFloat + var angle: CGFloat + var radius: CGFloat + var scale: CGFloat - private var currentAnimation: (start: ChartData, end: ChartData, current: ChartData, progress: CGFloat)? - private var animator: DisplayLinkAnimator? + init( + image: UIImage, + alpha: CGFloat, + angle: CGFloat, + radius: CGFloat, + scale: CGFloat + ) { + self.image = image + self.alpha = alpha + self.angle = angle + self.radius = radius + self.scale = scale + } - private var labels: [AnyHashable: ChartLabel] = [:] + func interpolateTo(_ other: CalculatedLabel, amount: CGFloat) -> CalculatedLabel { + return CalculatedLabel( + image: other.image, + alpha: self.alpha.interpolate(to: other.alpha, amount: amount), + angle: self.angle.interpolate(to: other.angle, amount: amount), + radius: self.radius.interpolate(to: other.radius, amount: amount), + scale: self.scale.interpolate(to: other.scale, amount: amount) + ) + } + } + + private struct CalculatedSection { + var id: StorageUsageScreenComponent.Category + var color: UIColor + var innerAngle: Range + var outerAngle: Range + var innerRadius: CGFloat + var outerRadius: CGFloat + var label: CalculatedLabel? - override init(frame: CGRect) { - super.init(frame: frame) + init( + id: StorageUsageScreenComponent.Category, + color: UIColor, + innerAngle: Range, + outerAngle: Range, + innerRadius: CGFloat, + outerRadius: CGFloat, + label: CalculatedLabel? + ) { + self.id = id + self.color = color + self.innerAngle = innerAngle + self.outerAngle = outerAngle + self.innerRadius = innerRadius + self.outerRadius = outerRadius + self.label = label + } + } + + private struct ItemAngleData { + var angleValue: CGFloat + var startAngle: CGFloat + var endAngle: CGFloat + } + + private struct CalculatedLayout { + var size: CGSize + var sections: [CalculatedSection] + + init(size: CGSize, sections: [CalculatedSection]) { + self.size = size + self.sections = sections + } + + init(interpolating start: CalculatedLayout, to end: CalculatedLayout, progress: CGFloat, size: CGSize) { + self.size = size + self.sections = [] - self.backgroundColor = nil - self.isOpaque = false - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.animator?.invalidate() - } - - func setItems(theme: PresentationTheme, data: ChartData, selectedKey: AnyHashable?, animated: Bool) { - let data = processChartData(data: data) - - if self.theme !== theme || self.data != data || self.selectedKey != selectedKey { - self.theme = theme - self.selectedKey = selectedKey + for i in 0 ..< end.sections.count { + let right = end.sections[i] - if animated, let previous = self.data { - var initialState = previous - if let currentAnimation = self.currentAnimation { - initialState = currentAnimation.current + if i < start.sections.count { + let left = start.sections[i] + let innerAngle: Range = left.innerAngle.lowerBound.interpolate(to: right.innerAngle.lowerBound, amount: progress) ..< left.innerAngle.upperBound.interpolate(to: right.innerAngle.upperBound, amount: progress) + let outerAngle: Range = left.outerAngle.lowerBound.interpolate(to: right.outerAngle.lowerBound, amount: progress) ..< left.outerAngle.upperBound.interpolate(to: right.outerAngle.upperBound, amount: progress) + + var label: CalculatedLabel? + if let leftLabel = left.label, let rightLabel = right.label { + label = leftLabel.interpolateTo(rightLabel, amount: progress) } - self.currentAnimation = (initialState, data, initialState, 0.0) - self.animator?.invalidate() - self.animator = DisplayLinkAnimator(duration: 0.4, from: 0.0, to: 1.0, update: { [weak self] progress in - guard let self else { - return - } - let progress = listViewAnimationCurveSystem(progress) - if let currentAnimationValue = self.currentAnimation { - self.currentAnimation = (currentAnimationValue.start, currentAnimationValue.end, interpolateChartData(start: currentAnimationValue.start, end: currentAnimationValue.end, progress: progress), progress) - self.setNeedsDisplay() - } - }, completion: { [weak self] in - guard let self else { - return - } - self.currentAnimation = nil - self.setNeedsDisplay() - }) + + self.sections.append(CalculatedSection( + id: right.id, + color: left.color.interpolateTo(right.color, fraction: progress) ?? right.color, + innerAngle: innerAngle, + outerAngle: outerAngle, + innerRadius: left.innerRadius.interpolate(to: right.innerRadius, amount: progress), + outerRadius: left.outerRadius.interpolate(to: right.outerRadius, amount: progress), + label: label + )) + } else { + self.sections.append(right) } - - self.data = data - - self.setNeedsDisplay() } } - override func draw(_ rect: CGRect) { - guard let context = UIGraphicsGetCurrentContext() else { - return - } - guard let _ = self.theme, let data = self.currentAnimation?.current ?? self.data else { - return - } - if data.items.isEmpty { + init(size: CGSize, items: [ChartData.Item], selectedKey: AnyHashable?) { + self.size = size + self.sections = [] + + if items.isEmpty { return } @@ -260,8 +383,8 @@ final class PieChartComponent: Component { let innerAngleSpacing: CGFloat = spacing / (innerDiameter * 0.5) var angles: [Double] = [] - for i in 0 ..< data.items.count { - let item = data.items[i] + for i in 0 ..< items.count { + let item = items[i] let angle = item.value * CGFloat.pi * 2.0 angles.append(angle) } @@ -269,22 +392,14 @@ final class PieChartComponent: Component { let diameter: CGFloat = 200.0 let reducedDiameter: CGFloat = 170.0 - let shapeLayerFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: diameter, height: diameter)) - - struct ItemAngleData { - var angleValue: CGFloat - var startAngle: CGFloat - var endAngle: CGFloat - } - var anglesData: [ItemAngleData] = [] var startAngle: CGFloat = 0.0 - for i in 0 ..< data.items.count { - let item = data.items[i] + for i in 0 ..< items.count { + let item = items[i] let itemOuterDiameter: CGFloat - if let selectedKey = self.selectedKey { + if let selectedKey { if selectedKey == AnyHashable(item.id) { itemOuterDiameter = diameter } else { @@ -303,16 +418,16 @@ final class PieChartComponent: Component { if item.mergeable { let previousItem: ChartData.Item if i == 0 { - previousItem = data.items[data.items.count - 1] + previousItem = items[items.count - 1] } else { - previousItem = data.items[i - 1] + previousItem = items[i - 1] } let nextItem: ChartData.Item - if i == data.items.count - 1 { - nextItem = data.items[0] + if i == items.count - 1 { + nextItem = items[0] } else { - nextItem = data.items[i + 1] + nextItem = items[i + 1] } if previousItem.mergeable { @@ -338,273 +453,645 @@ final class PieChartComponent: Component { var arcOuterEndAngle = startAngle + angleValue - angleSpacing * 0.5 * afterSpacingFraction arcOuterEndAngle = max(arcOuterEndAngle, arcOuterStartAngle) - let path = CGMutablePath() - - path.addArc(center: CGPoint(x: diameter * 0.5, y: diameter * 0.5), radius: innerDiameter * 0.5, startAngle: arcInnerEndAngle, endAngle: arcInnerStartAngle, clockwise: true) - path.addArc(center: CGPoint(x: diameter * 0.5, y: diameter * 0.5), radius: itemOuterDiameter * 0.5, startAngle: arcOuterStartAngle, endAngle: arcOuterEndAngle, clockwise: false) - - context.addPath(path) - context.setFillColor(item.color.cgColor) - context.fillPath() + self.sections.append(CalculatedSection( + id: item.id, + color: item.color, + innerAngle: arcInnerStartAngle ..< arcInnerEndAngle, + outerAngle: arcOuterStartAngle ..< arcOuterEndAngle, + innerRadius: innerDiameter * 0.5, + outerRadius: itemOuterDiameter * 0.5, + label: nil + )) startAngle += angleValue anglesData.append(ItemAngleData(angleValue: angleValue, startAngle: innerStartAngle, endAngle: innerEndAngle)) } - func updateItemLabel(id: AnyHashable, displayValue: Double, mergeFactor: CGFloat, angleData: ItemAngleData) { - let fractionValue: Double = floor(displayValue * 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)" - } + for i in 0 ..< items.count { + let item = items[i] - let label: ChartLabel - if let current = self.labels[id] { - label = current - } else { - label = ChartLabel() - self.labels[id] = label - } - let labelSize = label.update(text: "\(fractionString)%") - - var labelFrame: CGRect? - - let angleValue = angleData.angleValue - let innerStartAngle = angleData.startAngle - let innerEndAngle = angleData.endAngle - - if angleValue >= 0.001 { - for step in 0 ... 20 { - let stepFraction: CGFloat = CGFloat(step) / 20.0 - let centerOffset: CGFloat = 0.5 * (1.0 - stepFraction) + 0.65 * stepFraction - - let midAngle: CGFloat = (innerStartAngle + innerEndAngle) * 0.5 - let centerDistance: CGFloat = (innerDiameter * 0.5 + (diameter * 0.5 - innerDiameter * 0.5) * centerOffset) - - let relLabelCenter = CGPoint( - x: cos(midAngle) * centerDistance, - y: sin(midAngle) * centerDistance - ) - - let labelCenter = CGPoint( - x: shapeLayerFrame.midX + relLabelCenter.x, - y: shapeLayerFrame.midY + relLabelCenter.y - ) - - func lineCircleIntersection(_ center: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ r: CGFloat) -> CGFloat { - let dx: CGFloat = p2.x - p1.x - let dy: CGFloat = p2.y - p1.y - let dr: CGFloat = sqrt(dx * dx + dy * dy) - let D: CGFloat = p1.x * p2.y - p2.x * p1.y - - var minDistance: CGFloat = 10000.0 - - for i in 0 ..< 2 { - let signFactor: CGFloat = i == 0 ? 1.0 : (-1.0) - let dysign: CGFloat = dy < 0.0 ? -1.0 : 1.0 - let ix: CGFloat = (D * dy + signFactor * dysign * dx * sqrt(r * r * dr * dr - D * D)) / (dr * dr) - let iy: CGFloat = (-D * dx + signFactor * abs(dy) * sqrt(r * r * dr * dr - D * D)) / (dr * dr) - let distance: CGFloat = sqrt(pow(ix - center.x, 2.0) + pow(iy - center.y, 2.0)) - minDistance = min(minDistance, distance) - } - - return minDistance - } - - func lineLineIntersection(_ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ p4: CGPoint) -> CGFloat { - let x1 = p1.x - let y1 = p1.y - let x2 = p2.x - let y2 = p2.y - let x3 = p3.x - let y3 = p3.y - let x4 = p4.x - let y4 = p4.y - - let d: CGFloat = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) - if abs(d) <= 0.00001 { - return 10000.0 - } - - let px: CGFloat = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / d - let py: CGFloat = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / d - - let distance: CGFloat = sqrt(pow(px - p1.x, 2.0) + pow(py - p1.y, 2.0)) - return distance - } - - let intersectionOuterTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), diameter * 0.5) - let intersectionInnerTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), innerDiameter * 0.5) - let intersectionOuterBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), diameter * 0.5) - let intersectionInnerBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), innerDiameter * 0.5) - - let horizontalInset: CGFloat = 2.0 - let intersectionOuterLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), diameter * 0.5) - horizontalInset - let intersectionInnerLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), innerDiameter * 0.5) - horizontalInset - - let intersectionLine1TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerStartAngle), y: sin(innerStartAngle))) - let intersectionLine1BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerStartAngle), y: sin(innerStartAngle))) - let intersectionLine2TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerEndAngle), y: sin(innerEndAngle))) - let intersectionLine2BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerEndAngle), y: sin(innerEndAngle))) - - var distances: [CGFloat] = [ - intersectionOuterTopRight, - intersectionInnerTopRight, - intersectionOuterBottomRight, - intersectionInnerBottomRight, - intersectionOuterLeft, - intersectionInnerLeft - ] - - if angleValue < CGFloat.pi / 2.0 { - distances.append(contentsOf: [ - intersectionLine1TopRight, - intersectionLine1BottomRight, - intersectionLine2TopRight, - intersectionLine2BottomRight - ] as [CGFloat]) - } - - var minDistance: CGFloat = 1000.0 - for distance in distances { - minDistance = min(minDistance, max(distance, 1.0)) - } - - let diagonalAngle = atan2(labelSize.height, labelSize.width) - - let maxHalfWidth = cos(diagonalAngle) * minDistance - let maxHalfHeight = sin(diagonalAngle) * minDistance - - let maxSize = CGSize(width: maxHalfWidth * 2.0, height: maxHalfHeight * 2.0) - let finalSize = CGSize(width: min(labelSize.width, maxSize.width), height: min(labelSize.height, maxSize.height)) - - let currentFrame = CGRect(origin: CGPoint(x: labelCenter.x - finalSize.width * 0.5, y: labelCenter.y - finalSize.height * 0.5), size: finalSize) - - if finalSize.width >= labelSize.width { - labelFrame = currentFrame - break - } - if let labelFrame { - if labelFrame.width > finalSize.width { - continue - } - } - labelFrame = currentFrame + var isDimmedBySelection = false + if let selectedKey { + if selectedKey == AnyHashable(item.id) { + } else { + isDimmedBySelection = true } - } else { - let midAngle: CGFloat = (innerStartAngle + innerEndAngle) * 0.5 - let centerDistance: CGFloat = (innerDiameter * 0.5 + (diameter * 0.5 - innerDiameter * 0.5) * 0.5) + } + + self.updateLabel( + index: i, + displayValue: item.displayValue, + mergeFactor: item.mergeFactor, + innerAngle: self.sections[i].innerAngle, + outerAngle: self.sections[i].outerAngle, + innerRadius: self.sections[i].innerRadius, + outerRadius: self.sections[i].outerRadius, + isDimmedBySelection: isDimmedBySelection + ) + } + } + + private mutating func updateLabel( + index: Int, + displayValue: Double, + mergeFactor: CGFloat, + innerAngle: Range, + outerAngle: Range, + innerRadius: CGFloat, + outerRadius: CGFloat, + isDimmedBySelection: Bool + ) { + let normalAlpha: CGFloat = isDimmedBySelection ? 0.0 : 1.0 + + let fractionValue: Double = floor(displayValue * 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)" + } + + let labelString = NSAttributedString(string: "\(fractionString)%", font: chartLabelFont, textColor: .white) + let labelBounds = labelString.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: [.usesLineFragmentOrigin], context: nil) + let labelSize = CGSize(width: ceil(labelBounds.width), height: ceil(labelBounds.height)) + guard let labelImage = generateImage(labelSize, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + UIGraphicsPushContext(context) + labelString.draw(in: labelBounds) + UIGraphicsPopContext() + }) else { + return + } + + var resultLabel: CalculatedLabel? + + if innerAngle.upperBound - innerAngle.lowerBound >= 0.001 { + for step in 0 ... 10 { + let stepFraction: CGFloat = CGFloat(step) / 10.0 + let centerOffset: CGFloat = 0.5 * (1.0 - stepFraction) + 0.65 * stepFraction + + let midAngle: CGFloat = (innerAngle.lowerBound + innerAngle.upperBound) * 0.5 + let centerDistance: CGFloat = (innerRadius + (outerRadius - innerRadius) * centerOffset) let relLabelCenter = CGPoint( x: cos(midAngle) * centerDistance, y: sin(midAngle) * centerDistance ) - let labelCenter = CGPoint( - x: shapeLayerFrame.midX + relLabelCenter.x, - y: shapeLayerFrame.midY + relLabelCenter.y - ) - - let minSize = labelSize.aspectFitted(CGSize(width: 4.0, height: 4.0)) - labelFrame = CGRect(origin: CGPoint(x: labelCenter.x - minSize.width * 0.5, y: labelCenter.y - minSize.height * 0.5), size: minSize) - } - - let labelView = label - if let labelFrame { - var animateIn: Bool = false - if labelView.superview == nil { - animateIn = true - self.addSubview(labelView) - } - - var labelScale = labelFrame.width / labelSize.width - - var normalAlpha: CGFloat = labelScale < 0.4 ? 0.0 : 1.0 - normalAlpha *= max(0.0, mergeFactor) - - var relLabelCenter = CGPoint( - x: labelFrame.midX - shapeLayerFrame.midX, - y: labelFrame.midY - shapeLayerFrame.midY - ) - - let labelAlpha: CGFloat - if let selectedKey = self.selectedKey { - if selectedKey == id { - labelAlpha = normalAlpha - } else { - labelAlpha = 0.0 - - let reducedFactor: CGFloat = (reducedDiameter - innerDiameter) / (diameter - innerDiameter) - let reducedDiameterFactor: CGFloat = reducedDiameter / diameter - - labelScale *= reducedFactor - - relLabelCenter.x *= reducedDiameterFactor - relLabelCenter.y *= reducedDiameterFactor + func lineCircleIntersection(_ center: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ r: CGFloat) -> CGFloat { + let dx: CGFloat = p2.x - p1.x + let dy: CGFloat = p2.y - p1.y + let dr: CGFloat = sqrt(dx * dx + dy * dy) + let D: CGFloat = p1.x * p2.y - p2.x * p1.y + + var minDistance: CGFloat = 10000.0 + + for i in 0 ..< 2 { + let signFactor: CGFloat = i == 0 ? 1.0 : (-1.0) + let dysign: CGFloat = dy < 0.0 ? -1.0 : 1.0 + let ix: CGFloat = (D * dy + signFactor * dysign * dx * sqrt(r * r * dr * dr - D * D)) / (dr * dr) + let iy: CGFloat = (-D * dx + signFactor * abs(dy) * sqrt(r * r * dr * dr - D * D)) / (dr * dr) + let distance: CGFloat = sqrt(pow(ix - center.x, 2.0) + pow(iy - center.y, 2.0)) + minDistance = min(minDistance, distance) } - } else { - labelAlpha = normalAlpha + + return minDistance } - if labelView.alpha != labelAlpha { - let transition: Transition - if animateIn || "".isEmpty { - transition = .immediate - } else { - transition = Transition(animation: .curve(duration: 0.18, curve: .easeInOut)) + + func lineLineIntersection(_ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ p4: CGPoint) -> CGFloat { + let x1 = p1.x + let y1 = p1.y + let x2 = p2.x + let y2 = p2.y + let x3 = p3.x + let y3 = p3.y + let x4 = p4.x + let y4 = p4.y + + let d: CGFloat = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) + if abs(d) <= 0.00001 { + return 10000.0 } - transition.setAlpha(view: labelView, alpha: labelAlpha) + + let px: CGFloat = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / d + let py: CGFloat = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / d + + let distance: CGFloat = sqrt(pow(px - p1.x, 2.0) + pow(py - p1.y, 2.0)) + return distance } - let labelCenter = CGPoint( - x: shapeLayerFrame.midX + relLabelCenter.x, - y: shapeLayerFrame.midY + relLabelCenter.y + let intersectionOuterTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), outerRadius) + let intersectionInnerTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), innerRadius) + let intersectionOuterBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), outerRadius) + let intersectionInnerBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), innerRadius) + + let horizontalInset: CGFloat = 2.0 + let intersectionOuterLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), outerRadius) - horizontalInset + let intersectionInnerLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), innerRadius) - horizontalInset + + let intersectionLine1TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.lowerBound), y: sin(innerAngle.lowerBound))) + let intersectionLine1BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.lowerBound), y: sin(innerAngle.lowerBound))) + let intersectionLine2TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.upperBound), y: sin(innerAngle.upperBound))) + let intersectionLine2BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.upperBound), y: sin(innerAngle.upperBound))) + + var distances: [CGFloat] = [ + intersectionOuterTopRight, + intersectionInnerTopRight, + intersectionOuterBottomRight, + intersectionInnerBottomRight, + intersectionOuterLeft, + intersectionInnerLeft + ] + + if innerAngle.upperBound - innerAngle.lowerBound < CGFloat.pi / 2.0 { + distances.append(contentsOf: [ + intersectionLine1TopRight, + intersectionLine1BottomRight, + intersectionLine2TopRight, + intersectionLine2BottomRight + ] as [CGFloat]) + } + + var minDistance: CGFloat = 1000.0 + for distance in distances { + minDistance = min(minDistance, max(distance, 1.0)) + } + + let diagonalAngle = atan2(labelSize.height, labelSize.width) + + let maxHalfWidth = cos(diagonalAngle) * minDistance + let maxHalfHeight = sin(diagonalAngle) * minDistance + + let maxSize = CGSize(width: maxHalfWidth * 2.0, height: maxHalfHeight * 2.0) + let finalSize = CGSize(width: min(labelSize.width, maxSize.width), height: min(labelSize.height, maxSize.height)) + + let currentScale = finalSize.width / labelSize.width + + if currentScale >= 1.0 - 0.001 { + resultLabel = CalculatedLabel( + image: labelImage, + alpha: 1.0 * normalAlpha, + angle: midAngle, + radius: centerDistance, + scale: 1.0 + ) + break + } + if let resultLabel { + if resultLabel.scale > currentScale { + continue + } + } + resultLabel = CalculatedLabel( + image: labelImage, + alpha: (currentScale >= 0.4 ? 1.0 : 0.0) * normalAlpha, + angle: midAngle, + radius: centerDistance, + scale: currentScale ) - - labelView.center = labelCenter - labelView.transform = CGAffineTransformMakeScale(labelScale, labelScale) } - } - - var mergedItem: (displayValue: Double, angleData: ItemAngleData, mergeFactor: CGFloat)? - for i in 0 ..< data.items.count { - let item = data.items[i] - let angleData = anglesData[i] - updateItemLabel(id: item.id, displayValue: item.displayValue, mergeFactor: item.mergeFactor, angleData: angleData) - - if item.mergeable { - if var currentMergedItem = mergedItem { - currentMergedItem.displayValue += item.displayValue - currentMergedItem.angleData.startAngle = min(currentMergedItem.angleData.startAngle, angleData.startAngle) - currentMergedItem.angleData.endAngle = max(currentMergedItem.angleData.endAngle, angleData.endAngle) - mergedItem = currentMergedItem - } else { - let invertedMergeFactor: CGFloat = 1.0 - max(0.0, item.mergeFactor) - mergedItem = (item.displayValue, angleData, invertedMergeFactor) - } - } - } - - if let mergedItem { - updateItemLabel(id: "merged", displayValue: mergedItem.displayValue, mergeFactor: mergedItem.mergeFactor, angleData: mergedItem.angleData) } else { - if let label = self.labels["merged"] { - self.labels.removeValue(forKey: "merged") - label.removeFromSuperview() + let midAngle: CGFloat = (innerAngle.lowerBound + innerAngle.upperBound) * 0.5 + let centerDistance: CGFloat = (innerRadius + (outerRadius - innerRadius) * 0.5) + + resultLabel = CalculatedLabel( + image: labelImage, + alpha: 0.0, + angle: midAngle, + radius: centerDistance, + scale: 0.001 + ) + } + + if let resultLabel { + self.sections[index].label = resultLabel + } + } + } + + private struct Particle { + var trackIndex: Int + var position: CGPoint + var scale: CGFloat + var alpha: CGFloat + var direction: CGPoint + var velocity: CGFloat + + init( + trackIndex: Int, + position: CGPoint, + scale: CGFloat, + alpha: CGFloat, + direction: CGPoint, + velocity: CGFloat + ) { + self.trackIndex = trackIndex + self.position = position + self.scale = scale + self.alpha = alpha + self.direction = direction + self.velocity = velocity + } + + mutating func update(deltaTime: CGFloat) { + var position = self.position + position.x += self.direction.x * self.velocity * deltaTime + position.y += self.direction.y * self.velocity * deltaTime + self.position = position + } + } + + private final class ParticleSet { + private(set) var particles: [Particle] = [] + + init() { + self.generateParticles(preAdvance: true) + } + + private func generateParticles(preAdvance: Bool) { + let maxDirections = 24 + + if self.particles.count < maxDirections { + var allTrackIndices: [Int] = Array(repeating: 0, count: maxDirections) + for i in 0 ..< maxDirections { + allTrackIndices[i] = i } + var takenIndexCount = 0 + for particle in self.particles { + allTrackIndices[particle.trackIndex] = -1 + takenIndexCount += 1 + } + var availableTrackIndices: [Int] = [] + availableTrackIndices.reserveCapacity(maxDirections - takenIndexCount) + for index in allTrackIndices { + if index != -1 { + availableTrackIndices.append(index) + } + } + + if !availableTrackIndices.isEmpty { + availableTrackIndices.shuffle() + + for takeIndex in availableTrackIndices { + let directionIndex = takeIndex + let angle = (CGFloat(directionIndex % maxDirections) / CGFloat(maxDirections)) * CGFloat.pi * 2.0 + + let direction = CGPoint(x: cos(angle), y: sin(angle)) + let velocity = CGFloat.random(in: 20.0 ..< 40.0) + let alpha = CGFloat.random(in: 0.1 ..< 0.4) + let scale = CGFloat.random(in: 0.5 ... 1.0) * 0.22 + + var position = CGPoint(x: 100.0, y: 100.0) + var initialOffset: CGFloat = 0.4 + if preAdvance { + initialOffset = CGFloat.random(in: initialOffset ... 1.0) + } + position.x += direction.x * initialOffset * 105.0 + position.y += direction.y * initialOffset * 105.0 + + let particle = Particle( + trackIndex: directionIndex, + position: position, + scale: scale, + alpha: alpha, + direction: direction, + velocity: velocity + ) + self.particles.append(particle) + } + } + } + } + + func update(deltaTime: CGFloat) { + let size = CGSize(width: 200.0, height: 200.0) + let radius2 = pow(size.width * 0.5 + 10.0, 2.0) + for i in (0 ..< self.particles.count).reversed() { + self.particles[i].update(deltaTime: deltaTime) + let position = self.particles[i].position + + if pow(position.x - size.width * 0.5, 2.0) + pow(position.y - size.height * 0.5, 2.0) > radius2 { + self.particles.remove(at: i) + } + } + + self.generateParticles(preAdvance: false) + } + } + + private final class SectionLayer: SimpleLayer { + private let maskLayer: SimpleShapeLayer + private let gradientLayer: SimpleGradientLayer + private let labelLayer: SimpleLayer + + private var currentLabelImage: UIImage? + + private var particleImage: UIImage? + private var particleLayers: [SimpleLayer] = [] + + init(category: StorageUsageScreenComponent.Category) { + self.maskLayer = SimpleShapeLayer() + self.maskLayer.fillColor = UIColor.white.cgColor + + self.gradientLayer = SimpleGradientLayer() + self.gradientLayer.type = .radial + self.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + self.gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) + + self.labelLayer = SimpleLayer() + + super.init() + + self.mask = self.maskLayer + self.addSublayer(self.gradientLayer) + self.addSublayer(self.labelLayer) + + switch category { + case .photos: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticlePhotos")?.precomposed() + case .videos: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleVideos")?.precomposed() + case .files: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleDocuments")?.precomposed() + case .music: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleMusic")?.precomposed() + case .other: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleOther")?.precomposed() + case .stickers: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleStickers")?.precomposed() + case .avatars: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleAvatars")?.precomposed() + case .misc: + self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleOther")?.precomposed() + } + } + + override init(layer: Any) { + self.maskLayer = SimpleShapeLayer() + self.gradientLayer = SimpleGradientLayer() + self.labelLayer = SimpleLayer() + + super.init(layer: layer) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func isPointOnGraph(point: CGPoint) -> Bool { + if let path = self.maskLayer.path { + return path.contains(point) + } + return false + } + + func tooltipLocation() -> CGPoint { + return self.labelLayer.position + } + + func update(size: CGSize, section: CalculatedSection) { + self.maskLayer.frame = CGRect(origin: CGPoint(), size: size) + self.gradientLayer.frame = CGRect(origin: CGPoint(), size: size) + + let normalColor = section.color.cgColor + let darkerColor = section.color.withMultipliedBrightnessBy(0.96).cgColor + let colors: [CGColor] = [ + darkerColor, + normalColor, + normalColor, + normalColor, + darkerColor + ] + self.gradientLayer.colors = colors + + let locations: [CGFloat] = [ + 0.0, + 0.3, + 0.5, + 0.7, + 1.0 + ] + self.gradientLayer.locations = locations.map { location in + let location = location * 0.5 + 0.5 + return location as NSNumber + } + + let path = CGMutablePath() + path.addArc(center: CGPoint(x: size.width * 0.5, y: size.height * 0.5), radius: section.innerRadius, startAngle: section.innerAngle.upperBound, endAngle: section.innerAngle.lowerBound, clockwise: true) + path.addArc(center: CGPoint(x: size.width * 0.5, y: size.height * 0.5), radius: section.outerRadius, startAngle: section.outerAngle.lowerBound, endAngle: section.outerAngle.upperBound, clockwise: false) + self.maskLayer.path = path + + if let label = section.label { + if self.currentLabelImage !== label.image { + self.currentLabelImage = label.image + self.labelLayer.contents = label.image.cgImage + } + + let position = CGPoint(x: size.width * 0.5 + cos(label.angle) * label.radius, y: size.height * 0.5 + sin(label.angle) * label.radius) + let labelSize = CGSize(width: label.image.size.width * label.scale, height: label.image.size.height * label.scale) + let labelFrame = CGRect(origin: CGPoint(x: position.x - labelSize.width * 0.5, y: position.y - labelSize.height * 0.5), size: labelSize) + self.labelLayer.frame = labelFrame + self.labelLayer.opacity = Float(label.alpha) + } else { + self.currentLabelImage = nil + self.labelLayer.contents = nil + } + } + + func updateParticles(particleSet: ParticleSet) { + guard let particleImage = self.particleImage else { + return + } + for i in 0 ..< particleSet.particles.count { + let particle = particleSet.particles[i] + + let particleLayer: SimpleLayer + if i < self.particleLayers.count { + particleLayer = self.particleLayers[i] + particleLayer.isHidden = false + } else { + particleLayer = SimpleLayer() + particleLayer.contents = particleImage.cgImage + particleLayer.bounds = CGRect(origin: CGPoint(), size: particleImage.size) + self.particleLayers.append(particleLayer) + self.insertSublayer(particleLayer, above: self.gradientLayer) + } + + particleLayer.position = particle.position + particleLayer.transform = CATransform3DMakeScale(particle.scale, particle.scale, 1.0) + particleLayer.opacity = Float(particle.alpha) + } + if particleSet.particles.count < self.particleLayers.count { + for i in particleSet.particles.count ..< self.particleLayers.count { + self.particleLayers[i].isHidden = true + } + } + } + } + + private final class ChartDataView: UIView { + private(set) var theme: PresentationTheme? + private(set) var data: ChartData? + private(set) var selectedKey: AnyHashable? + + private var currentAnimation: (start: CalculatedLayout, startTime: Double, duration: Double)? + private var currentLayout: CalculatedLayout? + private var animator: DisplayLinkAnimator? + + private var displayLink: SharedDisplayLinkDriver.Link? + + private var sectionLayers: [AnyHashable: SectionLayer] = [:] + private let particleSet: ParticleSet + private var labels: [AnyHashable: ChartLabel] = [:] + + override init(frame: CGRect) { + self.particleSet = ParticleSet() + + super.init(frame: frame) + + self.backgroundColor = nil + self.isOpaque = false + + var previousTimestamp: Double? + self.displayLink = SharedDisplayLinkDriver.shared.add(needsHighestFramerate: true, { [weak self] in + let timestamp = CACurrentMediaTime() + var delta: Double + if let previousTimestamp { + delta = timestamp - previousTimestamp + } else { + delta = 1.0 / 60.0 + } + previousTimestamp = timestamp + + if delta < 0.0 { + delta = 1.0 / 60.0 + } else if delta > 0.5 { + delta = 1.0 / 60.0 + } + + self?.update(deltaTime: CGFloat(delta)) + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.animator?.invalidate() + } + + func sectionKey(at point: CGPoint) -> AnyHashable? { + for (id, itemLayer) in self.sectionLayers { + if itemLayer.isPointOnGraph(point: point) { + return id + } + } + return nil + } + + func tooltipLocation(forKey key: StorageUsageScreenComponent.Category) -> CGPoint? { + for (id, itemLayer) in self.sectionLayers { + if id == AnyHashable(key) { + return itemLayer.tooltipLocation() + } + } + return nil + } + + func setItems(theme: PresentationTheme, data: ChartData, selectedKey: AnyHashable?, animated: Bool) { + let data = processChartData(data: data) + + if self.theme !== theme || self.data != data || self.selectedKey != selectedKey { + self.theme = theme + self.selectedKey = selectedKey + + if animated, let previous = self.currentLayout { + var initialState = previous + if let currentAnimation = self.currentAnimation { + let currentProgress: Double = max(0.0, min(1.0, (CACurrentMediaTime() - currentAnimation.startTime) / currentAnimation.duration)) + let mappedProgress = listViewAnimationCurveSystem(CGFloat(currentProgress)) + initialState = CalculatedLayout(interpolating: currentAnimation.start, to: previous, progress: mappedProgress, size: previous.size) + } + let targetLayout = CalculatedLayout( + size: CGSize(width: 200.0, height: 200.0), + items: data.items, + selectedKey: self.selectedKey + ) + self.currentLayout = targetLayout + self.currentAnimation = (initialState, CACurrentMediaTime(), 0.4) + } else { + self.currentLayout = CalculatedLayout( + size: CGSize(width: 200.0, height: 200.0), + items: data.items, + selectedKey: self.selectedKey + ) + } + + self.data = data + + self.update(deltaTime: 0.0) + } + } + + private func update(deltaTime: CGFloat) { + self.particleSet.update(deltaTime: deltaTime) + + var validIds: [AnyHashable] = [] + if let currentLayout = self.currentLayout { + var effectiveLayout = currentLayout + if let currentAnimation = self.currentAnimation { + let currentProgress: Double = max(0.0, min(1.0, (CACurrentMediaTime() - currentAnimation.startTime) / currentAnimation.duration)) + let mappedProgress = listViewAnimationCurveSystem(CGFloat(currentProgress)) + + effectiveLayout = CalculatedLayout(interpolating: currentAnimation.start, to: currentLayout, progress: mappedProgress, size: currentLayout.size) + + if currentProgress >= 1.0 - CGFloat.ulpOfOne { + self.currentAnimation = nil + } + } + + for section in effectiveLayout.sections { + validIds.append(section.id) + + let sectionLayer: SectionLayer + if let current = self.sectionLayers[section.id] { + sectionLayer = current + } else { + sectionLayer = SectionLayer(category: section.id) + self.sectionLayers[section.id] = sectionLayer + self.layer.addSublayer(sectionLayer) + } + + sectionLayer.frame = CGRect(origin: CGPoint(), size: CGSize(width: 200.0, height: 200.0)) + sectionLayer.update(size: sectionLayer.bounds.size, section: section) + sectionLayer.updateParticles(particleSet: self.particleSet) + } + } + + var removeIds: [AnyHashable] = [] + for (id, sectionLayer) in self.sectionLayers { + if !validIds.contains(id) { + removeIds.append(id) + sectionLayer.removeFromSuperlayer() + } + } + for id in removeIds { + self.sectionLayers.removeValue(forKey: id) } } } class View: UIView { private let dataView: ChartDataView - private var labels: [StorageUsageScreenComponent.Category: ComponentView] = [:] - var selectedKey: StorageUsageScreenComponent.Category? + private var tooltip: (key: AnyHashable, value: ComponentView)? + var selectedKey: AnyHashable? + + private var component: PieChartComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { @@ -623,32 +1110,105 @@ final class PieChartComponent: Component { @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { - let point = recognizer.location(in: self) - let _ = point - /*for (key, layer) in self.shapeLayers { - if layer.frame.contains(point), let path = layer.path { - if path.contains(self.layer.convert(point, to: layer)) { - if self.selectedKey == key { - self.selectedKey = nil - } else { - self.selectedKey = key - } - - self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) - - break - } + let point = recognizer.location(in: self.dataView) + if let key = self.dataView.sectionKey(at: point) { + if self.selectedKey == key { + self.selectedKey = nil + } else { + self.selectedKey = key } - }*/ + } else { + self.selectedKey = nil + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) } } func update(component: PieChartComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let dataUpdated = self.component?.chartData != component.chartData + self.state = state + self.component = component + + if dataUpdated { + self.selectedKey = nil + } transition.setFrame(view: self.dataView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - 200.0) / 2.0), y: 0.0), size: CGSize(width: 200.0, height: 200.0))) self.dataView.setItems(theme: component.theme, data: component.chartData, selectedKey: self.selectedKey, animated: !transition.animation.isImmediate) + if let selectedKey = self.selectedKey?.base as? StorageUsageScreenComponent.Category, let item = component.chartData.items.first(where: { $0.id == selectedKey }) { + let tooltip: ComponentView + var tooltipTransition = transition + var animateIn = false + if let current = self.tooltip, current.key == AnyHashable(selectedKey) { + tooltip = current.value + } else if let current = self.tooltip { + if let tooltipView = current.value.view { + transition.setAlpha(view: tooltipView, alpha: 0.0, completion: { [weak tooltipView] _ in + tooltipView?.removeFromSuperview() + }) + } + tooltipTransition = .immediate + animateIn = true + tooltip = ComponentView() + self.tooltip = (selectedKey, tooltip) + } else { + tooltipTransition = .immediate + animateIn = true + tooltip = ComponentView() + self.tooltip = (selectedKey, tooltip) + } + + let fractionValue: Double = floor(item.displayValue * 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)%" + } + + let tooltipSize = tooltip.update( + transition: tooltipTransition, + component: AnyComponent(ChartSelectionTooltip( + theme: component.theme, + fractionText: fractionString, + title: selectedKey.title(strings: component.strings), + sizeText: dataSizeString(Int(item.displaySize), formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")) + )), + environment: {}, + containerSize: availableSize + ) + + if let relativeTooltipLocation = self.dataView.tooltipLocation(forKey: selectedKey) { + let tooltipLocation = relativeTooltipLocation.offsetBy(dx: self.dataView.frame.minX, dy: self.dataView.frame.minY) + let tooltipFrame = CGRect(origin: CGPoint(x: floor(tooltipLocation.x - tooltipSize.width / 2.0), y: tooltipLocation.y - 16.0 - tooltipSize.height), size: tooltipSize) + + if let tooltipView = tooltip.view { + if tooltipView.superview == nil { + self.addSubview(tooltipView) + } + tooltipTransition.setFrame(view: tooltipView, frame: tooltipFrame) + if animateIn { + transition.animateAlpha(view: tooltipView, from: 0.0, to: 1.0) + transition.animateScale(view: tooltipView, from: 0.8, to: 1.0) + } + } + } + } else { + if let tooltip = self.tooltip { + self.tooltip = nil + if let tooltipView = tooltip.value.view { + transition.setAlpha(view: tooltipView, alpha: 0.0, completion: { [weak tooltipView] _ in + tooltipView?.removeFromSuperview() + }) + transition.setScale(view: tooltipView, scale: 0.8) + } + } + } + return CGSize(width: availableSize.width, height: 200.0) } } diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoriesComponent.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoriesComponent.swift index dd22e30378..9588072219 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoriesComponent.swift +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoriesComponent.swift @@ -40,6 +40,7 @@ final class StorageCategoriesComponent: Component { let strings: PresentationStrings let categories: [CategoryData] let isOtherExpanded: Bool + let displayAction: Bool let toggleCategorySelection: (StorageUsageScreenComponent.Category) -> Void let toggleOtherExpanded: () -> Void let clearAction: () -> Void @@ -49,6 +50,7 @@ final class StorageCategoriesComponent: Component { strings: PresentationStrings, categories: [CategoryData], isOtherExpanded: Bool, + displayAction: Bool, toggleCategorySelection: @escaping (StorageUsageScreenComponent.Category) -> Void, toggleOtherExpanded: @escaping () -> Void, clearAction: @escaping () -> Void @@ -57,6 +59,7 @@ final class StorageCategoriesComponent: Component { self.strings = strings self.categories = categories self.isOtherExpanded = isOtherExpanded + self.displayAction = displayAction self.toggleCategorySelection = toggleCategorySelection self.toggleOtherExpanded = toggleOtherExpanded self.clearAction = clearAction @@ -75,6 +78,9 @@ final class StorageCategoriesComponent: Component { if lhs.isOtherExpanded != rhs.isOtherExpanded { return false } + if lhs.displayAction != rhs.displayAction { + return false + } return true } @@ -195,59 +201,65 @@ final class StorageCategoriesComponent: Component { self.itemViews.removeValue(forKey: key) } - let clearTitle: String - let label: String? - if totalSelectedSize == 0 { - clearTitle = component.strings.StorageManagement_ClearSelected - label = nil - } else if hasDeselected { - clearTitle = component.strings.StorageManagement_ClearSelected - label = dataSizeString(totalSelectedSize, formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")) - } else { - clearTitle = component.strings.StorageManagement_ClearAll - label = dataSizeString(totalSelectedSize, formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")) - } - - contentHeight += 8.0 - let buttonSize = self.button.update( - transition: transition, - component: AnyComponent(SolidRoundedButtonComponent( - title: clearTitle, - label: label, - theme: SolidRoundedButtonComponent.Theme( - backgroundColor: component.theme.list.itemCheckColors.fillColor, - backgroundColors: [], - foregroundColor: component.theme.list.itemCheckColors.foregroundColor - ), - font: .bold, - fontSize: 17.0, - height: 50.0, - cornerRadius: 10.0, - gloss: false, - isEnabled: totalSelectedSize != 0, - animationName: nil, - iconPosition: .right, - iconSpacing: 4.0, - action: { [weak self] in - guard let self, let component = self.component else { - return - } - component.clearAction() - } - )), - environment: {}, - containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 50.0) - ) - let buttonFrame = CGRect(origin: CGPoint(x: 16.0, y: contentHeight), size: buttonSize) - if let buttonView = button.view { - if buttonView.superview == nil { - self.addSubview(buttonView) + if component.displayAction { + let clearTitle: String + let label: String? + if totalSelectedSize == 0 { + clearTitle = component.strings.StorageManagement_ClearSelected + label = nil + } else if hasDeselected { + clearTitle = component.strings.StorageManagement_ClearSelected + label = dataSizeString(totalSelectedSize, formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")) + } else { + clearTitle = component.strings.StorageManagement_ClearAll + label = dataSizeString(totalSelectedSize, formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: ".")) + } + + contentHeight += 8.0 + let buttonSize = self.button.update( + transition: transition, + component: AnyComponent(SolidRoundedButtonComponent( + title: clearTitle, + label: label, + theme: SolidRoundedButtonComponent.Theme( + backgroundColor: component.theme.list.itemCheckColors.fillColor, + backgroundColors: [], + foregroundColor: component.theme.list.itemCheckColors.foregroundColor + ), + font: .bold, + fontSize: 17.0, + height: 50.0, + cornerRadius: 10.0, + gloss: false, + isEnabled: totalSelectedSize != 0, + animationName: nil, + iconPosition: .right, + iconSpacing: 4.0, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.clearAction() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 50.0) + ) + let buttonFrame = CGRect(origin: CGPoint(x: 16.0, y: contentHeight), size: buttonSize) + if let buttonView = self.button.view { + if buttonView.superview == nil { + self.addSubview(buttonView) + } + transition.setFrame(view: buttonView, frame: buttonFrame) + } + contentHeight += buttonSize.height + + contentHeight += 16.0 + } else { + if let buttonView = self.button.view { + buttonView.removeFromSuperview() } - transition.setFrame(view: buttonView, frame: buttonFrame) } - contentHeight += buttonSize.height - - contentHeight += 16.0 self.backgroundColor = component.theme.list.itemBlocksBackgroundColor diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoryItemCompoment.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoryItemCompoment.swift index b08dedad44..41605decc5 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoryItemCompoment.swift +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageCategoryItemCompoment.swift @@ -206,14 +206,18 @@ final class StorageCategoryItemComponent: Component { iconView.removeFromSuperview() } - let fractionValue: Double = floor(component.category.sizeFraction * 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))" + if component.category.sizeFraction != 0.0 { + let fractionValue: Double = floor(component.category.sizeFraction * 100.0 * 10.0) / 10.0 + if fractionValue < 0.1 { + fractionString = "<0.1%" + } else if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 { + fractionString = "\(Int(fractionValue))%" + } else { + fractionString = "\(fractionValue)%" + } } else { - fractionString = "\(fractionValue)" + fractionString = "" } let labelSize = self.label.update( @@ -225,8 +229,8 @@ final class StorageCategoryItemComponent: Component { availableWidth = max(1.0, availableWidth - labelSize.width - 1.0) let titleValueSize = self.titleValue.update( - transition: transition, - component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: "\(fractionString)%", font: Font.regular(17.0), textColor: component.theme.list.itemSecondaryTextColor)))), + transition: .immediate, + component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: fractionString, font: Font.regular(17.0), textColor: component.theme.list.itemSecondaryTextColor)))), environment: {}, containerSize: CGSize(width: availableWidth, height: 100.0) ) @@ -266,7 +270,12 @@ final class StorageCategoryItemComponent: Component { titleValueView.isUserInteractionEnabled = false self.addSubview(titleValueView) } - transition.setFrame(view: titleValueView, frame: titleValueFrame) + + if titleValueView.bounds.size != titleValueFrame.size { + titleValueView.frame = titleValueFrame + } else { + transition.setFrame(view: titleValueView, frame: titleValueFrame) + } } if let labelView = self.label.view { if labelView.superview == nil { diff --git a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift index c34773459a..af8ffb7e3d 100644 --- a/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift +++ b/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift @@ -22,6 +22,7 @@ import AnimatedStickerNode import TelegramAnimatedStickerNode import TelegramStringFormatting import GalleryData +import AnimatedTextComponent #if DEBUG import os.signpost @@ -306,24 +307,386 @@ final class StorageUsageScreenComponent: Component { } } + 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) + } + } + } + } 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) + } + } + } + } + + 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) + } + } + } + } 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) + } + } + } + } + + 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 currentStats: AllStorageUsageStats? - private var existingCategories: Set = Set() + private var aggregatedData: AggregatedData? private var otherCategories: Set = Set() - private var currentMessages: [MessageId: Message] = [:] private var cacheSettings: CacheStorageSettings? private var cacheSettingsExceptionCount: [CacheStorageSettings.PeerStorageCategory: Int32]? - private var peerItems: StoragePeerListPanelComponent.Items? - private var imageItems: StorageMediaGridPanelComponent.Items? - private var fileItems: StorageFileListPanelComponent.Items? - private var musicItems: StorageFileListPanelComponent.Items? - - private var selectionState: SelectionState? - private var currentSelectedPanelId: AnyHashable? private var clearingDisplayTimestamp: Double? @@ -344,7 +707,6 @@ final class StorageUsageScreenComponent: Component { } } - private var selectedCategories: Set = Set() private var isOtherCategoryExpanded: Bool = false private let navigationBackgroundView: BlurredBackgroundView @@ -539,11 +901,13 @@ final class StorageUsageScreenComponent: Component { } } + let isSelectingPeers = self.aggregatedData?.isSelectingPeers ?? false + if let navigationEditButtonView = self.navigationEditButton.view { - animatedTransition.setAlpha(view: navigationEditButtonView, alpha: (self.selectionState == nil ? 1.0 : 0.0) * buttonsMasterAlpha * navigationBackgroundAlpha) + animatedTransition.setAlpha(view: navigationEditButtonView, alpha: (isSelectingPeers ? 0.0 : 1.0) * buttonsMasterAlpha * navigationBackgroundAlpha) } if let navigationDoneButtonView = self.navigationDoneButton.view { - animatedTransition.setAlpha(view: navigationDoneButtonView, alpha: (self.selectionState == nil ? 0.0 : 1.0) * buttonsMasterAlpha * navigationBackgroundAlpha) + animatedTransition.setAlpha(view: navigationDoneButtonView, alpha: (isSelectingPeers ? 1.0 : 0.0) * buttonsMasterAlpha * navigationBackgroundAlpha) } let expansionDistance: CGFloat = 32.0 @@ -576,7 +940,7 @@ final class StorageUsageScreenComponent: Component { let environment = environment[ViewControllerComponentContainer.Environment.self].value - if self.currentStats == nil { + if self.aggregatedData == nil { let loadingView: UIActivityIndicatorView if let current = self.loadingView { loadingView = current @@ -678,7 +1042,7 @@ final class StorageUsageScreenComponent: Component { } self.cacheSettings = cacheSettings self.cacheSettingsExceptionCount = cacheSettingsExceptionCount - if self.currentStats != nil { + if self.aggregatedData != nil { self.state?.updated(transition: .immediate) } }) @@ -703,8 +1067,8 @@ final class StorageUsageScreenComponent: Component { } else { alphaTransition = .immediate } - alphaTransition.setAlpha(view: self.scrollView, alpha: self.currentStats != nil ? 1.0 : 0.0) - alphaTransition.setAlpha(view: self.headerOffsetContainer, alpha: self.currentStats != nil ? 1.0 : 0.0) + 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.snapshotView(afterScreenUpdates: false) { snapshotView.frame = self.bounds @@ -715,8 +1079,8 @@ final class StorageUsageScreenComponent: Component { } } } else { - transition.setAlpha(view: self.scrollView, alpha: self.currentStats != nil ? 1.0 : 0.0) - transition.setAlpha(view: self.headerOffsetContainer, alpha: self.currentStats != nil ? 1.0 : 0.0) + 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 @@ -743,22 +1107,9 @@ final class StorageUsageScreenComponent: Component { guard let self else { return } - if self.selectionState == nil { - #if DEBUG - let signpostState = SignpostContext.shared?.begin(name: "edit") - #endif - - self.selectionState = SelectionState( - selectedPeers: Set(), - selectedMessages: Set() - ) + if let aggregatedData = self.aggregatedData, !aggregatedData.isSelectingPeers { + aggregatedData.isSelectingPeers = true 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))), @@ -777,10 +1128,11 @@ final class StorageUsageScreenComponent: Component { 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 else { + guard let self, let aggregatedData = self.aggregatedData else { return } - self.selectionState = nil + 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))), @@ -804,7 +1156,17 @@ final class StorageUsageScreenComponent: Component { let sideInset: CGFloat = 16.0 + environment.safeInsets.left var bottomInset: CGFloat = environment.safeInsets.bottom - if let selectionState = self.selectionState, !selectionState.isEmpty { + + 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 { @@ -815,62 +1177,19 @@ final class StorageUsageScreenComponent: Component { self.selectionPanel = selectionPanel } - var selectedSize: Int64 = 0 - if let currentStats = self.currentStats { - let contextStats: StorageUsageStats - if let peer = component.peer { - contextStats = currentStats.peers[peer.id]?.stats ?? StorageUsageStats(categories: [:]) - } else { - contextStats = currentStats.totalStats - } - - for peerId in selectionState.selectedPeers { - if let stats = currentStats.peers[peerId] { - let peerSize = stats.stats.categories.values.reduce(0, { - $0 + $1.size - }) - selectedSize += peerSize - - for (messageId, _) in self.currentMessages { - if messageId.peerId == peerId { - if !selectionState.selectedMessages.contains(messageId) { - inner: for (_, category) in contextStats.categories { - if let messageSize = category.messages[messageId] { - selectedSize -= messageSize - break inner - } - } - } - } - } - } - } - - for messageId in selectionState.selectedMessages { - for (_, category) in contextStats.categories { - if let messageSize = category.messages[messageId] { - if !selectionState.selectedPeers.contains(messageId.peerId) { - selectedSize += messageSize - } - break - } - } - } - } - let selectionPanelSize = selectionPanel.update( transition: selectionPanelTransition, component: AnyComponent(StorageUsageScreenSelectionPanelComponent( theme: environment.theme, - title: environment.strings.StorageManagement_ClearSelected, - label: selectedSize == 0 ? nil : dataSizeString(Int(selectedSize), formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")), - isEnabled: selectedSize != 0, + 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, let selectionState = self.selectionState else { + guard let self else { return } - self.requestClear(categories: Set(), peers: selectionState.selectedPeers, messages: selectionState.selectedMessages) + self.requestClear(fromCategories: false) } )), environment: {}, @@ -909,32 +1228,16 @@ final class StorageUsageScreenComponent: Component { .misc ] - if let _ = self.currentStats { - if let animationHint { - switch animationHint.value { - case .firstStatsUpdate, .clearedItems: - self.selectedCategories = self.existingCategories - } - } - - self.selectedCategories.formIntersection(self.existingCategories) - } else { - self.selectedCategories.removeAll() - } - var listCategories: [StorageCategoriesComponent.CategoryData] = [] var totalSize: Int64 = 0 - if let currentStats = self.currentStats { - let contextStats: StorageUsageStats - if let peer = component.peer { - contextStats = currentStats.peers[peer.id]?.stats ?? StorageUsageStats(categories: [:]) - } else { - contextStats = currentStats.totalStats - } - - for (_, value) in contextStats.categories { + var totalSelectedCategorySize: Int64 = 0 + if let aggregatedData = self.aggregatedData { + for (key, value) in aggregatedData.contextStats.categories { totalSize += value.size + if aggregatedData.selectedCategories.contains(Category(key)) { + totalSelectedCategorySize += value.size + } } for category in allCategories { @@ -959,49 +1262,55 @@ final class StorageUsageScreenComponent: Component { } var categorySize: Int64 = 0 - if let categoryData = contextStats.categories[mappedCategory] { + if let categoryData = aggregatedData.contextStats.categories[mappedCategory] { categorySize = categoryData.size } let categoryFraction: Double - if categorySize == 0 || totalSize == 0 { + if !aggregatedData.selectedCategories.contains(category) { + categoryFraction = 0.0 + } else if categorySize == 0 || totalSelectedCategorySize == 0 { categoryFraction = 0.0 } else { - categoryFraction = Double(categorySize) / Double(totalSize) + 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: self.selectedCategories.contains(category), subcategories: [])) + 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.sizeFraction > $1.sizeFraction }) + listCategories.sort(by: { $0.size > $1.size }) var otherListCategories: [StorageCategoriesComponent.CategoryData] = [] if listCategories.count > 5 { for i in (4 ..< listCategories.count).reversed() { - if listCategories[i].sizeFraction < 0.04 { - otherListCategories.insert(listCategories[i], at: 0) - listCategories.remove(at: i) - } + 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, mergeable: false, mergeFactor: 1.0)) + } + + var totalOtherSize: Int64 = 0 + for listCategory in otherListCategories { + totalOtherSize += listCategory.size + } + 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 categoryFraction: Double = otherListCategories.reduce(0.0, { $0 + $1.sizeFraction }) let isSelected = otherListCategories.allSatisfy { item in - return self.selectedCategories.contains(item.key) + return self.aggregatedData?.selectedCategories.contains(item.key) ?? false } let listColor: UIColor @@ -1015,36 +1324,38 @@ final class StorageUsageScreenComponent: Component { key: Category.other, color: listColor, title: Category.other.title(strings: environment.strings), size: totalOtherSize, sizeFraction: categoryFraction, isSelected: isSelected, subcategories: otherListCategories)) } - var chartItems: [PieChartComponent.ChartData.Item] = [] - for listCategory in listCategories { - var categoryChartFraction: CGFloat = listCategory.sizeFraction - if !self.selectedCategories.isEmpty && !self.selectedCategories.contains(listCategory.key) { - categoryChartFraction = 0.0 - } - chartItems.append(PieChartComponent.ChartData.Item(id: listCategory.key, displayValue: listCategory.sizeFraction, value: categoryChartFraction, color: listCategory.color, mergeable: false, mergeFactor: 1.0)) - } + var otherSum: CGFloat = 0.0 + var otherRealSum: CGFloat = 0.0 for listCategory in otherListCategories { var categoryChartFraction: CGFloat = listCategory.sizeFraction - if !self.selectedCategories.isEmpty && !self.selectedCategories.contains(listCategory.key) { + if let aggregatedData = self.aggregatedData, !aggregatedData.selectedCategories.isEmpty, !aggregatedData.selectedCategories.contains(listCategory.key) { categoryChartFraction = 0.0 } - let visualMergeFactor: CGFloat - if self.isOtherCategoryExpanded { - visualMergeFactor = 1.0 - } else { - visualMergeFactor = 0.0 + var chartItem = PieChartComponent.ChartData.Item(id: listCategory.key, displayValue: listCategory.sizeFraction, displaySize: listCategory.size, value: categoryChartFraction, color: listCategory.color, 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(PieChartComponent.ChartData.Item(id: listCategory.key, displayValue: listCategory.sizeFraction, value: categoryChartFraction, color: self.isOtherCategoryExpanded ? listCategory.color : Category.misc.color, mergeable: true, mergeFactor: visualMergeFactor)) + chartItems.append(chartItem) } + chartItems.append(PieChartComponent.ChartData.Item(id: .other, displayValue: otherRealSum, displaySize: totalOtherSize, value: self.isOtherCategoryExpanded ? 0.0 : otherSum, color: Category.misc.color, mergeable: false, mergeFactor: 1.0)) + let chartData = PieChartComponent.ChartData(items: chartItems) self.pieChartView.parentState = state let pieChartSize = self.pieChartView.update( transition: transition, component: AnyComponent(PieChartComponent( theme: environment.theme, + strings: environment.strings, chartData: chartData )), environment: {}, @@ -1059,7 +1370,7 @@ final class StorageUsageScreenComponent: Component { transition.setFrame(view: pieChartComponentView, frame: pieChartFrame) transition.setAlpha(view: pieChartComponentView, alpha: listCategories.isEmpty ? 0.0 : 1.0) } - if let _ = self.currentStats, listCategories.isEmpty { + if let _ = self.aggregatedData, listCategories.isEmpty { let checkColor = UIColor(rgb: 0x34C759) let doneStatusNode: RadialStatusNode @@ -1149,22 +1460,15 @@ final class StorageUsageScreenComponent: Component { let totalUsageText: String if listCategories.isEmpty { totalUsageText = environment.strings.StorageManagement_DescriptionCleared - } else if let currentStats = self.currentStats { - let contextStats: StorageUsageStats - if let peer = component.peer { - contextStats = currentStats.peers[peer.id]?.stats ?? StorageUsageStats(categories: [:]) - } else { - contextStats = currentStats.totalStats - } - + } else if let aggregatedData = self.aggregatedData { var totalStatsSize: Int64 = 0 - for (_, value) in contextStats.categories { + for (_, value) in aggregatedData.contextStats.categories { totalStatsSize += value.size } if let _ = component.peer { var allStatsSize: Int64 = 0 - for (_, value) in currentStats.totalStats.categories { + for (_, value) in aggregatedData.stats.totalStats.categories { allStatsSize += value.size } @@ -1188,8 +1492,8 @@ final class StorageUsageScreenComponent: Component { totalUsageText = environment.strings.StorageManagement_DescriptionChatUsage(fractionString).string } else { let fraction: Double - if currentStats.deviceFreeSpace != 0 && totalStatsSize != 0 { - fraction = Double(totalStatsSize) / Double(currentStats.deviceFreeSpace + totalStatsSize) + if aggregatedData.stats.deviceFreeSpace != 0 && totalStatsSize != 0 { + fraction = Double(totalStatsSize) / Double(aggregatedData.stats.deviceFreeSpace + totalStatsSize) } else { fraction = 0.0 } @@ -1258,22 +1562,58 @@ final class StorageUsageScreenComponent: Component { } else { chartAvatarNode = AvatarNode(font: avatarPlaceholderFont(size: 17.0)) self.chartAvatarNode = chartAvatarNode - self.scrollView.addSubview(chartAvatarNode.view) + if let pieChartComponentView = self.pieChartView.view { + self.scrollView.insertSubview(chartAvatarNode.view, belowSubview: pieChartComponentView) + } else { + self.scrollView.addSubview(chartAvatarNode.view) + } chartAvatarNode.frame = avatarFrame 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(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) + /*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.scrollView.addSubview(chartTotalLabelView) + if let pieChartComponentView = self.pieChartView.view { + self.scrollView.insertSubview(chartTotalLabelView, belowSubview: pieChartComponentView) + } else { + 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)) + 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) } } @@ -1287,28 +1627,29 @@ final class StorageUsageScreenComponent: Component { strings: environment.strings, categories: listCategories, isOtherExpanded: self.isOtherCategoryExpanded, + displayAction: component.peer == nil, toggleCategorySelection: { [weak self] key in - guard let self else { + guard let self, let aggregatedData = self.aggregatedData else { return } if key == Category.other { - let otherCategories = self.otherCategories.filter(self.existingCategories.contains) + let otherCategories = self.otherCategories.filter(aggregatedData.existingCategories.contains) if !otherCategories.isEmpty { - if otherCategories.allSatisfy(self.selectedCategories.contains) { + if otherCategories.allSatisfy(aggregatedData.selectedCategories.contains) { for item in otherCategories { - self.selectedCategories.remove(item) + aggregatedData.setIsCategorySelected(category: item, isSelected: false) } } else { for item in otherCategories { - let _ = self.selectedCategories.insert(item) + aggregatedData.setIsCategorySelected(category: item, isSelected: true) } } } } else { - if self.selectedCategories.contains(key) { - self.selectedCategories.remove(key) + if aggregatedData.selectedCategories.contains(key) { + aggregatedData.setIsCategorySelected(category: key, isSelected: false) } else { - self.selectedCategories.insert(key) + aggregatedData.setIsCategorySelected(category: key, isSelected: true) } } self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) @@ -1325,7 +1666,7 @@ final class StorageUsageScreenComponent: Component { guard let self else { return } - self.requestClear(categories: self.selectedCategories, peers: Set(), messages: Set()) + self.requestClear(fromCategories: true) } )), environment: {}, @@ -1581,20 +1922,21 @@ final class StorageUsageScreenComponent: Component { } var panelItems: [StorageUsagePanelContainerComponent.Item] = [] - if let peerItems = self.peerItems, !peerItems.items.isEmpty, !listCategories.isEmpty { + 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: self.peerItems, - selectionState: self.selectionState, + items: peerItems, + selectionState: aggregatedData.isSelectingPeers ? aggregatedData.selectionState : nil, peerAction: { [weak self] peer in - guard let self else { + guard let self, let aggregatedData = self.aggregatedData else { return } - if let selectionState = self.selectionState { - self.selectionState = selectionState.togglePeer(id: peer.id, availableMessages: self.currentMessages) + 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) @@ -1605,10 +1947,11 @@ final class StorageUsageScreenComponent: Component { return } + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + var itemList: [ContextMenuItem] = [] - //TODO:localize itemList.append(.action(ContextMenuActionItem( - text: "Show Details", + 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 @@ -1620,7 +1963,7 @@ final class StorageUsageScreenComponent: Component { }) )) itemList.append(.action(ContextMenuActionItem( - text: "Open Profile", + 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) @@ -1649,26 +1992,21 @@ final class StorageUsageScreenComponent: Component { }) )) itemList.append(.action(ContextMenuActionItem( - text: "Select", + 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 else { + guard let self, let aggregatedData = self.aggregatedData else { return } - if self.selectionState == nil { - self.selectionState = SelectionState() - } - self.selectionState = self.selectionState?.togglePeer(id: peer.id, availableMessages: self.currentMessages) + 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 presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - let controller = ContextController( account: component.context.account, presentationData: presentationData, @@ -1685,25 +2023,22 @@ final class StorageUsageScreenComponent: Component { )) )) } - if let imageItems = self.imageItems, !imageItems.items.isEmpty, !listCategories.isEmpty { + 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: self.imageItems, - selectionState: self.selectionState ?? SelectionState(), + items: aggregatedData.imageItems, + selectionState: aggregatedData.selectionState, action: { [weak self] messageId in - guard let self else { + guard let self, let aggregatedData = self.aggregatedData else { return } - guard let _ = self.currentMessages[messageId] else { + guard let _ = aggregatedData.messages[messageId] else { return } - if self.selectionState == nil { - self.selectionState = SelectionState() - } - self.selectionState = self.selectionState?.toggleMessage(id: messageId) + aggregatedData.toggleMessageSelection(id: messageId) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }, contextAction: { [weak self] messageId, containerView, sourceRect, gesture in @@ -1715,25 +2050,22 @@ final class StorageUsageScreenComponent: Component { )) )) } - if let fileItems = self.fileItems, !fileItems.items.isEmpty, !listCategories.isEmpty { + 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: self.fileItems, - selectionState: self.selectionState ?? SelectionState(), + items: fileItems, + selectionState: aggregatedData.selectionState, action: { [weak self] messageId in - guard let self else { + guard let self, let aggregatedData = self.aggregatedData else { return } - guard let _ = self.currentMessages[messageId] else { + guard let _ = aggregatedData.messages[messageId] else { return } - if self.selectionState == nil { - self.selectionState = SelectionState() - } - self.selectionState = self.selectionState?.toggleMessage(id: messageId) + aggregatedData.toggleMessageSelection(id: messageId) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }, contextAction: { [weak self] messageId, containerView, gesture in @@ -1745,32 +2077,23 @@ final class StorageUsageScreenComponent: Component { )) )) } - if let musicItems = self.musicItems, !musicItems.items.isEmpty, !listCategories.isEmpty { + 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: self.musicItems, - selectionState: self.selectionState ?? SelectionState(), + items: musicItems, + selectionState: aggregatedData.selectionState, action: { [weak self] messageId in - guard let self else { + guard let self, let aggregatedData = self.aggregatedData else { return } - guard let message = self.currentMessages[messageId] else { + guard let _ = aggregatedData.messages[messageId] else { return } - if self.selectionState == nil { - //self.openMessage(message: message) - let _ = message - - self.selectionState = SelectionState() - self.selectionState = self.selectionState?.toggleMessage(id: messageId) - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) - } else { - self.selectionState = self.selectionState?.toggleMessage(id: messageId) - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) - } + 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 { @@ -1915,24 +2238,6 @@ final class StorageUsageScreenComponent: Component { return } - var existingCategories = Set() - let contextStats: StorageUsageStats - if let peer = component.peer { - contextStats = stats.peers[peer.id]?.stats ?? StorageUsageStats(categories: [:]) - } else { - contextStats = stats.totalStats - } - for (category, value) in contextStats.categories { - if value.size != 0 { - existingCategories.insert(StorageUsageScreenComponent.Category(category)) - } - } - - if firstTime { - self.currentStats = stats - self.existingCategories = existingCategories - } - var peerItems: [StoragePeerListPanelComponent.Item] = [] if component.peer == nil { @@ -1955,8 +2260,20 @@ final class StorageUsageScreenComponent: Component { } } + 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.peerItems = StoragePeerListPanelComponent.Items(items: peerItems) + self.aggregatedData = initialAggregatedData + self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(value: .firstStatsUpdate))) self.component?.ready.set(.single(true)) } @@ -1968,7 +2285,7 @@ final class StorageUsageScreenComponent: Component { var musicItems: [StorageFileListPanelComponent.Item] = [] } - self.messagesDisposable = (component.context.engine.resources.renderStorageUsageStatsMessages(stats: contextStats, categories: [.files, .photos, .videos, .music], existingMessages: self.currentMessages) + 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() @@ -2085,25 +2402,15 @@ final class StorageUsageScreenComponent: Component { } } - if !firstTime { - self.currentStats = stats - self.existingCategories = existingCategories - self.peerItems = StoragePeerListPanelComponent.Items(items: peerItems) - } - - self.currentMessages = result.messages - - self.imageItems = StorageMediaGridPanelComponent.Items(items: result.imageItems) - self.fileItems = StorageFileListPanelComponent.Items(items: result.fileItems) - self.musicItems = StorageFileListPanelComponent.Items(items: result.musicItems) - - if self.selectionState != nil { - if result.imageItems.isEmpty && result.fileItems.isEmpty && result.musicItems.isEmpty && peerItems.isEmpty { - self.selectionState = nil - } else { - self.selectionState = nil - } - } + 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 @@ -2135,7 +2442,7 @@ final class StorageUsageScreenComponent: Component { } private func messageGaleryContextAction(messageId: EngineMessage.Id, sourceView: UIView, sourceRect: CGRect, gesture: ContextGesture) { - guard let component = self.component, let message = self.currentMessages[messageId] else { + guard let component = self.component, let aggregatedData = self.aggregatedData, let message = aggregatedData.messages[messageId] else { gesture.cancel() return } @@ -2160,6 +2467,20 @@ final class StorageUsageScreenComponent: Component { let strings = presentationData.strings var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem( + text: presentationData.strings.StorageManagement_OpenPhoto, + 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 { @@ -2190,13 +2511,10 @@ final class StorageUsageScreenComponent: Component { c.dismiss(completion: { }) - guard let self else { + guard let self, let aggregatedData = self.aggregatedData else { return } - if self.selectionState == nil { - self.selectionState = SelectionState() - } - self.selectionState = self.selectionState?.toggleMessage(id: message.id) + aggregatedData.toggleMessageSelection(id: message.id) self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) }))) @@ -2222,31 +2540,31 @@ final class StorageUsageScreenComponent: Component { } private func messageContextAction(messageId: EngineMessage.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture) { - guard let component = self.component else { + guard let component = self.component, let aggregatedData = self.aggregatedData else { return } - guard let message = self.currentMessages[messageId] else { + guard let message = aggregatedData.messages[messageId] else { return } - //TODO:localize - var openTitle: String = "Open" + 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 = "Open Photo" + openTitle = presentationData.strings.StorageManagement_OpenPhoto } else if let file = media as? TelegramMediaFile { if file.isVideo { - openTitle = "Open Video" + openTitle = presentationData.strings.StorageManagement_OpenVideo } else { - openTitle = "Open File" + openTitle = presentationData.strings.StorageManagement_OpenFile } isAudio = file.isMusic || file.isVoice } } var itemList: [ContextMenuItem] = [] - //TODO:localize if !isAudio { itemList.append(.action(ContextMenuActionItem( text: openTitle, @@ -2261,8 +2579,9 @@ final class StorageUsageScreenComponent: Component { }) )) } + itemList.append(.action(ContextMenuActionItem( - text: "View in Chat", + text: presentationData.strings.SharedMedia_ViewInChat, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor) }, @@ -2291,26 +2610,21 @@ final class StorageUsageScreenComponent: Component { }) )) itemList.append(.action(ContextMenuActionItem( - text: "Select", + 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 else { + guard let self, let aggregatedData = self.aggregatedData else { return } - if self.selectionState == nil { - self.selectionState = SelectionState() - } - self.selectionState = self.selectionState?.toggleMessage(id: message.id) + 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 presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - let controller = ContextController( account: component.context.account, presentationData: presentationData, @@ -2444,21 +2758,24 @@ final class StorageUsageScreenComponent: Component { )) } - private func requestClear(categories: Set, peers: Set, messages: Set) { - guard let component = self.component else { + 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 categories == self.existingCategories { - clearTitle = presentationData.strings.StorageManagement_ClearAll - } else { + 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: [ @@ -2466,7 +2783,7 @@ final class StorageUsageScreenComponent: Component { ActionSheetButtonItem(title: clearTitle, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() - self?.commitClear(categories: categories, peers: peers, messages: messages) + self?.commitClear(fromCategories: fromCategories) }) ]), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in @@ -2476,12 +2793,172 @@ final class StorageUsageScreenComponent: Component { self.controller?()?.present(actionSheet, in: .window(.root)) } - private func commitClear(categories: Set, peers: Set, messages: Set) { - guard let component = self.component else { + private func commitClear(fromCategories: Bool) { + guard let component = self.component, let aggregatedData = self.aggregatedData else { return } - if !categories.isEmpty { + 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) + } + } + + 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(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) + } + } + + self.isClearing = true + self.state?.updated(transition: .immediate) + + let _ = (component.context.engine.resources.clearStorage(peerId: component.peer?.id, categories: mappedCategories, includeMessages: [], excludeMessages: []) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let _ = self.component, let aggregatedData = self.aggregatedData else { + return + } + 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 + } + + if let value = contextStats.categories[mappedCategory] { + totalSize += value.size + } + } + + 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(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) + } + }) + }) + } + } + + /*if !aggregatedData.selectedCategories.isEmpty { let peerId: EnginePeer.Id? = component.peer?.id var mappedCategories: [StorageUsageStats.CategoryKey] = [] @@ -2511,17 +2988,12 @@ final class StorageUsageScreenComponent: Component { let _ = (component.context.engine.resources.clearStorage(peerId: peerId, categories: mappedCategories, includeMessages: [], excludeMessages: []) |> deliverOnMainQueue).start(completed: { [weak self] in - guard let self, let component = self.component, let currentStats = self.currentStats else { + guard let self, let _ = self.component, let aggregatedData = self.aggregatedData else { return } var totalSize: Int64 = 0 - let contextStats: StorageUsageStats - if let peer = component.peer { - contextStats = currentStats.peers[peer.id]?.stats ?? StorageUsageStats(categories: [:]) - } else { - contextStats = currentStats.totalStats - } + let contextStats = aggregatedData.contextStats for category in categories { let mappedCategory: StorageUsageStats.CategoryKey @@ -2559,49 +3031,8 @@ final class StorageUsageScreenComponent: Component { }) }) } else if !peers.isEmpty || !messages.isEmpty { - self.isClearing = true - self.state?.updated(transition: .immediate) - var totalSize: Int64 = 0 - if let peerItems = self.peerItems { - for item in peerItems.items { - if peers.contains(item.peer.id) { - totalSize += item.size - } - } - } - - var includeMessages: [Message] = [] - var excludeMessages: [Message] = [] - - for (id, message) in self.currentMessages { - if peers.contains(id.peerId) { - if !messages.contains(id) { - excludeMessages.append(message) - } - } else { - if messages.contains(id) { - includeMessages.append(message) - } - } - } - - let _ = (component.context.engine.resources.clearStorage(peerIds: peers, includeMessages: includeMessages, excludeMessages: excludeMessages) - |> deliverOnMainQueue).start(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 openKeepMediaCategory(mappedCategory: CacheStorageSettings.PeerStorageCategory, sourceView: StoragePeerTypeItemComponent.View) { diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/Contents.json new file mode 100644 index 0000000000..6e965652df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleAvatars.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleAvatars.imageset/Contents.json new file mode 100644 index 0000000000..bcc0438465 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleAvatars.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Profile Photos.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleAvatars.imageset/Profile Photos.svg b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleAvatars.imageset/Profile Photos.svg new file mode 100644 index 0000000000..60a180f4fd --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleAvatars.imageset/Profile Photos.svg @@ -0,0 +1,7 @@ + + + Icon / Profile Photos + + + + \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleDocuments.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleDocuments.imageset/Contents.json new file mode 100644 index 0000000000..120a59fd74 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleDocuments.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Documents.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleDocuments.imageset/Documents.svg b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleDocuments.imageset/Documents.svg new file mode 100644 index 0000000000..f69d052835 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleDocuments.imageset/Documents.svg @@ -0,0 +1,7 @@ + + + Icon / Documents + + + + \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleMusic.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleMusic.imageset/Contents.json new file mode 100644 index 0000000000..1b4b2c3beb --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleMusic.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Music.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleMusic.imageset/Music.svg b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleMusic.imageset/Music.svg new file mode 100644 index 0000000000..de6c5abbe3 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleMusic.imageset/Music.svg @@ -0,0 +1,7 @@ + + + Icon / Music + + + + \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleOther.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleOther.imageset/Contents.json new file mode 100644 index 0000000000..3387c50e5b --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleOther.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Other.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleOther.imageset/Other.svg b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleOther.imageset/Other.svg new file mode 100644 index 0000000000..436a2ef442 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleOther.imageset/Other.svg @@ -0,0 +1,7 @@ + + + Icon / Other + + + + \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticlePhotos.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticlePhotos.imageset/Contents.json new file mode 100644 index 0000000000..393e403fd4 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticlePhotos.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Photos.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticlePhotos.imageset/Photos.svg b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticlePhotos.imageset/Photos.svg new file mode 100644 index 0000000000..5b5fa61b20 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticlePhotos.imageset/Photos.svg @@ -0,0 +1,7 @@ + + + Icon / Photos + + + + \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleStickers.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleStickers.imageset/Contents.json new file mode 100644 index 0000000000..b84d8c2123 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleStickers.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Stickers.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleStickers.imageset/Stickers.svg b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleStickers.imageset/Stickers.svg new file mode 100644 index 0000000000..4d2ce8eb11 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleStickers.imageset/Stickers.svg @@ -0,0 +1,7 @@ + + + Icon / Stickers + + + + \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleVideos.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleVideos.imageset/Contents.json new file mode 100644 index 0000000000..12a3baa92e --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleVideos.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Videos.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleVideos.imageset/Videos.svg b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleVideos.imageset/Videos.svg new file mode 100644 index 0000000000..2432eee397 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Settings/Storage/ParticleVideos.imageset/Videos.svg @@ -0,0 +1,7 @@ + + + Icon / Videos + + + + \ No newline at end of file diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift index 2f791a6e9b..45eed334bd 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift @@ -262,6 +262,7 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, UIScrollViewDe } }, present: { _ in }, openForumThread: { _, _ in + }, openStorageManagement: { }) interaction.searchTextHighightState = searchQuery self.interaction = interaction diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 802a86a466..080818c108 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -29,6 +29,7 @@ import PremiumUI import StickerPackPreviewUI import ChatControllerInteraction import ChatPresentationInterfaceState +import StorageUsageScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -1424,6 +1425,15 @@ public final class SharedAccountContextImpl: SharedAccountContext { return SettingsUI.makePrivacyAndSecurityController(context: context) } + public func makeStorageManagementController(context: AccountContext) -> ViewController { + return StorageUsageScreen(context: context, makeStorageUsageExceptionsScreen: { [weak context] category in + guard let context else { + return nil + } + return storageUsageExceptionsScreen(context: context, category: category) + }) + } + public func makePremiumIntroController(context: AccountContext, source: PremiumIntroSource) -> ViewController { let mappedSource: PremiumSource switch source { diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index 2f773d98bd..b0f186935c 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit 2f773d98bdfd60d55247b2b3adb8ec70a1fc5748 +Subproject commit b0f186935c88e09be2106c80b9880ba92fb6b2d3 diff --git a/submodules/lottie-ios/BUILD b/submodules/lottie-ios/BUILD index 84c7288527..433dc9b8b8 100644 --- a/submodules/lottie-ios/BUILD +++ b/submodules/lottie-ios/BUILD @@ -10,6 +10,7 @@ swift_library( "-warnings-as-errors", ], deps = [ + "//submodules/Display" ], visibility = [ "//visibility:public", diff --git a/submodules/lottie-ios/Sources/Public/Animation/AnimationView.swift b/submodules/lottie-ios/Sources/Public/Animation/AnimationView.swift index 1ff17dbf1c..cc33f87ed8 100644 --- a/submodules/lottie-ios/Sources/Public/Animation/AnimationView.swift +++ b/submodules/lottie-ios/Sources/Public/Animation/AnimationView.swift @@ -8,6 +8,7 @@ import Foundation import QuartzCore import UIKit +import Display // MARK: - LottieBackgroundBehavior @@ -395,31 +396,15 @@ final public class AnimationView: AnimationViewBase { } } - private var workaroundDisplayLink: CADisplayLink? + private var workaroundDisplayLink: SharedDisplayLinkDriver.Link? private var needsWorkaroundDisplayLink: Bool = false { didSet { if self.needsWorkaroundDisplayLink != oldValue { if self.needsWorkaroundDisplayLink { - if workaroundDisplayLink == nil { - class WorkaroundDisplayLinkTarget { - private let f: () -> Void - - init(_ f: @escaping () -> Void) { - self.f = f - } - - @objc func update() { - self.f() - } - } - /*self.workaroundDisplayLink = CADisplayLink(target: WorkaroundDisplayLinkTarget { [weak self] in + if self.workaroundDisplayLink == nil { + self.workaroundDisplayLink = SharedDisplayLinkDriver.shared.add { [weak self] in let _ = self?.realtimeAnimationProgress - }, selector: #selector(WorkaroundDisplayLinkTarget.update)) - if #available(iOS 15.0, *) { - let maxFps = Float(UIScreen.main.maximumFramesPerSecond) - self.workaroundDisplayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: maxFps, maximum: maxFps, preferred: maxFps) } - self.workaroundDisplayLink?.add(to: .main, forMode: .common)*/ } } else { if let workaroundDisplayLink = self.workaroundDisplayLink { diff --git a/versions.json b/versions.json index 8c2d1d0fbb..40064af59e 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "9.3", + "app": "9.3.1", "bazel": "5.3.1", "xcode": "14.1" }