mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2026-04-08 06:07:52 +00:00
Fixes
fix localeWithStrings globally (#30)
Fix badge on zoomed devices. closes #9
Hide channel bottom panel closes #27
Another attempt to fix badge on some Zoomed devices
Force System Share sheet tg://sg/debug
fixes for device badge
New Crowdin updates (#34)
* New translations sglocalizable.strings (Chinese Traditional)
* New translations sglocalizable.strings (Chinese Simplified)
* New translations sglocalizable.strings (Chinese Traditional)
Fix input panel hidden on selection (#31)
* added if check for selectionState != nil
* same order of subnodes
Revert "Fix input panel hidden on selection (#31)"
This reverts commit e8a8bb1496.
Fix input panel for channels Closes #37
Quickly share links with system's share menu
force tabbar when editing
increase height for correct animation
New translations sglocalizable.strings (Ukrainian) (#38)
Hide Post Story button
Fix 10.15.1
Fix archive option for long-tap
Enable in-app Safari
Disable some unsupported purchases
disableDeleteChatSwipeOption + refactor restart alert
Hide bot in suggestions list
Fix merge v11.0
Fix exceptions for safari webview controller
New Crowdin updates (#47)
* New translations sglocalizable.strings (Romanian)
* New translations sglocalizable.strings (French)
* New translations sglocalizable.strings (Spanish)
* New translations sglocalizable.strings (Afrikaans)
* New translations sglocalizable.strings (Arabic)
* New translations sglocalizable.strings (Catalan)
* New translations sglocalizable.strings (Czech)
* New translations sglocalizable.strings (Danish)
* New translations sglocalizable.strings (German)
* New translations sglocalizable.strings (Greek)
* New translations sglocalizable.strings (Finnish)
* New translations sglocalizable.strings (Hebrew)
* New translations sglocalizable.strings (Hungarian)
* New translations sglocalizable.strings (Italian)
* New translations sglocalizable.strings (Japanese)
* New translations sglocalizable.strings (Korean)
* New translations sglocalizable.strings (Dutch)
* New translations sglocalizable.strings (Norwegian)
* New translations sglocalizable.strings (Polish)
* New translations sglocalizable.strings (Portuguese)
* New translations sglocalizable.strings (Serbian (Cyrillic))
* New translations sglocalizable.strings (Swedish)
* New translations sglocalizable.strings (Turkish)
* New translations sglocalizable.strings (Vietnamese)
* New translations sglocalizable.strings (Indonesian)
* New translations sglocalizable.strings (Hindi)
* New translations sglocalizable.strings (Uzbek)
New Crowdin updates (#49)
* New translations sglocalizable.strings (Arabic)
* New translations sglocalizable.strings (Arabic)
New translations sglocalizable.strings (Russian) (#51)
Call confirmation
WIP Settings search
Settings Search
Localize placeholder
Update AccountUtils.swift
mark mutual contact
Align back context action to left
New Crowdin updates (#54)
* New translations sglocalizable.strings (Chinese Simplified)
* New translations sglocalizable.strings (Chinese Traditional)
* New translations sglocalizable.strings (Ukrainian)
Independent Playground app for simulator
New translations sglocalizable.strings (Ukrainian) (#55)
Playground UIKit base and controllers
Inject SwiftUI view with overflow to AsyncDisplayKit
Launch Playgound project on simulator
Create .swiftformat
Move Playground to example
Update .swiftformat
Init SwiftUIViewController
wip
New translations sglocalizable.strings (Chinese Traditional) (#57)
Xcode 16 fixes
Fix
New translations sglocalizable.strings (Italian) (#59)
New translations sglocalizable.strings (Chinese Simplified) (#63)
Force disable CallKit integration due to missing NSE Entitlement
Fix merge
Fix whole chat translator
Sweetpad config
Bump version
11.3.1 fixes
Mutual contact placement fix
Disable Video PIP swipe
Update versions.json
Fix PIP crash
3733 lines
183 KiB
Swift
3733 lines
183 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import ComponentFlow
|
|
import SwiftSignalKit
|
|
import ViewControllerComponent
|
|
import ComponentDisplayAdapters
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import TelegramCore
|
|
import Postbox
|
|
import MultilineTextComponent
|
|
import EmojiStatusComponent
|
|
import Markdown
|
|
import ContextUI
|
|
import AnimatedAvatarSetNode
|
|
import AvatarNode
|
|
import RadialStatusNode
|
|
import UndoUI
|
|
import AnimatedStickerNode
|
|
import TelegramAnimatedStickerNode
|
|
import TelegramStringFormatting
|
|
import GalleryData
|
|
import AnimatedTextComponent
|
|
import BottomButtonPanelComponent
|
|
|
|
#if DEBUG
|
|
import os.signpost
|
|
|
|
private class SignpostContext {
|
|
enum EventType {
|
|
case begin
|
|
case end
|
|
}
|
|
|
|
class OpaqueData {
|
|
}
|
|
|
|
static var shared: SignpostContext? = {
|
|
if #available(iOS 15.0, *) {
|
|
return SignpostContextImpl()
|
|
} else {
|
|
return nil
|
|
}
|
|
}()
|
|
|
|
func begin(name: StaticString) -> OpaqueData {
|
|
preconditionFailure()
|
|
}
|
|
|
|
func end(name: StaticString, data: OpaqueData) {
|
|
}
|
|
}
|
|
|
|
@available(iOS 15.0, *)
|
|
private final class SignpostContextImpl: SignpostContext {
|
|
final class OpaqueDataImpl: OpaqueData {
|
|
let state: OSSignpostIntervalState
|
|
let timestamp: Double
|
|
|
|
init(state: OSSignpostIntervalState, timestamp: Double) {
|
|
self.state = state
|
|
self.timestamp = timestamp
|
|
}
|
|
}
|
|
|
|
private let signpost = OSSignposter(subsystem: "org.telegram.Telegram-iOS", category: "StorageUsageScreen")
|
|
private let id: OSSignpostID
|
|
|
|
override init() {
|
|
self.id = self.signpost.makeSignpostID()
|
|
|
|
super.init()
|
|
}
|
|
|
|
override func begin(name: StaticString) -> OpaqueData {
|
|
let result = self.signpost.beginInterval(name, id: self.id)
|
|
return OpaqueDataImpl(state: result, timestamp: CFAbsoluteTimeGetCurrent())
|
|
}
|
|
|
|
override func end(name: StaticString, data: OpaqueData) {
|
|
if let data = data as? OpaqueDataImpl {
|
|
self.signpost.endInterval(name, data.state)
|
|
print("Signpost \(name): \((CFAbsoluteTimeGetCurrent() - data.timestamp) * 1000.0) ms")
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
private extension StorageUsageScreenComponent.Category {
|
|
init(_ category: StorageUsageStats.CategoryKey) {
|
|
switch category {
|
|
case .photos:
|
|
self = .photos
|
|
case .videos:
|
|
self = .videos
|
|
case .files:
|
|
self = .files
|
|
case .music:
|
|
self = .music
|
|
case .stickers:
|
|
self = .stickers
|
|
case .avatars:
|
|
self = .avatars
|
|
case .misc:
|
|
self = .misc
|
|
case .stories:
|
|
self = .stories
|
|
}
|
|
}
|
|
}
|
|
|
|
final class StorageUsageScreenComponent: Component {
|
|
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
|
|
|
let context: AccountContext
|
|
let makeStorageUsageExceptionsScreen: (CacheStorageSettings.PeerStorageCategory) -> ViewController?
|
|
let peer: EnginePeer?
|
|
let ready: Promise<Bool>
|
|
|
|
init(
|
|
context: AccountContext,
|
|
makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController?,
|
|
peer: EnginePeer?,
|
|
ready: Promise<Bool>
|
|
) {
|
|
self.context = context
|
|
self.makeStorageUsageExceptionsScreen = makeStorageUsageExceptionsScreen
|
|
self.peer = peer
|
|
self.ready = ready
|
|
}
|
|
|
|
static func ==(lhs: StorageUsageScreenComponent, rhs: StorageUsageScreenComponent) -> Bool {
|
|
if lhs.context !== rhs.context {
|
|
return false
|
|
}
|
|
if lhs.peer != rhs.peer {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private final class ScrollViewImpl: UIScrollView {
|
|
override func touchesShouldCancel(in view: UIView) -> Bool {
|
|
return true
|
|
}
|
|
|
|
override var contentOffset: CGPoint {
|
|
set(value) {
|
|
var value = value
|
|
if value.y > self.contentSize.height - self.bounds.height {
|
|
value.y = max(0.0, self.contentSize.height - self.bounds.height)
|
|
self.bounces = false
|
|
} else {
|
|
self.bounces = true
|
|
}
|
|
super.contentOffset = value
|
|
} get {
|
|
return super.contentOffset
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class AnimationHint {
|
|
enum Value {
|
|
case firstStatsUpdate
|
|
case clearedItems
|
|
}
|
|
let value: Value
|
|
|
|
init(value: Value) {
|
|
self.value = value
|
|
}
|
|
}
|
|
|
|
final class SelectionState: Equatable {
|
|
let selectedPeers: Set<EnginePeer.Id>
|
|
let selectedMessages: Set<EngineMessage.Id>
|
|
|
|
var isEmpty: Bool {
|
|
if !self.selectedPeers.isEmpty {
|
|
return false
|
|
}
|
|
if !self.selectedMessages.isEmpty {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
init(
|
|
selectedPeers: Set<EnginePeer.Id>,
|
|
selectedMessages: Set<EngineMessage.Id>
|
|
) {
|
|
self.selectedPeers = selectedPeers
|
|
self.selectedMessages = selectedMessages
|
|
}
|
|
|
|
convenience init() {
|
|
self.init(
|
|
selectedPeers: Set(),
|
|
selectedMessages: Set()
|
|
)
|
|
}
|
|
|
|
static func ==(lhs: SelectionState, rhs: SelectionState) -> Bool {
|
|
if lhs.selectedPeers != rhs.selectedPeers {
|
|
return false
|
|
}
|
|
if lhs.selectedMessages != rhs.selectedMessages {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func togglePeer(id: EnginePeer.Id, availableMessages: [EngineMessage.Id: Message]) -> SelectionState {
|
|
var selectedPeers = self.selectedPeers
|
|
var selectedMessages = self.selectedMessages
|
|
|
|
if selectedPeers.contains(id) {
|
|
selectedPeers.remove(id)
|
|
|
|
for (messageId, _) in availableMessages {
|
|
if messageId.peerId == id {
|
|
selectedMessages.remove(messageId)
|
|
}
|
|
}
|
|
} else {
|
|
selectedPeers.insert(id)
|
|
|
|
for (messageId, _) in availableMessages {
|
|
if messageId.peerId == id {
|
|
selectedMessages.insert(messageId)
|
|
}
|
|
}
|
|
}
|
|
|
|
return SelectionState(
|
|
selectedPeers: selectedPeers,
|
|
selectedMessages: selectedMessages
|
|
)
|
|
}
|
|
|
|
func toggleMessage(id: EngineMessage.Id) -> SelectionState {
|
|
var selectedMessages = self.selectedMessages
|
|
if selectedMessages.contains(id) {
|
|
selectedMessages.remove(id)
|
|
} else {
|
|
selectedMessages.insert(id)
|
|
}
|
|
|
|
return SelectionState(
|
|
selectedPeers: self.selectedPeers,
|
|
selectedMessages: selectedMessages
|
|
)
|
|
}
|
|
}
|
|
|
|
enum Category: Hashable {
|
|
case photos
|
|
case videos
|
|
case files
|
|
case music
|
|
case other
|
|
case stickers
|
|
case avatars
|
|
case misc
|
|
case stories
|
|
|
|
var color: UIColor {
|
|
switch self {
|
|
case .photos:
|
|
return UIColor(rgb: 0x5AC8FA)
|
|
case .videos:
|
|
return UIColor(rgb: 0x3478F6)
|
|
case .files:
|
|
return UIColor(rgb: 0x34C759)
|
|
case .music:
|
|
return UIColor(rgb: 0xFF2D55)
|
|
case .other:
|
|
return UIColor(rgb: 0xC4C4C6)
|
|
case .stickers:
|
|
return UIColor(rgb: 0x5856D6)
|
|
case .avatars:
|
|
return UIColor(rgb: 0xAF52DE)
|
|
case .misc:
|
|
return UIColor(rgb: 0xFF9500)
|
|
case .stories:
|
|
return UIColor(rgb: 0xFF2D55)
|
|
}
|
|
}
|
|
|
|
func title(strings: PresentationStrings) -> String {
|
|
switch self {
|
|
case .photos:
|
|
return strings.StorageManagement_SectionPhotos
|
|
case .videos:
|
|
return strings.StorageManagement_SectionVideos
|
|
case .files:
|
|
return strings.StorageManagement_SectionFiles
|
|
case .music:
|
|
return strings.StorageManagement_SectionMusic
|
|
case .other:
|
|
return strings.StorageManagement_SectionOther
|
|
case .stickers:
|
|
return strings.StorageManagement_SectionStickers
|
|
case .avatars:
|
|
return strings.StorageManagement_SectionAvatars
|
|
case .misc:
|
|
return strings.StorageManagement_SectionMiscellaneous
|
|
case .stories:
|
|
return strings.StorageManagement_SectionStories
|
|
}
|
|
}
|
|
|
|
var particle: String? {
|
|
switch self {
|
|
case .photos:
|
|
return "Settings/Storage/ParticlePhotos"
|
|
case .videos:
|
|
return "Settings/Storage/ParticleVideos"
|
|
case .files:
|
|
return "Settings/Storage/ParticleDocuments"
|
|
case .music:
|
|
return "Settings/Storage/ParticleMusic"
|
|
case .other:
|
|
return "Settings/Storage/ParticleOther"
|
|
case .stickers:
|
|
return "Settings/Storage/ParticleStickers"
|
|
case .avatars:
|
|
return "Settings/Storage/ParticleAvatars"
|
|
case .misc:
|
|
return "Settings/Storage/ParticleOther"
|
|
case .stories:
|
|
return "Settings/Storage/ParticleOther"
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class AggregatedData {
|
|
let peerId: EnginePeer.Id?
|
|
let stats: AllStorageUsageStats
|
|
let contextStats: StorageUsageStats
|
|
let messages: [MessageId: Message]
|
|
|
|
var isSelectingPeers: Bool = false
|
|
private(set) var selectionState: SelectionState
|
|
|
|
let existingCategories: Set<Category>
|
|
private(set) var selectedCategories: Set<Category>
|
|
|
|
let peerItems: StoragePeerListPanelComponent.Items?
|
|
let imageItems: StorageMediaGridPanelComponent.Items?
|
|
let fileItems: StorageFileListPanelComponent.Items?
|
|
let musicItems: StorageFileListPanelComponent.Items?
|
|
|
|
private let allPhotos: Set<EngineMessage.Id>
|
|
private let allVideos: Set<EngineMessage.Id>
|
|
private let allFiles: Set<EngineMessage.Id>
|
|
private let allMusic: Set<EngineMessage.Id>
|
|
|
|
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<EngineMessage.Id>()
|
|
var allVideos = Set<EngineMessage.Id>()
|
|
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<EngineMessage.Id>()
|
|
if let fileItems = self.fileItems {
|
|
for item in fileItems.items {
|
|
allFiles.insert(item.message.id)
|
|
}
|
|
}
|
|
self.allFiles = allFiles
|
|
|
|
var allMusic = Set<EngineMessage.Id>()
|
|
if let musicItems = self.musicItems {
|
|
for item in musicItems.items {
|
|
allMusic.insert(item.message.id)
|
|
}
|
|
}
|
|
self.allMusic = allMusic
|
|
|
|
var existingCategories = Set<Category>()
|
|
for (category, value) in self.contextStats.categories {
|
|
if value.size != 0 {
|
|
existingCategories.insert(StorageUsageScreenComponent.Category(category))
|
|
}
|
|
}
|
|
self.existingCategories = existingCategories
|
|
self.selectedCategories = existingCategories
|
|
|
|
if self.peerId != nil {
|
|
var selectedMessages = self.selectionState.selectedMessages
|
|
selectedMessages.formUnion(self.allPhotos)
|
|
selectedMessages.formUnion(self.allVideos)
|
|
selectedMessages.formUnion(self.allFiles)
|
|
selectedMessages.formUnion(self.allMusic)
|
|
|
|
self.selectionState = SelectionState(selectedPeers: self.selectionState.selectedPeers, selectedMessages: selectedMessages)
|
|
}
|
|
|
|
self.refreshSelectionStats()
|
|
}
|
|
|
|
func setIsCategorySelected(category: Category, isSelected: Bool) {
|
|
if isSelected {
|
|
self.selectedCategories.insert(category)
|
|
} else {
|
|
self.selectedCategories.remove(category)
|
|
}
|
|
|
|
if self.peerId != nil {
|
|
var selectedMessages = self.selectionState.selectedMessages
|
|
switch category {
|
|
case .photos:
|
|
if isSelected {
|
|
selectedMessages.formUnion(self.allPhotos)
|
|
} else {
|
|
selectedMessages.subtract(self.allPhotos)
|
|
}
|
|
case .videos:
|
|
if isSelected {
|
|
selectedMessages.formUnion(self.allVideos)
|
|
} else {
|
|
selectedMessages.subtract(self.allVideos)
|
|
}
|
|
case .files:
|
|
if let fileItems = self.fileItems {
|
|
for item in fileItems.items {
|
|
if isSelected {
|
|
selectedMessages.insert(item.message.id)
|
|
} else {
|
|
selectedMessages.remove(item.message.id)
|
|
}
|
|
}
|
|
}
|
|
case .music:
|
|
if let fileItems = self.musicItems {
|
|
for item in fileItems.items {
|
|
if isSelected {
|
|
selectedMessages.insert(item.message.id)
|
|
} else {
|
|
selectedMessages.remove(item.message.id)
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
self.selectionState = SelectionState(selectedPeers: self.selectionState.selectedPeers, selectedMessages: selectedMessages)
|
|
}
|
|
|
|
self.refreshSelectionStats()
|
|
}
|
|
|
|
func clearPeerSelection() {
|
|
self.selectionState = SelectionState(selectedPeers: Set(), selectedMessages: Set())
|
|
|
|
self.refreshSelectionStats()
|
|
}
|
|
|
|
func togglePeerSelection(id: EnginePeer.Id) {
|
|
self.selectionState = self.selectionState.togglePeer(id: id, availableMessages: self.messages)
|
|
|
|
self.refreshSelectionStats()
|
|
}
|
|
|
|
func toggleMessageSelection(id: EngineMessage.Id) {
|
|
self.selectionState = self.selectionState.toggleMessage(id: id)
|
|
|
|
if self.peerId != nil {
|
|
if self.allPhotos.contains(id) {
|
|
if !self.selectionState.selectedMessages.contains(id) {
|
|
if self.allPhotos.intersection(self.selectionState.selectedMessages).isEmpty {
|
|
self.selectedCategories.remove(.photos)
|
|
}
|
|
} else {
|
|
if self.allPhotos.intersection(self.selectionState.selectedMessages) == self.allPhotos {
|
|
self.selectedCategories.insert(.photos)
|
|
}
|
|
}
|
|
} else if self.allVideos.contains(id) {
|
|
if !self.selectionState.selectedMessages.contains(id) {
|
|
if self.allVideos.intersection(self.selectionState.selectedMessages).isEmpty {
|
|
self.selectedCategories.remove(.videos)
|
|
}
|
|
} else {
|
|
if self.allVideos.intersection(self.selectionState.selectedMessages) == self.allVideos {
|
|
self.selectedCategories.insert(.videos)
|
|
}
|
|
}
|
|
} else if self.allFiles.contains(id) {
|
|
if !self.selectionState.selectedMessages.contains(id) {
|
|
if self.allFiles.intersection(self.selectionState.selectedMessages).isEmpty {
|
|
self.selectedCategories.remove(.files)
|
|
}
|
|
} else {
|
|
if self.allFiles.intersection(self.selectionState.selectedMessages) == self.allFiles {
|
|
self.selectedCategories.insert(.files)
|
|
}
|
|
}
|
|
} else if self.allMusic.contains(id) {
|
|
if !self.selectionState.selectedMessages.contains(id) {
|
|
if self.allMusic.intersection(self.selectionState.selectedMessages).isEmpty {
|
|
self.selectedCategories.remove(.music)
|
|
}
|
|
} else {
|
|
if self.allMusic.intersection(self.selectionState.selectedMessages) == self.allMusic {
|
|
self.selectedCategories.insert(.music)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.refreshSelectionStats()
|
|
}
|
|
|
|
private func refreshSelectionStats() {
|
|
if let _ = self.peerId {
|
|
var selectedSize: Int64 = 0
|
|
for (category, value) in self.contextStats.categories {
|
|
let mappedCategory = StorageUsageScreenComponent.Category(category)
|
|
if self.selectedCategories.contains(mappedCategory) {
|
|
selectedSize += value.size
|
|
}
|
|
}
|
|
|
|
var clearIncludeMessages: [Message] = []
|
|
var clearExcludeMessages: [Message] = []
|
|
|
|
if self.selectedCategories.contains(.photos) {
|
|
let deselectedPhotos = self.allPhotos.subtracting(self.selectionState.selectedMessages)
|
|
if !deselectedPhotos.isEmpty, let imageItems = self.imageItems {
|
|
for item in imageItems.items {
|
|
if deselectedPhotos.contains(item.message.id) {
|
|
selectedSize -= item.size
|
|
clearExcludeMessages.append(item.message._asMessage())
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let selectedPhotos = self.allPhotos.intersection(self.selectionState.selectedMessages)
|
|
if !selectedPhotos.isEmpty, let imageItems = self.imageItems {
|
|
for item in imageItems.items {
|
|
if selectedPhotos.contains(item.message.id) {
|
|
selectedSize += item.size
|
|
clearIncludeMessages.append(item.message._asMessage())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.selectedCategories.contains(.videos) {
|
|
let deselectedVideos = self.allVideos.subtracting(self.selectionState.selectedMessages)
|
|
if !deselectedVideos.isEmpty, let imageItems = self.imageItems {
|
|
for item in imageItems.items {
|
|
if deselectedVideos.contains(item.message.id) {
|
|
selectedSize -= item.size
|
|
clearExcludeMessages.append(item.message._asMessage())
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let selectedVideos = self.allVideos.intersection(self.selectionState.selectedMessages)
|
|
if !selectedVideos.isEmpty, let imageItems = self.imageItems {
|
|
for item in imageItems.items {
|
|
if selectedVideos.contains(item.message.id) {
|
|
selectedSize += item.size
|
|
clearIncludeMessages.append(item.message._asMessage())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.selectedCategories.contains(.files) {
|
|
let deselectedFiles = self.allFiles.subtracting(self.selectionState.selectedMessages)
|
|
if !deselectedFiles.isEmpty, let fileItems = self.fileItems {
|
|
for item in fileItems.items {
|
|
if deselectedFiles.contains(item.message.id) {
|
|
selectedSize -= item.size
|
|
clearExcludeMessages.append(item.message)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let selectedFiles = self.allFiles.intersection(self.selectionState.selectedMessages)
|
|
if !selectedFiles.isEmpty, let fileItems = self.fileItems {
|
|
for item in fileItems.items {
|
|
if selectedFiles.contains(item.message.id) {
|
|
selectedSize += item.size
|
|
clearIncludeMessages.append(item.message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.selectedCategories.contains(.music) {
|
|
let deselectedMusic = self.allMusic.subtracting(self.selectionState.selectedMessages)
|
|
if !deselectedMusic.isEmpty, let musicItems = self.musicItems {
|
|
for item in musicItems.items {
|
|
if deselectedMusic.contains(item.message.id) {
|
|
selectedSize -= item.size
|
|
clearExcludeMessages.append(item.message)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let selectedMusic = self.allMusic.intersection(self.selectionState.selectedMessages)
|
|
if !selectedMusic.isEmpty, let musicItems = self.musicItems {
|
|
for item in musicItems.items {
|
|
if selectedMusic.contains(item.message.id) {
|
|
selectedSize += item.size
|
|
clearIncludeMessages.append(item.message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.selectedSize = selectedSize
|
|
self.clearExcludeMessages = clearExcludeMessages
|
|
self.clearIncludeMessages = clearIncludeMessages
|
|
} else {
|
|
var selectedSize: Int64 = 0
|
|
|
|
for peerId in self.selectionState.selectedPeers {
|
|
if let stats = self.stats.peers[peerId] {
|
|
let peerSize = stats.stats.categories.values.reduce(0, {
|
|
$0 + $1.size
|
|
})
|
|
selectedSize += peerSize
|
|
|
|
for (messageId, _) in self.messages {
|
|
if messageId.peerId == peerId {
|
|
if !self.selectionState.selectedMessages.contains(messageId) {
|
|
inner: for (_, category) in stats.stats.categories {
|
|
if let messageSize = category.messages[messageId] {
|
|
selectedSize -= messageSize
|
|
break inner
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for messageId in self.selectionState.selectedMessages {
|
|
for (_, category) in self.contextStats.categories {
|
|
if let messageSize = category.messages[messageId] {
|
|
if !self.selectionState.selectedPeers.contains(messageId.peerId) {
|
|
selectedSize += messageSize
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
self.selectedSize = selectedSize
|
|
self.clearIncludeMessages = []
|
|
self.clearExcludeMessages = []
|
|
}
|
|
}
|
|
}
|
|
|
|
class View: UIView, UIScrollViewDelegate {
|
|
private let scrollView: ScrollViewImpl
|
|
|
|
private var aggregatedData: AggregatedData?
|
|
private var otherCategories: Set<Category> = Set()
|
|
|
|
private var cacheSettings: CacheStorageSettings?
|
|
private var cacheSettingsExceptionCount: [CacheStorageSettings.PeerStorageCategory: Int32]?
|
|
|
|
private var currentSelectedPanelId: AnyHashable?
|
|
|
|
private var clearingDisplayTimestamp: Double?
|
|
private var isClearing: Bool = false {
|
|
didSet {
|
|
if self.isClearing != oldValue {
|
|
if self.isClearing {
|
|
if self.keepScreenActiveDisposable == nil {
|
|
self.keepScreenActiveDisposable = self.component?.context.sharedContext.applicationBindings.pushIdleTimerExtension()
|
|
}
|
|
} else {
|
|
if let keepScreenActiveDisposable = self.keepScreenActiveDisposable {
|
|
self.keepScreenActiveDisposable = nil
|
|
keepScreenActiveDisposable.dispose()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var isOtherCategoryExpanded: Bool = false
|
|
|
|
private let navigationBackgroundView: BlurredBackgroundView
|
|
private let navigationSeparatorLayer: SimpleLayer
|
|
private let navigationSeparatorLayerContainer: SimpleLayer
|
|
private let navigationEditButton = ComponentView<Empty>()
|
|
private let navigationDoneButton = ComponentView<Empty>()
|
|
|
|
private let headerView = ComponentView<Empty>()
|
|
private let headerOffsetContainer: UIView
|
|
private let headerDescriptionView = ComponentView<Empty>()
|
|
|
|
private let headerProgressBackgroundLayer: SimpleLayer
|
|
private let headerProgressForegroundLayer: SimpleLayer
|
|
|
|
private var chartAvatarNode: AvatarNode?
|
|
|
|
private var doneStatusCircle: SimpleShapeLayer?
|
|
private var doneStatusNode: RadialStatusNode?
|
|
|
|
private let scrollContainerView: UIView
|
|
|
|
private let pieChartView = ComponentView<Empty>()
|
|
private let chartTotalLabel = ComponentView<Empty>()
|
|
private let categoriesView = ComponentView<Empty>()
|
|
private let categoriesDescriptionView = ComponentView<Empty>()
|
|
|
|
private let keepDurationTitleView = ComponentView<Empty>()
|
|
private let keepDurationDescriptionView = ComponentView<Empty>()
|
|
private var keepDurationSectionContainerView: UIView
|
|
private var keepDurationItems: [AnyHashable: ComponentView<Empty>] = [:]
|
|
|
|
private let keepSizeTitleView = ComponentView<Empty>()
|
|
private let keepSizeView = ComponentView<Empty>()
|
|
private let keepSizeDescriptionView = ComponentView<Empty>()
|
|
|
|
private let panelContainer = ComponentView<StorageUsagePanelContainerEnvironment>()
|
|
|
|
private var selectionPanel: ComponentView<Empty>?
|
|
|
|
private var clearingNode: StorageUsageClearProgressOverlayNode?
|
|
|
|
private var loadingView: UIActivityIndicatorView?
|
|
|
|
private var component: StorageUsageScreenComponent?
|
|
private weak var state: EmptyComponentState?
|
|
private var navigationMetrics: (navigationHeight: CGFloat, statusBarHeight: CGFloat)?
|
|
private var controller: (() -> ViewController?)?
|
|
|
|
private var enableVelocityTracking: Bool = false
|
|
private var previousVelocityM1: CGFloat = 0.0
|
|
private var previousVelocity: CGFloat = 0.0
|
|
|
|
private var ignoreScrolling: Bool = false
|
|
|
|
private var statsDisposable: Disposable?
|
|
private var messagesDisposable: Disposable?
|
|
private var cacheSettingsDisposable: Disposable?
|
|
private var keepScreenActiveDisposable: Disposable?
|
|
|
|
override init(frame: CGRect) {
|
|
self.headerOffsetContainer = UIView()
|
|
self.headerOffsetContainer.isUserInteractionEnabled = false
|
|
|
|
self.navigationBackgroundView = BlurredBackgroundView(color: nil, enableBlur: true)
|
|
self.navigationBackgroundView.alpha = 0.0
|
|
|
|
self.navigationSeparatorLayer = SimpleLayer()
|
|
self.navigationSeparatorLayer.opacity = 0.0
|
|
self.navigationSeparatorLayerContainer = SimpleLayer()
|
|
self.navigationSeparatorLayerContainer.opacity = 0.0
|
|
|
|
self.scrollContainerView = UIView()
|
|
|
|
self.scrollView = ScrollViewImpl()
|
|
|
|
self.keepDurationSectionContainerView = UIView()
|
|
self.keepDurationSectionContainerView.clipsToBounds = true
|
|
self.keepDurationSectionContainerView.layer.cornerRadius = 10.0
|
|
|
|
self.headerProgressBackgroundLayer = SimpleLayer()
|
|
self.headerProgressForegroundLayer = SimpleLayer()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.scrollView.delaysContentTouches = true
|
|
self.scrollView.canCancelContentTouches = true
|
|
self.scrollView.clipsToBounds = false
|
|
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
|
|
self.scrollView.contentInsetAdjustmentBehavior = .never
|
|
}
|
|
if #available(iOS 13.0, *) {
|
|
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
|
}
|
|
self.scrollView.showsVerticalScrollIndicator = false
|
|
self.scrollView.showsHorizontalScrollIndicator = false
|
|
self.scrollView.alwaysBounceHorizontal = false
|
|
self.scrollView.scrollsToTop = false
|
|
self.scrollView.delegate = self
|
|
self.scrollView.clipsToBounds = true
|
|
self.addSubview(self.scrollView)
|
|
|
|
self.scrollView.addSubview(self.scrollContainerView)
|
|
|
|
self.scrollContainerView.addSubview(self.keepDurationSectionContainerView)
|
|
|
|
self.scrollView.layer.addSublayer(self.headerProgressBackgroundLayer)
|
|
self.scrollView.layer.addSublayer(self.headerProgressForegroundLayer)
|
|
|
|
self.addSubview(self.navigationBackgroundView)
|
|
|
|
self.navigationSeparatorLayerContainer.addSublayer(self.navigationSeparatorLayer)
|
|
self.layer.addSublayer(self.navigationSeparatorLayerContainer)
|
|
|
|
self.addSubview(self.headerOffsetContainer)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.statsDisposable?.dispose()
|
|
self.messagesDisposable?.dispose()
|
|
self.keepScreenActiveDisposable?.dispose()
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
guard let result = super.hitTest(point, with: event) else {
|
|
return nil
|
|
}
|
|
var currentParent: UIView? = result
|
|
while true {
|
|
if currentParent == nil || currentParent === self {
|
|
break
|
|
}
|
|
if let scrollView = currentParent as? UIScrollView {
|
|
if scrollView === self.scrollView {
|
|
break
|
|
}
|
|
if scrollView.isDecelerating && scrollView.contentOffset.y < -scrollView.contentInset.top {
|
|
return self.scrollView
|
|
}
|
|
}
|
|
currentParent = currentParent?.superview
|
|
}
|
|
return result
|
|
}
|
|
|
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
self.enableVelocityTracking = true
|
|
}
|
|
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
if !self.ignoreScrolling {
|
|
if self.enableVelocityTracking {
|
|
self.previousVelocityM1 = self.previousVelocity
|
|
if let value = (scrollView.value(forKey: (["_", "verticalVelocity"] as [String]).joined()) as? NSNumber)?.doubleValue {
|
|
self.previousVelocity = CGFloat(value)
|
|
}
|
|
}
|
|
|
|
self.updateScrolling(transition: .immediate)
|
|
}
|
|
}
|
|
|
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
|
guard let _ = self.navigationMetrics else {
|
|
return
|
|
}
|
|
|
|
let paneAreaExpansionDistance: CGFloat = 32.0
|
|
let paneAreaExpansionFinalPoint: CGFloat = scrollView.contentSize.height - scrollView.bounds.height
|
|
if targetContentOffset.pointee.y > paneAreaExpansionFinalPoint - paneAreaExpansionDistance && targetContentOffset.pointee.y < paneAreaExpansionFinalPoint {
|
|
targetContentOffset.pointee.y = paneAreaExpansionFinalPoint
|
|
self.enableVelocityTracking = false
|
|
self.previousVelocity = 0.0
|
|
self.previousVelocityM1 = 0.0
|
|
}
|
|
}
|
|
|
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View {
|
|
let _ = panelContainerView
|
|
let paneAreaExpansionFinalPoint: CGFloat = scrollView.contentSize.height - scrollView.bounds.height
|
|
if abs(scrollView.contentOffset.y - paneAreaExpansionFinalPoint) < .ulpOfOne {
|
|
//panelContainerView.transferVelocity(self.previousVelocityM1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateScrolling(transition: ComponentTransition) {
|
|
let scrollBounds = self.scrollView.bounds
|
|
|
|
let isLockedAtPanels = scrollBounds.maxY == self.scrollView.contentSize.height
|
|
|
|
if let headerView = self.headerView.view, let navigationMetrics = self.navigationMetrics {
|
|
var headerOffset: CGFloat = scrollBounds.minY
|
|
|
|
let minY = navigationMetrics.statusBarHeight + floor((navigationMetrics.navigationHeight - navigationMetrics.statusBarHeight) / 2.0)
|
|
|
|
let minOffset = headerView.center.y - minY
|
|
|
|
headerOffset = min(headerOffset, minOffset)
|
|
|
|
let animatedTransition = ComponentTransition(animation: .curve(duration: 0.18, curve: .easeInOut))
|
|
let navigationBackgroundAlpha: CGFloat = abs(headerOffset - minOffset) < 4.0 ? 1.0 : 0.0
|
|
|
|
animatedTransition.setAlpha(view: self.navigationBackgroundView, alpha: navigationBackgroundAlpha)
|
|
animatedTransition.setAlpha(layer: self.navigationSeparatorLayerContainer, alpha: navigationBackgroundAlpha)
|
|
|
|
var buttonsMasterAlpha: CGFloat = 1.0
|
|
if let component = self.component, component.peer != nil {
|
|
buttonsMasterAlpha = 0.0
|
|
} else {
|
|
if self.currentSelectedPanelId == nil || self.currentSelectedPanelId == AnyHashable("peers") {
|
|
buttonsMasterAlpha = 1.0
|
|
} else {
|
|
buttonsMasterAlpha = 0.0
|
|
}
|
|
}
|
|
|
|
let isSelectingPeers = self.aggregatedData?.isSelectingPeers ?? false
|
|
|
|
if let navigationEditButtonView = self.navigationEditButton.view {
|
|
animatedTransition.setAlpha(view: navigationEditButtonView, alpha: (isSelectingPeers ? 0.0 : 1.0) * buttonsMasterAlpha * navigationBackgroundAlpha)
|
|
}
|
|
if let navigationDoneButtonView = self.navigationDoneButton.view {
|
|
animatedTransition.setAlpha(view: navigationDoneButtonView, alpha: (isSelectingPeers ? 1.0 : 0.0) * buttonsMasterAlpha * navigationBackgroundAlpha)
|
|
}
|
|
|
|
let expansionDistance: CGFloat = 32.0
|
|
var expansionDistanceFactor: CGFloat = abs(scrollBounds.maxY - self.scrollView.contentSize.height) / expansionDistance
|
|
expansionDistanceFactor = max(0.0, min(1.0, expansionDistanceFactor))
|
|
|
|
transition.setAlpha(layer: self.navigationSeparatorLayer, alpha: expansionDistanceFactor)
|
|
if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View {
|
|
panelContainerView.updateNavigationMergeFactor(value: 1.0 - expansionDistanceFactor, transition: transition)
|
|
}
|
|
|
|
var offsetFraction: CGFloat = abs(headerOffset - minOffset) / 60.0
|
|
offsetFraction = min(1.0, max(0.0, offsetFraction))
|
|
transition.setScale(view: headerView, scale: 1.0 * offsetFraction + 0.8 * (1.0 - offsetFraction))
|
|
|
|
transition.setBounds(view: self.headerOffsetContainer, bounds: CGRect(origin: CGPoint(x: 0.0, y: headerOffset), size: self.headerOffsetContainer.bounds.size))
|
|
}
|
|
|
|
let _ = self.panelContainer.updateEnvironment(
|
|
transition: transition,
|
|
environment: {
|
|
StorageUsagePanelContainerEnvironment(isScrollable: isLockedAtPanels)
|
|
}
|
|
)
|
|
}
|
|
|
|
func update(component: StorageUsageScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
|
|
self.component = component
|
|
self.state = state
|
|
|
|
let environment = environment[ViewControllerComponentContainer.Environment.self].value
|
|
|
|
if self.aggregatedData == nil {
|
|
let loadingView: UIActivityIndicatorView
|
|
if let current = self.loadingView {
|
|
loadingView = current
|
|
} else {
|
|
let style: UIActivityIndicatorView.Style
|
|
if environment.theme.overallDarkAppearance {
|
|
style = .whiteLarge
|
|
} else {
|
|
if #available(iOS 13.0, *) {
|
|
style = .large
|
|
} else {
|
|
style = .gray
|
|
}
|
|
}
|
|
loadingView = UIActivityIndicatorView(style: style)
|
|
self.loadingView = loadingView
|
|
loadingView.sizeToFit()
|
|
self.insertSubview(loadingView, belowSubview: self.scrollView)
|
|
}
|
|
let loadingViewSize = loadingView.bounds.size
|
|
transition.setFrame(view: loadingView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - loadingViewSize.width) / 2.0), y: floor((availableSize.height - loadingViewSize.height) / 2.0)), size: loadingViewSize))
|
|
if !loadingView.isAnimating {
|
|
loadingView.startAnimating()
|
|
}
|
|
} else {
|
|
if let loadingView = self.loadingView {
|
|
self.loadingView = nil
|
|
if environment.isVisible {
|
|
loadingView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak loadingView] _ in
|
|
loadingView?.removeFromSuperview()
|
|
})
|
|
} else {
|
|
loadingView.removeFromSuperview()
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.statsDisposable == nil {
|
|
let context = component.context
|
|
let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings]))
|
|
let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = component.context.account.postbox.combinedView(keys: [viewKey])
|
|
|> map { views -> AccountSpecificCacheStorageSettings in
|
|
let cacheSettings: AccountSpecificCacheStorageSettings
|
|
if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) {
|
|
cacheSettings = value
|
|
} else {
|
|
cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings
|
|
}
|
|
|
|
return cacheSettings
|
|
}
|
|
|> distinctUntilChanged
|
|
|> mapToSignal { accountSpecificSettings -> Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> in
|
|
return context.engine.data.get(
|
|
EngineDataMap(accountSpecificSettings.peerStorageTimeoutExceptions.map(\.key).map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)))
|
|
)
|
|
|> map { peers -> [CacheStorageSettings.PeerStorageCategory: Int32] in
|
|
var result: [CacheStorageSettings.PeerStorageCategory: Int32] = [:]
|
|
|
|
for (_, peer) in peers {
|
|
guard let peer else {
|
|
continue
|
|
}
|
|
switch peer {
|
|
case .user, .secretChat:
|
|
result[.privateChats, default: 0] += 1
|
|
case .legacyGroup:
|
|
result[.groups, default: 0] += 1
|
|
case let .channel(channel):
|
|
if case .group = channel.info {
|
|
result[.groups, default: 0] += 1
|
|
} else {
|
|
result[.channels, default: 0] += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|
|
|
|
self.cacheSettingsDisposable = (combineLatest(queue: .mainQueue(),
|
|
component.context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings])
|
|
|> map { sharedData -> CacheStorageSettings in
|
|
let cacheSettings: CacheStorageSettings
|
|
if let value = sharedData.entries[SharedDataKeys.cacheStorageSettings]?.get(CacheStorageSettings.self) {
|
|
cacheSettings = value
|
|
} else {
|
|
cacheSettings = CacheStorageSettings.defaultSettings
|
|
}
|
|
|
|
return cacheSettings
|
|
},
|
|
cacheSettingsExceptionCount
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] cacheSettings, cacheSettingsExceptionCount in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.cacheSettings = cacheSettings
|
|
self.cacheSettingsExceptionCount = cacheSettingsExceptionCount
|
|
if self.aggregatedData != nil {
|
|
self.state?.updated(transition: .immediate)
|
|
}
|
|
})
|
|
|
|
self.reloadStats(firstTime: true, completion: {})
|
|
}
|
|
|
|
var wasLockedAtPanels = false
|
|
if let panelContainerView = self.panelContainer.view, let navigationMetrics = self.navigationMetrics {
|
|
if self.scrollView.bounds.minY > 0.0 && abs(self.scrollView.bounds.minY - (panelContainerView.frame.minY - navigationMetrics.navigationHeight)) <= UIScreenPixel {
|
|
wasLockedAtPanels = true
|
|
}
|
|
}
|
|
|
|
let animationHint = transition.userData(AnimationHint.self)
|
|
|
|
if let animationHint {
|
|
if case .firstStatsUpdate = animationHint.value {
|
|
let alphaTransition: ComponentTransition
|
|
if environment.isVisible {
|
|
alphaTransition = .easeInOut(duration: 0.25)
|
|
} else {
|
|
alphaTransition = .immediate
|
|
}
|
|
alphaTransition.setAlpha(view: self.scrollView, alpha: self.aggregatedData != nil ? 1.0 : 0.0)
|
|
alphaTransition.setAlpha(view: self.headerOffsetContainer, alpha: self.aggregatedData != nil ? 1.0 : 0.0)
|
|
} else if case .clearedItems = animationHint.value {
|
|
if let snapshotView = self.scrollContainerView.snapshotView(afterScreenUpdates: false) {
|
|
snapshotView.frame = self.scrollContainerView.frame
|
|
self.scrollView.insertSubview(snapshotView, aboveSubview: self.scrollContainerView)
|
|
self.scrollContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
|
snapshotView?.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
transition.setAlpha(view: self.scrollView, alpha: self.aggregatedData != nil ? 1.0 : 0.0)
|
|
transition.setAlpha(view: self.headerOffsetContainer, alpha: self.aggregatedData != nil ? 1.0 : 0.0)
|
|
}
|
|
|
|
self.controller = environment.controller
|
|
|
|
self.navigationMetrics = (environment.navigationHeight, environment.statusBarHeight)
|
|
|
|
self.navigationSeparatorLayer.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor
|
|
|
|
let navigationFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: environment.navigationHeight))
|
|
self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate)
|
|
self.navigationBackgroundView.update(size: navigationFrame.size, transition: transition.containedViewLayoutTransition)
|
|
transition.setFrame(view: self.navigationBackgroundView, frame: navigationFrame)
|
|
|
|
let navigationSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationFrame.maxY), size: CGSize(width: availableSize.width, height: UIScreenPixel))
|
|
|
|
transition.setFrame(layer: self.navigationSeparatorLayerContainer, frame: navigationSeparatorFrame)
|
|
transition.setFrame(layer: self.navigationSeparatorLayer, frame: CGRect(origin: CGPoint(), size: navigationSeparatorFrame.size))
|
|
|
|
let navigationEditButtonSize = self.navigationEditButton.update(
|
|
transition: transition,
|
|
component: AnyComponent(Button(
|
|
content: AnyComponent(Text(text: environment.strings.Common_Edit, font: Font.regular(17.0), color: environment.theme.rootController.navigationBar.accentTextColor)),
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if let aggregatedData = self.aggregatedData, !aggregatedData.isSelectingPeers {
|
|
aggregatedData.isSelectingPeers = true
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
}
|
|
}
|
|
).minSize(CGSize(width: 16.0, height: environment.navigationHeight - environment.statusBarHeight))),
|
|
environment: {},
|
|
containerSize: CGSize(width: 150.0, height: environment.navigationHeight - environment.statusBarHeight)
|
|
)
|
|
if let navigationEditButtonView = self.navigationEditButton.view {
|
|
if navigationEditButtonView.superview == nil {
|
|
self.addSubview(navigationEditButtonView)
|
|
}
|
|
transition.setFrame(view: navigationEditButtonView, frame: CGRect(origin: CGPoint(x: availableSize.width - 12.0 - environment.safeInsets.right - navigationEditButtonSize.width, y: environment.statusBarHeight), size: navigationEditButtonSize))
|
|
}
|
|
|
|
let navigationDoneButtonSize = self.navigationDoneButton.update(
|
|
transition: transition,
|
|
component: AnyComponent(Button(
|
|
content: AnyComponent(Text(text: environment.strings.Common_Done, font: Font.semibold(17.0), color: environment.theme.rootController.navigationBar.accentTextColor)),
|
|
action: { [weak self] in
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
aggregatedData.isSelectingPeers = false
|
|
aggregatedData.clearPeerSelection()
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
}
|
|
).minSize(CGSize(width: 16.0, height: environment.navigationHeight - environment.statusBarHeight))),
|
|
environment: {},
|
|
containerSize: CGSize(width: 150.0, height: environment.navigationHeight - environment.statusBarHeight)
|
|
)
|
|
if let navigationDoneButtonView = self.navigationDoneButton.view {
|
|
if navigationDoneButtonView.superview == nil {
|
|
self.addSubview(navigationDoneButtonView)
|
|
}
|
|
transition.setFrame(view: navigationDoneButtonView, frame: CGRect(origin: CGPoint(x: availableSize.width - 12.0 - environment.safeInsets.right - navigationDoneButtonSize.width, y: environment.statusBarHeight), size: navigationDoneButtonSize))
|
|
}
|
|
|
|
let navigationRightButtonMaxWidth: CGFloat = max(navigationEditButtonSize.width, navigationDoneButtonSize.width)
|
|
|
|
self.backgroundColor = environment.theme.list.blocksBackgroundColor
|
|
|
|
var contentHeight: CGFloat = 0.0
|
|
|
|
let topInset: CGFloat = 19.0
|
|
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
|
|
|
|
var bottomInset: CGFloat = environment.safeInsets.bottom
|
|
|
|
var bottomPanelSelectionData: (size: Int64, isComplete: Bool)?
|
|
if let aggregatedData = self.aggregatedData {
|
|
if let _ = component.peer {
|
|
bottomPanelSelectionData = (aggregatedData.selectedSize, true)
|
|
} else if !aggregatedData.selectionState.isEmpty {
|
|
bottomPanelSelectionData = (aggregatedData.selectedSize, false)
|
|
}
|
|
}
|
|
|
|
if let bottomPanelSelectionData {
|
|
let selectionPanel: ComponentView<Empty>
|
|
var selectionPanelTransition = transition
|
|
if let current = self.selectionPanel {
|
|
selectionPanel = current
|
|
} else {
|
|
selectionPanelTransition = .immediate
|
|
selectionPanel = ComponentView()
|
|
self.selectionPanel = selectionPanel
|
|
}
|
|
|
|
let selectionPanelSize = selectionPanel.update(
|
|
transition: selectionPanelTransition,
|
|
component: AnyComponent(BottomButtonPanelComponent(
|
|
theme: environment.theme,
|
|
title: bottomPanelSelectionData.isComplete ? environment.strings.StorageManagement_ClearCache : environment.strings.StorageManagement_ClearSelected,
|
|
label: bottomPanelSelectionData.size == 0 ? nil : dataSizeString(Int(bottomPanelSelectionData.size), formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")),
|
|
isEnabled: bottomPanelSelectionData.size != 0,
|
|
insets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: environment.safeInsets.bottom, right: sideInset),
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.requestClear(fromCategories: false)
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: availableSize
|
|
)
|
|
if let selectionPanelView = selectionPanel.view {
|
|
var animateIn = false
|
|
if selectionPanelView.superview == nil {
|
|
self.addSubview(selectionPanelView)
|
|
animateIn = true
|
|
}
|
|
selectionPanelTransition.setFrame(view: selectionPanelView, frame: CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - selectionPanelSize.height), size: selectionPanelSize))
|
|
if animateIn {
|
|
transition.animatePosition(view: selectionPanelView, from: CGPoint(x: 0.0, y: selectionPanelSize.height), to: CGPoint(), additive: true)
|
|
}
|
|
}
|
|
bottomInset = selectionPanelSize.height
|
|
} else if let selectionPanel = self.selectionPanel {
|
|
self.selectionPanel = nil
|
|
if let selectionPanelView = selectionPanel.view {
|
|
transition.setPosition(view: selectionPanelView, position: CGPoint(x: selectionPanelView.center.x, y: availableSize.height + selectionPanelView.bounds.height * 0.5), completion: { [weak selectionPanelView] _ in
|
|
selectionPanelView?.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
|
|
contentHeight += environment.statusBarHeight + topInset
|
|
|
|
let allCategories: [Category] = [
|
|
.photos,
|
|
.videos,
|
|
.files,
|
|
.music,
|
|
.stickers,
|
|
.avatars,
|
|
.misc,
|
|
.stories
|
|
]
|
|
|
|
var listCategories: [StorageCategoriesComponent.CategoryData] = []
|
|
|
|
var totalSize: Int64 = 0
|
|
var totalSelectedCategorySize: Int64 = 0
|
|
if let aggregatedData = self.aggregatedData {
|
|
for (key, value) in aggregatedData.contextStats.categories {
|
|
totalSize += value.size
|
|
if aggregatedData.selectedCategories.isEmpty || aggregatedData.selectedCategories.contains(Category(key)) {
|
|
totalSelectedCategorySize += value.size
|
|
}
|
|
}
|
|
|
|
for category in allCategories {
|
|
let mappedCategory: StorageUsageStats.CategoryKey
|
|
switch category {
|
|
case .photos:
|
|
mappedCategory = .photos
|
|
case .videos:
|
|
mappedCategory = .videos
|
|
case .files:
|
|
mappedCategory = .files
|
|
case .music:
|
|
mappedCategory = .music
|
|
case .stickers:
|
|
mappedCategory = .stickers
|
|
case .avatars:
|
|
mappedCategory = .avatars
|
|
case .misc:
|
|
mappedCategory = .misc
|
|
case .stories:
|
|
mappedCategory = .stories
|
|
case .other:
|
|
continue
|
|
}
|
|
|
|
var categorySize: Int64 = 0
|
|
if let categoryData = aggregatedData.contextStats.categories[mappedCategory] {
|
|
categorySize = categoryData.size
|
|
}
|
|
|
|
let categoryFraction: Double
|
|
if !aggregatedData.selectedCategories.isEmpty && !aggregatedData.selectedCategories.contains(category) {
|
|
categoryFraction = 0.0
|
|
} else if categorySize == 0 || totalSelectedCategorySize == 0 {
|
|
categoryFraction = 0.0
|
|
} else {
|
|
categoryFraction = Double(categorySize) / Double(totalSelectedCategorySize)
|
|
}
|
|
|
|
if categorySize != 0 {
|
|
listCategories.append(StorageCategoriesComponent.CategoryData(
|
|
key: category, color: category.color, title: category.title(strings: environment.strings), size: categorySize, sizeFraction: categoryFraction, isSelected: aggregatedData.selectedCategories.contains(category), subcategories: []))
|
|
}
|
|
}
|
|
}
|
|
|
|
listCategories.sort(by: { $0.size > $1.size })
|
|
|
|
var otherListCategories: [StorageCategoriesComponent.CategoryData] = []
|
|
if listCategories.count > 5 {
|
|
for i in (4 ..< listCategories.count).reversed() {
|
|
otherListCategories.insert(listCategories[i], at: 0)
|
|
listCategories.remove(at: i)
|
|
}
|
|
}
|
|
self.otherCategories = Set(otherListCategories.map(\.key))
|
|
|
|
var chartItems: [PieChartComponent.ChartData.Item] = []
|
|
for listCategory in listCategories {
|
|
var categoryChartFraction: CGFloat = listCategory.sizeFraction
|
|
if let aggregatedData = self.aggregatedData, !aggregatedData.selectedCategories.isEmpty && !aggregatedData.selectedCategories.contains(listCategory.key) {
|
|
categoryChartFraction = 0.0
|
|
}
|
|
chartItems.append(PieChartComponent.ChartData.Item(id: listCategory.key, displayValue: listCategory.sizeFraction, displaySize: listCategory.size, value: categoryChartFraction, color: listCategory.color, particle: listCategory.key.particle, title: listCategory.key.title(strings: environment.strings), mergeable: false, mergeFactor: 1.0))
|
|
}
|
|
|
|
var totalOtherSize: Int64 = 0
|
|
for listCategory in otherListCategories {
|
|
totalOtherSize += listCategory.size
|
|
}
|
|
|
|
if !otherListCategories.isEmpty {
|
|
let categoryFraction: Double = otherListCategories.reduce(0.0, { $0 + $1.sizeFraction })
|
|
let isSelected = otherListCategories.allSatisfy { item in
|
|
return self.aggregatedData?.selectedCategories.contains(item.key) ?? false
|
|
}
|
|
|
|
let listColor: UIColor
|
|
if self.isOtherCategoryExpanded {
|
|
listColor = Category.other.color
|
|
} else {
|
|
listColor = Category.misc.color
|
|
}
|
|
|
|
listCategories.append(StorageCategoriesComponent.CategoryData(
|
|
key: Category.other, color: listColor, title: Category.other.title(strings: environment.strings), size: totalOtherSize, sizeFraction: categoryFraction, isSelected: isSelected, subcategories: otherListCategories))
|
|
}
|
|
|
|
var otherSum: CGFloat = 0.0
|
|
var otherRealSum: CGFloat = 0.0
|
|
for listCategory in otherListCategories {
|
|
var categoryChartFraction: CGFloat = listCategory.sizeFraction
|
|
if let aggregatedData = self.aggregatedData, !aggregatedData.selectedCategories.isEmpty, !aggregatedData.selectedCategories.contains(listCategory.key) {
|
|
categoryChartFraction = 0.0
|
|
}
|
|
|
|
var chartItem = PieChartComponent.ChartData.Item(id: listCategory.key, displayValue: listCategory.sizeFraction, displaySize: listCategory.size, value: categoryChartFraction, color: listCategory.color, particle: listCategory.key.particle, title: listCategory.key.title(strings: environment.strings), mergeable: false, mergeFactor: 1.0)
|
|
|
|
if chartItem.value > 0.00001 {
|
|
chartItem.value = max(chartItem.value, 0.01)
|
|
}
|
|
otherSum += chartItem.value
|
|
otherRealSum += chartItem.displayValue
|
|
|
|
if !self.isOtherCategoryExpanded {
|
|
chartItem.value = 0.0
|
|
}
|
|
|
|
chartItems.append(chartItem)
|
|
}
|
|
|
|
if !listCategories.isEmpty {
|
|
chartItems.append(PieChartComponent.ChartData.Item(id: AnyHashable(Category.other), displayValue: otherRealSum, displaySize: totalOtherSize, value: self.isOtherCategoryExpanded ? 0.0 : otherSum, color: Category.misc.color, particle: Category.misc.particle, title: Category.misc.title(strings: environment.strings), mergeable: false, mergeFactor: 1.0))
|
|
}
|
|
|
|
let chartData = PieChartComponent.ChartData(items: chartItems)
|
|
self.pieChartView.parentState = state
|
|
|
|
var pieChartTransition = transition
|
|
if transition.animation.isImmediate, let animationHint, case .clearedItems = animationHint.value {
|
|
pieChartTransition = ComponentTransition(animation: .curve(duration: 0.4, curve: .spring))
|
|
}
|
|
|
|
let pieChartSize = self.pieChartView.update(
|
|
transition: pieChartTransition,
|
|
component: AnyComponent(PieChartComponent(
|
|
theme: environment.theme,
|
|
strings: environment.strings,
|
|
emptyColor: UIColor(rgb: 0x34C759),
|
|
chartData: chartData
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width, height: 60.0)
|
|
)
|
|
let pieChartFrame = CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: pieChartSize)
|
|
if let pieChartComponentView = self.pieChartView.view {
|
|
if pieChartComponentView.superview == nil {
|
|
self.scrollView.addSubview(pieChartComponentView)
|
|
}
|
|
|
|
pieChartTransition.setFrame(view: pieChartComponentView, frame: pieChartFrame)
|
|
}
|
|
if let _ = self.aggregatedData, listCategories.isEmpty {
|
|
let checkColor = UIColor(rgb: 0x34C759)
|
|
|
|
let doneStatusNode: RadialStatusNode
|
|
var animateIn = false
|
|
if let current = self.doneStatusNode {
|
|
doneStatusNode = current
|
|
} else {
|
|
doneStatusNode = RadialStatusNode(backgroundNodeColor: .clear)
|
|
self.doneStatusNode = doneStatusNode
|
|
self.scrollView.addSubnode(doneStatusNode)
|
|
animateIn = true
|
|
}
|
|
let doneSize = CGSize(width: 100.0, height: 100.0)
|
|
doneStatusNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - doneSize.width) / 2.0), y: contentHeight), size: doneSize)
|
|
|
|
let doneStatusCircle: SimpleShapeLayer
|
|
if let current = self.doneStatusCircle {
|
|
doneStatusCircle = current
|
|
} else {
|
|
doneStatusCircle = SimpleShapeLayer()
|
|
self.doneStatusCircle = doneStatusCircle
|
|
//self.scrollView.layer.addSublayer(doneStatusCircle)
|
|
doneStatusCircle.opacity = 0.0
|
|
}
|
|
|
|
if animateIn {
|
|
Queue.mainQueue().after(0.18, {
|
|
doneStatusNode.transitionToState(.check(checkColor), animated: true)
|
|
doneStatusCircle.opacity = 1.0
|
|
doneStatusCircle.animateAlpha(from: 0.0, to: 1.0, duration: 0.12)
|
|
})
|
|
}
|
|
|
|
doneStatusCircle.lineWidth = 6.0
|
|
doneStatusCircle.strokeColor = checkColor.cgColor
|
|
doneStatusCircle.fillColor = nil
|
|
doneStatusCircle.path = UIBezierPath(ovalIn: CGRect(origin: CGPoint(x: doneStatusCircle.lineWidth * 0.5, y: doneStatusCircle.lineWidth * 0.5), size: CGSize(width: doneSize.width - doneStatusCircle.lineWidth * 0.5, height: doneSize.height - doneStatusCircle.lineWidth * 0.5))).cgPath
|
|
|
|
doneStatusCircle.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - doneSize.width) / 2.0), y: contentHeight), size: doneSize).insetBy(dx: -doneStatusCircle.lineWidth * 0.5, dy: -doneStatusCircle.lineWidth * 0.5)
|
|
|
|
contentHeight += doneSize.height
|
|
} else {
|
|
contentHeight += pieChartSize.height
|
|
|
|
if let doneStatusNode = self.doneStatusNode {
|
|
self.doneStatusNode = nil
|
|
doneStatusNode.removeFromSupernode()
|
|
}
|
|
if let doneStatusCircle = self.doneStatusCircle {
|
|
self.doneStatusCircle = nil
|
|
doneStatusCircle.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
contentHeight += 23.0
|
|
|
|
let headerText: String
|
|
if listCategories.isEmpty {
|
|
headerText = environment.strings.StorageManagement_TitleCleared
|
|
} else if let peer = component.peer {
|
|
if peer.id == component.context.account.peerId {
|
|
headerText = environment.strings.DialogList_SavedMessages
|
|
} else {
|
|
headerText = peer.displayTitle(strings: environment.strings, displayOrder: .firstLast)
|
|
}
|
|
} else {
|
|
headerText = environment.strings.StorageManagement_Title
|
|
}
|
|
let headerViewSize = self.headerView.update(
|
|
transition: transition,
|
|
component: AnyComponent(Text(text: headerText, font: Font.semibold(20.0), color: environment.theme.list.itemPrimaryTextColor)),
|
|
environment: {},
|
|
containerSize: CGSize(width: floor((availableSize.width - navigationRightButtonMaxWidth * 2.0) / 0.8), height: 100.0)
|
|
)
|
|
let headerViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerViewSize.width) / 2.0), y: contentHeight), size: headerViewSize)
|
|
if let headerComponentView = self.headerView.view {
|
|
if headerComponentView.superview == nil {
|
|
self.headerOffsetContainer.addSubview(headerComponentView)
|
|
}
|
|
transition.setPosition(view: headerComponentView, position: headerViewFrame.center)
|
|
transition.setBounds(view: headerComponentView, bounds: CGRect(origin: CGPoint(), size: headerViewFrame.size))
|
|
}
|
|
contentHeight += headerViewSize.height
|
|
|
|
contentHeight += 6.0
|
|
|
|
let body = MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor)
|
|
let bold = MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.freeTextColor)
|
|
|
|
var usageFraction: Double = 0.0
|
|
let totalUsageText: String
|
|
if listCategories.isEmpty {
|
|
totalUsageText = environment.strings.StorageManagement_DescriptionCleared
|
|
} else if let aggregatedData = self.aggregatedData {
|
|
var totalStatsSize: Int64 = 0
|
|
for (_, value) in aggregatedData.contextStats.categories {
|
|
totalStatsSize += value.size
|
|
}
|
|
|
|
if let _ = component.peer {
|
|
var allStatsSize: Int64 = 0
|
|
for (_, value) in aggregatedData.stats.totalStats.categories {
|
|
allStatsSize += value.size
|
|
}
|
|
|
|
let fraction: Double
|
|
if allStatsSize != 0 {
|
|
fraction = Double(totalStatsSize) / Double(allStatsSize)
|
|
} else {
|
|
fraction = 0.0
|
|
}
|
|
usageFraction = fraction
|
|
let fractionValue: Double = floor(fraction * 100.0 * 10.0) / 10.0
|
|
let fractionString: String
|
|
if fractionValue < 0.1 {
|
|
fractionString = "<0.1"
|
|
} else if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 {
|
|
fractionString = "\(Int(fractionValue))"
|
|
} else {
|
|
fractionString = "\(fractionValue)"
|
|
}
|
|
|
|
totalUsageText = environment.strings.StorageManagement_DescriptionChatUsage(fractionString).string
|
|
} else {
|
|
let fraction: Double
|
|
if aggregatedData.stats.deviceFreeSpace != 0 && totalStatsSize != 0 {
|
|
fraction = Double(totalStatsSize) / Double(aggregatedData.stats.deviceFreeSpace + totalStatsSize)
|
|
} else {
|
|
fraction = 0.0
|
|
}
|
|
usageFraction = fraction
|
|
let fractionValue: Double = floor(fraction * 100.0 * 10.0) / 10.0
|
|
let fractionString: String
|
|
if fractionValue < 0.1 {
|
|
fractionString = "<0.1"
|
|
} else if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 {
|
|
fractionString = "\(Int(fractionValue))"
|
|
} else {
|
|
fractionString = "\(fractionValue)"
|
|
}
|
|
|
|
totalUsageText = environment.strings.StorageManagement_DescriptionAppUsage(fractionString).string
|
|
}
|
|
} else {
|
|
totalUsageText = " "
|
|
}
|
|
let headerDescriptionSize = self.headerDescriptionView.update(
|
|
transition: transition,
|
|
component: AnyComponent(MultilineTextComponent(text: .markdown(text: totalUsageText, attributes: MarkdownAttributes(
|
|
body: body,
|
|
bold: bold,
|
|
link: body,
|
|
linkAttribute: { _ in nil }
|
|
)), horizontalAlignment: .center, maximumNumberOfLines: 0)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0)
|
|
)
|
|
let headerDescriptionFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerDescriptionSize.width) / 2.0), y: contentHeight), size: headerDescriptionSize)
|
|
if let headerDescriptionComponentView = self.headerDescriptionView.view {
|
|
if headerDescriptionComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(headerDescriptionComponentView)
|
|
}
|
|
transition.setFrame(view: headerDescriptionComponentView, frame: headerDescriptionFrame)
|
|
}
|
|
contentHeight += headerDescriptionSize.height
|
|
contentHeight += 8.0
|
|
|
|
let headerProgressWidth: CGFloat = min(200.0, availableSize.width - sideInset * 2.0)
|
|
let headerProgressFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - headerProgressWidth) / 2.0), y: contentHeight), size: CGSize(width: headerProgressWidth, height: 4.0))
|
|
transition.setFrame(layer: self.headerProgressBackgroundLayer, frame: headerProgressFrame)
|
|
transition.setCornerRadius(layer: self.headerProgressBackgroundLayer, cornerRadius: headerProgressFrame.height * 0.5)
|
|
self.headerProgressBackgroundLayer.backgroundColor = environment.theme.list.itemAccentColor.withMultipliedAlpha(0.2).cgColor
|
|
|
|
let headerProgress: CGFloat = usageFraction
|
|
transition.setFrame(layer: self.headerProgressForegroundLayer, frame: CGRect(origin: headerProgressFrame.origin, size: CGSize(width: max(headerProgressFrame.height, floorToScreenPixels(headerProgress * headerProgressFrame.width)), height: headerProgressFrame.height)))
|
|
transition.setCornerRadius(layer: self.headerProgressForegroundLayer, cornerRadius: headerProgressFrame.height * 0.5)
|
|
self.headerProgressForegroundLayer.backgroundColor = environment.theme.list.itemAccentColor.cgColor
|
|
contentHeight += 4.0
|
|
|
|
transition.setAlpha(layer: self.headerProgressBackgroundLayer, alpha: listCategories.isEmpty ? 0.0 : 1.0)
|
|
transition.setAlpha(layer: self.headerProgressForegroundLayer, alpha: listCategories.isEmpty ? 0.0 : 1.0)
|
|
|
|
contentHeight += 24.0
|
|
|
|
if let peer = component.peer {
|
|
let avatarSize = CGSize(width: 72.0, height: 72.0)
|
|
let avatarFrame: CGRect = CGRect(origin: CGPoint(x: pieChartFrame.minX + floor((pieChartFrame.width - avatarSize.width) / 2.0), y: pieChartFrame.minY + floor((pieChartFrame.height - avatarSize.height) / 2.0)), size: avatarSize)
|
|
|
|
let chartAvatarNode: AvatarNode
|
|
if let current = self.chartAvatarNode {
|
|
chartAvatarNode = current
|
|
transition.setFrame(view: chartAvatarNode.view, frame: avatarFrame)
|
|
} else {
|
|
chartAvatarNode = AvatarNode(font: avatarPlaceholderFont(size: 17.0))
|
|
self.chartAvatarNode = chartAvatarNode
|
|
self.scrollContainerView.addSubview(chartAvatarNode.view)
|
|
chartAvatarNode.frame = avatarFrame
|
|
|
|
if peer.id == component.context.account.peerId {
|
|
chartAvatarNode.setPeer(context: component.context, theme: environment.theme, peer: peer, overrideImage: .savedMessagesIcon, displayDimensions: avatarSize)
|
|
} else {
|
|
chartAvatarNode.setPeer(context: component.context, theme: environment.theme, peer: peer, displayDimensions: avatarSize)
|
|
}
|
|
}
|
|
transition.setAlpha(view: chartAvatarNode.view, alpha: listCategories.isEmpty ? 0.0 : 1.0)
|
|
} else {
|
|
let sizeText = dataSizeString(Int(totalSelectedCategorySize), forceDecimal: true, formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: "."))
|
|
|
|
var animatedTextItems: [AnimatedTextComponent.Item] = []
|
|
var remainingSizeText = sizeText
|
|
if let index = remainingSizeText.firstIndex(of: ".") {
|
|
animatedTextItems.append(AnimatedTextComponent.Item(id: "n-full", content: .text(String(remainingSizeText[remainingSizeText.startIndex ..< index]))))
|
|
animatedTextItems.append(AnimatedTextComponent.Item(id: "dot", content: .text(".")))
|
|
remainingSizeText = String(remainingSizeText[remainingSizeText.index(after: index)...])
|
|
}
|
|
if let index = remainingSizeText.firstIndex(of: " ") {
|
|
animatedTextItems.append(AnimatedTextComponent.Item(id: "n-fract", content: .text(String(remainingSizeText[remainingSizeText.startIndex ..< index]))))
|
|
remainingSizeText = String(remainingSizeText[index...])
|
|
}
|
|
if !remainingSizeText.isEmpty {
|
|
animatedTextItems.append(AnimatedTextComponent.Item(id: "rest", isUnbreakable: true, content: .text(remainingSizeText)))
|
|
}
|
|
|
|
let chartTotalLabelSize = self.chartTotalLabel.update(
|
|
transition: transition,
|
|
/*component: AnyComponent(Text(
|
|
text: dataSizeString(Int(totalSelectedCategorySize), formatting: DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")),
|
|
font: Font.with(size: 20.0, design: .round, weight: .bold), color: environment.theme.list.itemPrimaryTextColor
|
|
)),*/
|
|
component: AnyComponent(AnimatedTextComponent(
|
|
font: Font.with(size: 20.0, design: .round, weight: .bold),
|
|
color: environment.theme.list.itemPrimaryTextColor,
|
|
items: animatedTextItems
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 200.0, height: 200.0)
|
|
)
|
|
if let chartTotalLabelView = self.chartTotalLabel.view {
|
|
if chartTotalLabelView.superview == nil {
|
|
self.scrollContainerView.addSubview(chartTotalLabelView)
|
|
}
|
|
let totalLabelFrame = CGRect(origin: CGPoint(x: pieChartFrame.minX + floor((pieChartFrame.width - chartTotalLabelSize.width) / 2.0), y: pieChartFrame.minY + floor((pieChartFrame.height - chartTotalLabelSize.height) / 2.0)), size: chartTotalLabelSize)
|
|
transition.setFrame(view: chartTotalLabelView, frame: totalLabelFrame)
|
|
transition.setAlpha(view: chartTotalLabelView, alpha: listCategories.isEmpty ? 0.0 : 1.0)
|
|
}
|
|
}
|
|
|
|
if !listCategories.isEmpty {
|
|
self.categoriesView.parentState = state
|
|
let categoriesSize = self.categoriesView.update(
|
|
transition: transition,
|
|
component: AnyComponent(StorageCategoriesComponent(
|
|
theme: environment.theme,
|
|
strings: environment.strings,
|
|
categories: listCategories,
|
|
isOtherExpanded: self.isOtherCategoryExpanded,
|
|
displayAction: component.peer == nil,
|
|
toggleCategorySelection: { [weak self] key in
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
if key == Category.other {
|
|
let otherCategories = self.otherCategories.filter(aggregatedData.existingCategories.contains)
|
|
if !otherCategories.isEmpty {
|
|
if otherCategories.allSatisfy(aggregatedData.selectedCategories.contains) {
|
|
for item in otherCategories {
|
|
aggregatedData.setIsCategorySelected(category: item, isSelected: false)
|
|
}
|
|
} else {
|
|
for item in otherCategories {
|
|
aggregatedData.setIsCategorySelected(category: item, isSelected: true)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if aggregatedData.selectedCategories.contains(key) {
|
|
aggregatedData.setIsCategorySelected(category: key, isSelected: false)
|
|
} else {
|
|
aggregatedData.setIsCategorySelected(category: key, isSelected: true)
|
|
}
|
|
}
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
},
|
|
toggleOtherExpanded: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
self.isOtherCategoryExpanded = !self.isOtherCategoryExpanded
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
},
|
|
clearAction: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.requestClear(fromCategories: true)
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude)
|
|
)
|
|
if let categoriesComponentView = self.categoriesView.view {
|
|
if categoriesComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(categoriesComponentView)
|
|
}
|
|
|
|
transition.setFrame(view: categoriesComponentView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: categoriesSize))
|
|
}
|
|
contentHeight += categoriesSize.height
|
|
contentHeight += 8.0
|
|
|
|
|
|
let categoriesDescriptionSize = self.categoriesDescriptionView.update(
|
|
transition: transition,
|
|
component: AnyComponent(MultilineTextComponent(text: .markdown(text: environment.strings.StorageManagement_SectionsDescription, attributes: MarkdownAttributes(
|
|
body: body,
|
|
bold: bold,
|
|
link: body,
|
|
linkAttribute: { _ in nil }
|
|
)), maximumNumberOfLines: 0)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0)
|
|
)
|
|
let categoriesDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: categoriesDescriptionSize)
|
|
if let categoriesDescriptionComponentView = self.categoriesDescriptionView.view {
|
|
if categoriesDescriptionComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(categoriesDescriptionComponentView)
|
|
}
|
|
transition.setFrame(view: categoriesDescriptionComponentView, frame: categoriesDescriptionFrame)
|
|
}
|
|
contentHeight += categoriesDescriptionSize.height
|
|
contentHeight += 40.0
|
|
} else {
|
|
self.categoriesView.view?.removeFromSuperview()
|
|
self.categoriesDescriptionView.view?.removeFromSuperview()
|
|
}
|
|
|
|
if component.peer == nil {
|
|
let keepDurationTitleSize = self.keepDurationTitleView.update(
|
|
transition: transition,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .markdown(
|
|
text: environment.strings.StorageManagement_AutoremoveHeader, attributes: MarkdownAttributes(
|
|
body: body,
|
|
bold: bold,
|
|
link: body,
|
|
linkAttribute: { _ in nil }
|
|
)
|
|
),
|
|
maximumNumberOfLines: 0
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0)
|
|
)
|
|
let keepDurationTitleFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepDurationTitleSize)
|
|
if let keepDurationTitleComponentView = self.keepDurationTitleView.view {
|
|
if keepDurationTitleComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(keepDurationTitleComponentView)
|
|
}
|
|
transition.setFrame(view: keepDurationTitleComponentView, frame: keepDurationTitleFrame)
|
|
}
|
|
contentHeight += keepDurationTitleSize.height
|
|
contentHeight += 8.0
|
|
|
|
var keepContentHeight: CGFloat = 0.0
|
|
for i in 0 ..< 4 {
|
|
let item: ComponentView<Empty>
|
|
if let current = self.keepDurationItems[i] {
|
|
item = current
|
|
} else {
|
|
item = ComponentView<Empty>()
|
|
self.keepDurationItems[i] = item
|
|
}
|
|
|
|
let mappedCategory: CacheStorageSettings.PeerStorageCategory
|
|
|
|
let iconName: String
|
|
let title: String
|
|
switch i {
|
|
case 0:
|
|
iconName = "Settings/Menu/EditProfile"
|
|
title = environment.strings.Notifications_PrivateChats
|
|
mappedCategory = .privateChats
|
|
case 1:
|
|
iconName = "Settings/Menu/GroupChats"
|
|
title = environment.strings.Notifications_GroupChats
|
|
mappedCategory = .groups
|
|
case 3:
|
|
iconName = "Settings/Menu/Stories"
|
|
title = environment.strings.Notifications_Stories
|
|
mappedCategory = .stories
|
|
default:
|
|
iconName = "Settings/Menu/Channels"
|
|
title = environment.strings.Notifications_Channels
|
|
mappedCategory = .channels
|
|
}
|
|
|
|
let value = self.cacheSettings?.categoryStorageTimeout[mappedCategory] ?? Int32.max
|
|
let optionText: String
|
|
if value == Int32.max {
|
|
optionText = environment.strings.ClearCache_Never
|
|
} else {
|
|
optionText = timeIntervalString(strings: environment.strings, value: value)
|
|
}
|
|
|
|
var subtitle: String?
|
|
if mappedCategory != .stories {
|
|
if let cacheSettingsExceptionCount = self.cacheSettingsExceptionCount, let categoryCount = cacheSettingsExceptionCount[mappedCategory] {
|
|
subtitle = environment.strings.CacheEvictionMenu_CategoryExceptions(Int32(categoryCount))
|
|
}
|
|
}
|
|
|
|
let itemSize = item.update(
|
|
transition: transition,
|
|
component: AnyComponent(StoragePeerTypeItemComponent(
|
|
theme: environment.theme,
|
|
iconName: iconName,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
value: optionText,
|
|
hasNext: i != 4 - 1,
|
|
action: { [weak self] sourceView in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.openKeepMediaCategory(mappedCategory: mappedCategory, sourceView: sourceView)
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
|
|
)
|
|
let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: keepContentHeight), size: itemSize)
|
|
if let itemView = item.view {
|
|
if itemView.superview == nil {
|
|
self.keepDurationSectionContainerView.addSubview(itemView)
|
|
}
|
|
transition.setFrame(view: itemView, frame: itemFrame)
|
|
}
|
|
keepContentHeight += itemSize.height
|
|
}
|
|
self.keepDurationSectionContainerView.backgroundColor = environment.theme.list.itemBlocksBackgroundColor
|
|
transition.setFrame(view: self.keepDurationSectionContainerView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: keepContentHeight)))
|
|
contentHeight += keepContentHeight
|
|
contentHeight += 8.0
|
|
|
|
let keepDurationDescriptionSize = self.keepDurationDescriptionView.update(
|
|
transition: transition,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .markdown(
|
|
text: environment.strings.StorageManagement_AutoremoveDescription, attributes: MarkdownAttributes(
|
|
body: body,
|
|
bold: bold,
|
|
link: body,
|
|
linkAttribute: { _ in nil }
|
|
)
|
|
),
|
|
maximumNumberOfLines: 0
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0)
|
|
)
|
|
let keepDurationDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepDurationDescriptionSize)
|
|
if let keepDurationDescriptionComponentView = self.keepDurationDescriptionView.view {
|
|
if keepDurationDescriptionComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(keepDurationDescriptionComponentView)
|
|
}
|
|
transition.setFrame(view: keepDurationDescriptionComponentView, frame: keepDurationDescriptionFrame)
|
|
}
|
|
contentHeight += keepDurationDescriptionSize.height
|
|
contentHeight += 40.0
|
|
|
|
let keepSizeTitleSize = self.keepSizeTitleView.update(
|
|
transition: transition,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .markdown(
|
|
text: environment.strings.Cache_MaximumCacheSize.uppercased(), attributes: MarkdownAttributes(
|
|
body: body,
|
|
bold: bold,
|
|
link: body,
|
|
linkAttribute: { _ in nil }
|
|
)
|
|
),
|
|
maximumNumberOfLines: 0
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0)
|
|
)
|
|
let keepSizeTitleFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepSizeTitleSize)
|
|
if let keepSizeTitleComponentView = self.keepSizeTitleView.view {
|
|
if keepSizeTitleComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(keepSizeTitleComponentView)
|
|
}
|
|
transition.setFrame(view: keepSizeTitleComponentView, frame: keepSizeTitleFrame)
|
|
}
|
|
contentHeight += keepSizeTitleSize.height
|
|
contentHeight += 8.0
|
|
|
|
let keepSizeSize = self.keepSizeView.update(
|
|
transition: transition,
|
|
component: AnyComponent(StorageKeepSizeComponent(
|
|
theme: environment.theme,
|
|
strings: environment.strings,
|
|
value: cacheSettings?.defaultCacheStorageLimitGigabytes ?? 16,
|
|
updateValue: { [weak self] value in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
// MARK: Swiftgram
|
|
// let value = max(5, value)
|
|
let _ = updateCacheStorageSettingsInteractively(accountManager: component.context.sharedContext.accountManager, { current in
|
|
var current = current
|
|
current.defaultCacheStorageLimitGigabytes = value
|
|
return current
|
|
}).start()
|
|
}
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
|
|
)
|
|
let keepSizeFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: keepSizeSize)
|
|
if let keepSizeComponentView = self.keepSizeView.view {
|
|
if keepSizeComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(keepSizeComponentView)
|
|
}
|
|
transition.setFrame(view: keepSizeComponentView, frame: keepSizeFrame)
|
|
}
|
|
contentHeight += keepSizeSize.height
|
|
contentHeight += 8.0
|
|
|
|
let keepSizeDescriptionSize = self.keepSizeDescriptionView.update(
|
|
transition: transition,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .markdown(
|
|
text: environment.strings.StorageManagement_AutoremoveSpaceDescription, attributes: MarkdownAttributes(
|
|
body: body,
|
|
bold: bold,
|
|
link: body,
|
|
linkAttribute: { _ in nil }
|
|
)
|
|
),
|
|
maximumNumberOfLines: 0
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - 15.0 * 2.0, height: 10000.0)
|
|
)
|
|
let keepSizeDescriptionFrame = CGRect(origin: CGPoint(x: sideInset + 15.0, y: contentHeight), size: keepSizeDescriptionSize)
|
|
if let keepSizeDescriptionComponentView = self.keepSizeDescriptionView.view {
|
|
if keepSizeDescriptionComponentView.superview == nil {
|
|
self.scrollContainerView.addSubview(keepSizeDescriptionComponentView)
|
|
}
|
|
transition.setFrame(view: keepSizeDescriptionComponentView, frame: keepSizeDescriptionFrame)
|
|
}
|
|
contentHeight += keepSizeDescriptionSize.height
|
|
contentHeight += 40.0
|
|
}
|
|
|
|
var panelItems: [StorageUsagePanelContainerComponent.Item] = []
|
|
if let aggregatedData = self.aggregatedData, let peerItems = aggregatedData.peerItems, !peerItems.items.isEmpty, !listCategories.isEmpty {
|
|
panelItems.append(StorageUsagePanelContainerComponent.Item(
|
|
id: "peers",
|
|
title: environment.strings.StorageManagement_TabChats,
|
|
panel: AnyComponent(StoragePeerListPanelComponent(
|
|
context: component.context,
|
|
items: peerItems,
|
|
selectionState: aggregatedData.isSelectingPeers ? aggregatedData.selectionState : nil,
|
|
peerAction: { [weak self] peer in
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
if aggregatedData.isSelectingPeers {
|
|
aggregatedData.togglePeerSelection(id: peer.id)
|
|
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
} else {
|
|
self.openPeer(peer: peer)
|
|
}
|
|
},
|
|
contextAction: { [weak self] peer, sourceView, gesture in
|
|
guard let self, let component = self.component else {
|
|
return
|
|
}
|
|
|
|
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
|
|
|
|
var itemList: [ContextMenuItem] = []
|
|
itemList.append(.action(ContextMenuActionItem(
|
|
text: presentationData.strings.StorageManagement_PeerShowDetails,
|
|
icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor) },
|
|
action: { [weak self] c, _ in
|
|
c?.dismiss(completion: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.openPeer(peer: peer)
|
|
})
|
|
})
|
|
))
|
|
itemList.append(.action(ContextMenuActionItem(
|
|
text: presentationData.strings.StorageManagement_PeerOpenProfile,
|
|
icon: { theme in
|
|
if case .user = peer {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.contextMenu.primaryColor)
|
|
} else {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Groups"), color: theme.contextMenu.primaryColor)
|
|
}
|
|
},
|
|
action: { [weak self] c, _ in
|
|
c?.dismiss(completion: { [weak self] in
|
|
guard let self, let component = self.component, let controller = self.controller?() else {
|
|
return
|
|
}
|
|
let peerInfoController = component.context.sharedContext.makePeerInfoController(
|
|
context: component.context,
|
|
updatedPresentationData: nil,
|
|
peer: peer._asPeer(),
|
|
mode: .generic,
|
|
avatarInitiallyExpanded: false,
|
|
fromChat: false,
|
|
requestsContext: nil
|
|
)
|
|
if let peerInfoController {
|
|
controller.push(peerInfoController)
|
|
}
|
|
})
|
|
})
|
|
))
|
|
itemList.append(.action(ContextMenuActionItem(
|
|
text: presentationData.strings.StorageManagement_ContextSelect,
|
|
icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) },
|
|
action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
})
|
|
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
aggregatedData.togglePeerSelection(id: peer.id)
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
})
|
|
))
|
|
let items = ContextController.Items(content: .list(itemList))
|
|
|
|
let controller = ContextController(
|
|
presentationData: presentationData,
|
|
source: .extracted(StorageUsageListContextExtractedContentSource(contentView: sourceView)), items: .single(items), recognizer: nil, gesture: gesture)
|
|
|
|
self.controller?()?.forEachController({ controller in
|
|
if let controller = controller as? UndoOverlayController {
|
|
controller.dismiss()
|
|
}
|
|
return true
|
|
})
|
|
self.controller?()?.presentInGlobalOverlay(controller)
|
|
}
|
|
))
|
|
))
|
|
}
|
|
if let aggregatedData = self.aggregatedData, let imageItems = aggregatedData.imageItems, !imageItems.items.isEmpty, !listCategories.isEmpty {
|
|
panelItems.append(StorageUsagePanelContainerComponent.Item(
|
|
id: "images",
|
|
title: environment.strings.StorageManagement_TabMedia,
|
|
panel: AnyComponent(StorageMediaGridPanelComponent(
|
|
context: component.context,
|
|
items: aggregatedData.imageItems,
|
|
selectionState: aggregatedData.selectionState,
|
|
action: { [weak self] messageId in
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
guard let _ = aggregatedData.messages[messageId] else {
|
|
return
|
|
}
|
|
aggregatedData.toggleMessageSelection(id: messageId)
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
},
|
|
contextAction: { [weak self] messageId, containerView, sourceRect, gesture in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.messageGaleryContextAction(messageId: messageId, sourceView: containerView, sourceRect: sourceRect, gesture: gesture)
|
|
}
|
|
))
|
|
))
|
|
}
|
|
if let aggregatedData = self.aggregatedData, let fileItems = aggregatedData.fileItems, !fileItems.items.isEmpty, !listCategories.isEmpty {
|
|
panelItems.append(StorageUsagePanelContainerComponent.Item(
|
|
id: "files",
|
|
title: environment.strings.StorageManagement_TabFiles,
|
|
panel: AnyComponent(StorageFileListPanelComponent(
|
|
context: component.context,
|
|
items: fileItems,
|
|
selectionState: aggregatedData.selectionState,
|
|
action: { [weak self] messageId in
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
guard let _ = aggregatedData.messages[messageId] else {
|
|
return
|
|
}
|
|
aggregatedData.toggleMessageSelection(id: messageId)
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
},
|
|
contextAction: { [weak self] messageId, containerView, gesture in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.messageContextAction(messageId: messageId, sourceView: containerView, gesture: gesture)
|
|
}
|
|
))
|
|
))
|
|
}
|
|
if let aggregatedData = self.aggregatedData, let musicItems = aggregatedData.musicItems, !musicItems.items.isEmpty, !listCategories.isEmpty {
|
|
panelItems.append(StorageUsagePanelContainerComponent.Item(
|
|
id: "music",
|
|
title: environment.strings.StorageManagement_TabMusic,
|
|
panel: AnyComponent(StorageFileListPanelComponent(
|
|
context: component.context,
|
|
items: musicItems,
|
|
selectionState: aggregatedData.selectionState,
|
|
action: { [weak self] messageId in
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
guard let _ = aggregatedData.messages[messageId] else {
|
|
return
|
|
}
|
|
aggregatedData.toggleMessageSelection(id: messageId)
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
},
|
|
contextAction: { [weak self] messageId, containerView, gesture in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.messageContextAction(messageId: messageId, sourceView: containerView, gesture: gesture)
|
|
}
|
|
))
|
|
))
|
|
}
|
|
|
|
if !panelItems.isEmpty {
|
|
let panelContainerSize = self.panelContainer.update(
|
|
transition: transition,
|
|
component: AnyComponent(StorageUsagePanelContainerComponent(
|
|
theme: environment.theme,
|
|
strings: environment.strings,
|
|
dateTimeFormat: environment.dateTimeFormat,
|
|
insets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: bottomInset, right: environment.safeInsets.right),
|
|
items: panelItems,
|
|
currentPanelUpdated: { [weak self] id, transition in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.currentSelectedPanelId = id
|
|
self.state?.updated(transition: transition)
|
|
}
|
|
)),
|
|
environment: {
|
|
StorageUsagePanelContainerEnvironment(isScrollable: wasLockedAtPanels)
|
|
},
|
|
containerSize: CGSize(width: availableSize.width, height: availableSize.height - environment.navigationHeight)
|
|
)
|
|
if let panelContainerView = self.panelContainer.view {
|
|
if panelContainerView.superview == nil {
|
|
self.scrollContainerView.addSubview(panelContainerView)
|
|
}
|
|
transition.setFrame(view: panelContainerView, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: panelContainerSize))
|
|
}
|
|
contentHeight += panelContainerSize.height
|
|
} else {
|
|
self.panelContainer.view?.removeFromSuperview()
|
|
}
|
|
|
|
self.ignoreScrolling = true
|
|
|
|
let contentOffset = self.scrollView.bounds.minY
|
|
transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center)
|
|
let contentSize = CGSize(width: availableSize.width, height: contentHeight)
|
|
if self.scrollView.contentSize != contentSize {
|
|
self.scrollView.contentSize = contentSize
|
|
}
|
|
transition.setFrame(view: self.scrollContainerView, frame: CGRect(origin: CGPoint(), size: contentSize))
|
|
|
|
var scrollViewBounds = self.scrollView.bounds
|
|
scrollViewBounds.size = availableSize
|
|
if wasLockedAtPanels, let panelContainerView = self.panelContainer.view {
|
|
scrollViewBounds.origin.y = panelContainerView.frame.minY - environment.navigationHeight
|
|
}
|
|
transition.setBounds(view: self.scrollView, bounds: scrollViewBounds)
|
|
|
|
if !wasLockedAtPanels && !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset {
|
|
let deltaOffset = self.scrollView.bounds.minY - contentOffset
|
|
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true)
|
|
}
|
|
|
|
self.ignoreScrolling = false
|
|
|
|
self.updateScrolling(transition: transition)
|
|
|
|
if self.isClearing {
|
|
let clearingNode: StorageUsageClearProgressOverlayNode
|
|
var animateIn = false
|
|
if let current = self.clearingNode {
|
|
clearingNode = current
|
|
} else {
|
|
animateIn = true
|
|
clearingNode = StorageUsageClearProgressOverlayNode(presentationData: component.context.sharedContext.currentPresentationData.with { $0 })
|
|
self.clearingNode = clearingNode
|
|
self.addSubnode(clearingNode)
|
|
self.clearingDisplayTimestamp = CFAbsoluteTimeGetCurrent()
|
|
}
|
|
|
|
let clearingSize = CGSize(width: availableSize.width, height: availableSize.height)
|
|
clearingNode.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - clearingSize.width) / 2.0), y: floor((availableSize.height - clearingSize.height) / 2.0)), size: clearingSize)
|
|
clearingNode.updateLayout(size: clearingSize, bottomInset: environment.safeInsets.bottom, transition: .immediate)
|
|
|
|
if animateIn {
|
|
clearingNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, delay: 0.4)
|
|
}
|
|
} else {
|
|
if let clearingNode = self.clearingNode {
|
|
self.clearingNode = nil
|
|
|
|
var delay: Double = 0.0
|
|
if let clearingDisplayTimestamp = self.clearingDisplayTimestamp {
|
|
let timeDelta = CFAbsoluteTimeGetCurrent() - clearingDisplayTimestamp
|
|
if timeDelta < 0.4 {
|
|
delay = 0.0
|
|
} else if timeDelta < 1.0 {
|
|
delay = 1.0
|
|
}
|
|
}
|
|
|
|
if delay == 0.0 {
|
|
let animationTransition = ComponentTransition(animation: .curve(duration: 0.25, curve: .easeInOut))
|
|
animationTransition.setAlpha(view: clearingNode.view, alpha: 0.0, completion: { [weak clearingNode] _ in
|
|
clearingNode?.removeFromSupernode()
|
|
})
|
|
} else {
|
|
clearingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, delay: delay, removeOnCompletion: false, completion: { [weak clearingNode] _ in
|
|
clearingNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return availableSize
|
|
}
|
|
|
|
private func reportClearedStorage(size: Int64) {
|
|
guard let component = self.component else {
|
|
return
|
|
}
|
|
guard let controller = self.controller?() else {
|
|
return
|
|
}
|
|
|
|
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
|
|
controller.present(UndoOverlayController(presentationData: presentationData, content: .succeed(text: presentationData.strings.ClearCache_Success("\(dataSizeString(size, formatting: DataSizeStringFormatting(presentationData: presentationData)))", stringForDeviceType()).string, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current)
|
|
}
|
|
|
|
private func reloadStats(firstTime: Bool, completion: @escaping () -> Void) {
|
|
guard let component = self.component else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
self.statsDisposable = (component.context.engine.resources.collectStorageUsageStats()
|
|
|> deliverOnMainQueue).start(next: { [weak self] stats in
|
|
guard let self, let component = self.component else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
var peerItems: [StoragePeerListPanelComponent.Item] = []
|
|
|
|
if component.peer == nil {
|
|
for item in stats.peers.values.sorted(by: { lhs, rhs in
|
|
let lhsSize: Int64 = lhs.stats.categories.values.reduce(0, {
|
|
$0 + $1.size
|
|
})
|
|
let rhsSize: Int64 = rhs.stats.categories.values.reduce(0, {
|
|
$0 + $1.size
|
|
})
|
|
return lhsSize > rhsSize
|
|
}) {
|
|
let itemSize: Int64 = item.stats.categories.values.reduce(0, {
|
|
$0 + $1.size
|
|
})
|
|
peerItems.append(StoragePeerListPanelComponent.Item(
|
|
peer: item.peer,
|
|
size: itemSize
|
|
))
|
|
}
|
|
}
|
|
|
|
let initialAggregatedData = AggregatedData(
|
|
peerId: component.peer?.id,
|
|
stats: stats,
|
|
messages: [:],
|
|
peerItems: StoragePeerListPanelComponent.Items(items: peerItems),
|
|
imageItems: nil,
|
|
fileItems: nil,
|
|
musicItems: nil
|
|
)
|
|
let contextStats = initialAggregatedData.contextStats
|
|
|
|
if firstTime {
|
|
self.aggregatedData = initialAggregatedData
|
|
|
|
self.state?.updated(transition: ComponentTransition(animation: .none).withUserData(AnimationHint(value: .firstStatsUpdate)))
|
|
self.component?.ready.set(.single(true))
|
|
}
|
|
|
|
class RenderResult {
|
|
var messages: [MessageId: Message] = [:]
|
|
var imageItems: [StorageMediaGridPanelComponent.Item] = []
|
|
var fileItems: [StorageFileListPanelComponent.Item] = []
|
|
var musicItems: [StorageFileListPanelComponent.Item] = []
|
|
}
|
|
|
|
self.messagesDisposable = (component.context.engine.resources.renderStorageUsageStatsMessages(stats: contextStats, categories: [.files, .photos, .videos, .music], existingMessages: self.aggregatedData?.messages ?? [:])
|
|
|> deliverOn(Queue())
|
|
|> map { messages -> RenderResult in
|
|
let result = RenderResult()
|
|
|
|
result.messages = messages
|
|
|
|
var mergedMedia: [MessageId: Int64] = [:]
|
|
if let categoryStats = contextStats.categories[.photos] {
|
|
mergedMedia = categoryStats.messages
|
|
}
|
|
if let categoryStats = contextStats.categories[.videos] {
|
|
for (id, value) in categoryStats.messages {
|
|
mergedMedia[id] = value
|
|
}
|
|
}
|
|
|
|
if !mergedMedia.isEmpty {
|
|
for (id, messageSize) in mergedMedia.sorted(by: { $0.value > $1.value }) {
|
|
if let message = messages[id] {
|
|
var matches = false
|
|
for media in message.media {
|
|
if media is TelegramMediaImage {
|
|
matches = true
|
|
break
|
|
} else if let file = media as? TelegramMediaFile {
|
|
if file.isVideo {
|
|
matches = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches {
|
|
result.imageItems.append(StorageMediaGridPanelComponent.Item(
|
|
message: EngineMessage(message),
|
|
size: messageSize
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let categoryStats = contextStats.categories[.files] {
|
|
for (id, messageSize) in categoryStats.messages.sorted(by: { $0.value > $1.value }) {
|
|
if let message = messages[id] {
|
|
var matches = false
|
|
for media in message.media {
|
|
if let file = media as? TelegramMediaFile {
|
|
if file.isSticker || file.isCustomEmoji {
|
|
} else {
|
|
matches = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches {
|
|
result.fileItems.append(StorageFileListPanelComponent.Item(
|
|
message: message,
|
|
size: messageSize
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let categoryStats = contextStats.categories[.music] {
|
|
for (id, messageSize) in categoryStats.messages.sorted(by: { $0.value > $1.value }) {
|
|
if let message = messages[id] {
|
|
var matches = false
|
|
for media in message.media {
|
|
if media is TelegramMediaFile {
|
|
matches = true
|
|
}
|
|
}
|
|
|
|
if matches {
|
|
result.musicItems.append(StorageFileListPanelComponent.Item(
|
|
message: message,
|
|
size: messageSize
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|> deliverOnMainQueue).start(next: { [weak self] result in
|
|
guard let self, let component = self.component else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
if !firstTime {
|
|
if let peer = component.peer, let controller = self.controller?() as? StorageUsageScreen, let childCompleted = controller.childCompleted {
|
|
let contextStats: StorageUsageStats = stats.peers[peer.id]?.stats ?? StorageUsageStats(categories: [:])
|
|
var totalSize: Int64 = 0
|
|
for (_, value) in contextStats.categories {
|
|
totalSize += value.size
|
|
}
|
|
|
|
if totalSize == 0 {
|
|
childCompleted({ [weak self] in
|
|
completion()
|
|
|
|
if let self {
|
|
self.controller?()?.dismiss(animated: true)
|
|
}
|
|
})
|
|
return
|
|
} else {
|
|
childCompleted({})
|
|
}
|
|
}
|
|
}
|
|
|
|
self.aggregatedData = AggregatedData(
|
|
peerId: component.peer?.id,
|
|
stats: stats,
|
|
messages: result.messages,
|
|
peerItems: initialAggregatedData.peerItems,
|
|
imageItems: StorageMediaGridPanelComponent.Items(items: result.imageItems),
|
|
fileItems: StorageFileListPanelComponent.Items(items: result.fileItems),
|
|
musicItems: StorageFileListPanelComponent.Items(items: result.musicItems)
|
|
)
|
|
|
|
self.isClearing = false
|
|
|
|
if !firstTime {
|
|
self.state?.updated(transition: ComponentTransition(animation: .none).withUserData(AnimationHint(value: .clearedItems)))
|
|
} else {
|
|
self.state?.updated(transition: ComponentTransition(animation: .none))
|
|
}
|
|
|
|
completion()
|
|
})
|
|
})
|
|
}
|
|
|
|
private func openPeer(peer: EnginePeer) {
|
|
guard let component = self.component else {
|
|
return
|
|
}
|
|
guard let controller = self.controller?() else {
|
|
return
|
|
}
|
|
|
|
let childController = StorageUsageScreen(context: component.context, makeStorageUsageExceptionsScreen: component.makeStorageUsageExceptionsScreen, peer: peer)
|
|
childController.childCompleted = { [weak self] completed in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.reloadStats(firstTime: false, completion: {
|
|
completed()
|
|
})
|
|
}
|
|
controller.push(childController)
|
|
}
|
|
|
|
private func messageGaleryContextAction(messageId: EngineMessage.Id, sourceView: UIView, sourceRect: CGRect, gesture: ContextGesture) {
|
|
guard let component = self.component, let aggregatedData = self.aggregatedData, let message = aggregatedData.messages[messageId] else {
|
|
gesture.cancel()
|
|
return
|
|
}
|
|
|
|
let _ = (chatMediaListPreviewControllerData(
|
|
context: component.context,
|
|
chatLocation: .peer(id: message.id.peerId),
|
|
chatFilterTag: nil,
|
|
chatLocationContextHolder: nil,
|
|
message: message,
|
|
standalone: true,
|
|
reverseMessageGalleryOrder: false,
|
|
navigationController: self.controller?()?.navigationController as? NavigationController
|
|
)
|
|
|> deliverOnMainQueue).start(next: { [weak self] previewData in
|
|
guard let self, let component = self.component, let previewData else {
|
|
gesture.cancel()
|
|
return
|
|
}
|
|
|
|
let context = component.context
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
let strings = presentationData.strings
|
|
|
|
var items: [ContextMenuItem] = []
|
|
|
|
var openTitle: String = presentationData.strings.StorageManagement_OpenPhoto
|
|
for media in message.media {
|
|
if let _ = media as? TelegramMediaImage {
|
|
openTitle = presentationData.strings.StorageManagement_OpenPhoto
|
|
} else if let file = media as? TelegramMediaFile {
|
|
if file.isVideo {
|
|
openTitle = presentationData.strings.StorageManagement_OpenVideo
|
|
} else {
|
|
openTitle = presentationData.strings.StorageManagement_OpenFile
|
|
}
|
|
}
|
|
}
|
|
|
|
items.append(.action(ContextMenuActionItem(
|
|
text: openTitle,
|
|
icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Expand"), color: theme.contextMenu.primaryColor) },
|
|
action: { [weak self] c, _ in
|
|
c?.dismiss(completion: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.openMessage(message: message)
|
|
})
|
|
})
|
|
))
|
|
|
|
items.append(.action(ContextMenuActionItem(text: strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, f in
|
|
c?.dismiss(completion: { [weak self] in
|
|
guard let self, let component = self.component, let controller = self.controller?(), let navigationController = controller.navigationController as? NavigationController else {
|
|
return
|
|
}
|
|
guard let peer = message.peers[message.id.peerId].flatMap(EnginePeer.init) else {
|
|
return
|
|
}
|
|
|
|
var chatLocation: NavigateToChatControllerParams.Location = .peer(peer)
|
|
if case let .channel(channel) = peer, channel.flags.contains(.isForum), let threadId = message.threadId {
|
|
chatLocation = .replyThread(ChatReplyThreadMessage(peerId: peer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false))
|
|
}
|
|
|
|
component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
|
|
navigationController: navigationController,
|
|
context: component.context,
|
|
chatLocation: chatLocation,
|
|
subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false),
|
|
keepStack: .always
|
|
))
|
|
})
|
|
})))
|
|
|
|
items.append(.action(ContextMenuActionItem(text: strings.Conversation_ContextMenuSelect, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.actionSheet.primaryTextColor)
|
|
}, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
})
|
|
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
aggregatedData.toggleMessageSelection(id: message.id)
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
})))
|
|
|
|
switch previewData {
|
|
case let .gallery(gallery):
|
|
gallery.setHintWillBePresentedInPreviewingContext(true)
|
|
let contextController = ContextController(
|
|
presentationData: presentationData,
|
|
source: .controller(StorageUsageListContextGalleryContentSourceImpl(
|
|
controller: gallery,
|
|
sourceView: sourceView,
|
|
sourceRect: sourceRect
|
|
)),
|
|
items: .single(ContextController.Items(content: .list(items))),
|
|
gesture: gesture
|
|
)
|
|
self.controller?()?.presentInGlobalOverlay(contextController)
|
|
case .instantPage:
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
private func messageContextAction(messageId: EngineMessage.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture) {
|
|
guard let component = self.component, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
guard let message = aggregatedData.messages[messageId] else {
|
|
return
|
|
}
|
|
|
|
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
|
|
|
|
var openTitle: String = presentationData.strings.Conversation_LinkDialogOpen
|
|
var isAudio: Bool = false
|
|
for media in message.media {
|
|
if let _ = media as? TelegramMediaImage {
|
|
openTitle = presentationData.strings.StorageManagement_OpenPhoto
|
|
} else if let file = media as? TelegramMediaFile {
|
|
if file.isVideo {
|
|
openTitle = presentationData.strings.StorageManagement_OpenVideo
|
|
} else {
|
|
openTitle = presentationData.strings.StorageManagement_OpenFile
|
|
}
|
|
isAudio = file.isMusic || file.isVoice
|
|
}
|
|
}
|
|
|
|
var itemList: [ContextMenuItem] = []
|
|
if !isAudio {
|
|
itemList.append(.action(ContextMenuActionItem(
|
|
text: openTitle,
|
|
icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Expand"), color: theme.contextMenu.primaryColor) },
|
|
action: { [weak self] c, _ in
|
|
c?.dismiss(completion: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.openMessage(message: message)
|
|
})
|
|
})
|
|
))
|
|
}
|
|
|
|
itemList.append(.action(ContextMenuActionItem(
|
|
text: presentationData.strings.SharedMedia_ViewInChat,
|
|
icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor)
|
|
},
|
|
action: { [weak self] c, _ in
|
|
c?.dismiss(completion: { [weak self] in
|
|
guard let self, let component = self.component, let controller = self.controller?(), let navigationController = controller.navigationController as? NavigationController else {
|
|
return
|
|
}
|
|
guard let peer = message.peers[message.id.peerId].flatMap(EnginePeer.init) else {
|
|
return
|
|
}
|
|
|
|
var chatLocation: NavigateToChatControllerParams.Location = .peer(peer)
|
|
if case let .channel(channel) = peer, channel.flags.contains(.isForum), let threadId = message.threadId {
|
|
chatLocation = .replyThread(ChatReplyThreadMessage(peerId: peer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false))
|
|
}
|
|
|
|
component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(
|
|
navigationController: navigationController,
|
|
context: component.context,
|
|
chatLocation: chatLocation,
|
|
subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false),
|
|
keepStack: .always
|
|
))
|
|
})
|
|
})
|
|
))
|
|
itemList.append(.action(ContextMenuActionItem(
|
|
text: aggregatedData.selectionState.selectedMessages.contains(messageId) ? presentationData.strings.StorageManagement_ContextDeselect : presentationData.strings.StorageManagement_ContextSelect,
|
|
icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) },
|
|
action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
})
|
|
|
|
guard let self, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
aggregatedData.toggleMessageSelection(id: message.id)
|
|
self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)))
|
|
})
|
|
))
|
|
let items = ContextController.Items(content: .list(itemList))
|
|
|
|
let controller = ContextController(
|
|
presentationData: presentationData,
|
|
source: .extracted(StorageUsageListContextExtractedContentSource(contentView: sourceView)), items: .single(items), recognizer: nil, gesture: gesture)
|
|
|
|
self.controller?()?.forEachController({ controller in
|
|
if let controller = controller as? UndoOverlayController {
|
|
controller.dismiss()
|
|
}
|
|
return true
|
|
})
|
|
self.controller?()?.presentInGlobalOverlay(controller)
|
|
}
|
|
|
|
private func openMessage(message: Message) {
|
|
guard let component = self.component else {
|
|
return
|
|
}
|
|
guard let controller = self.controller?(), let navigationController = controller.navigationController as? NavigationController else {
|
|
return
|
|
}
|
|
let foundGalleryMessage: Message? = message
|
|
guard let galleryMessage = foundGalleryMessage else {
|
|
return
|
|
}
|
|
self.endEditing(true)
|
|
|
|
let _ = component.context.sharedContext.openChatMessage(OpenChatMessageParams(
|
|
context: component.context,
|
|
chatLocation: .peer(id: message.id.peerId),
|
|
chatFilterTag: nil,
|
|
chatLocationContextHolder: nil,
|
|
message: galleryMessage,
|
|
standalone: true,
|
|
reverseMessageGalleryOrder: true,
|
|
navigationController: navigationController,
|
|
dismissInput: { [weak self] in
|
|
self?.endEditing(true)
|
|
},
|
|
present: { [weak self] c, a, _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.controller?()?.present(c, in: .window(.root), with: a, blockInteraction: true)
|
|
},
|
|
transitionNode: { [weak self] messageId, media, _ in
|
|
guard let self else {
|
|
return nil
|
|
}
|
|
|
|
if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View {
|
|
if let currentPanelView = panelContainerView.currentPanelView as? StorageMediaGridPanelComponent.View {
|
|
return currentPanelView.transitionNodeForGallery(messageId: messageId, media: EngineMedia(media))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}, addToTransitionSurface: { [weak self] view in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View {
|
|
panelContainerView.currentPanelView?.addSubview(view)
|
|
}
|
|
}, openUrl: { [weak self] url in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, openPeer: { [weak self] peer, navigation in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
},
|
|
callPeer: { _, _ in
|
|
//self?.controllerInteraction?.callPeer(peerId)
|
|
},
|
|
enqueueMessage: { _ in
|
|
},
|
|
sendSticker: nil,
|
|
sendEmoji: nil,
|
|
setupTemporaryHiddenMedia: { _, _, _ in },
|
|
chatAvatarHiddenMedia: { _, _ in },
|
|
actionInteraction: GalleryControllerActionInteraction(openUrl: { [weak self] url, concealed in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
//strongSelf.openUrl(url: url, concealed: false, external: false)
|
|
}, openUrlIn: { [weak self] url in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, openPeerMention: { [weak self] mention in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, openPeer: { [weak self] peer in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, openHashtag: { [weak self] peerName, hashtag in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, openBotCommand: { _ in
|
|
}, openAd: { _ in
|
|
}, addContact: { _ in
|
|
}, storeMediaPlaybackState: { [weak self] messageId, timestamp, playbackRate in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, editMedia: { _, _, _ in
|
|
}, updateCanReadHistory: { _ in
|
|
}),
|
|
centralItemUpdated: { [weak self] messageId in
|
|
//let _ = self?.paneContainerNode.requestExpandTabs?()
|
|
//self?.paneContainerNode.currentPane?.node.ensureMessageIsVisible(id: messageId)
|
|
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}
|
|
))
|
|
}
|
|
|
|
private func requestClear(fromCategories: Bool) {
|
|
guard let component = self.component, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
let context = component.context
|
|
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
let actionSheet = ActionSheetController(presentationData: presentationData)
|
|
|
|
let clearTitle: String
|
|
if let _ = aggregatedData.peerId {
|
|
clearTitle = presentationData.strings.StorageManagement_ClearSelected
|
|
} else {
|
|
if aggregatedData.selectedCategories == aggregatedData.existingCategories, fromCategories {
|
|
clearTitle = presentationData.strings.StorageManagement_ClearAll
|
|
} else {
|
|
clearTitle = presentationData.strings.StorageManagement_ClearSelected
|
|
}
|
|
}
|
|
|
|
actionSheet.setItemGroups([ActionSheetItemGroup(items: [
|
|
ActionSheetTextItem(title: presentationData.strings.StorageManagement_ClearConfirmationText, parseMarkdown: true),
|
|
ActionSheetButtonItem(title: clearTitle, color: .destructive, action: { [weak self, weak actionSheet] in
|
|
actionSheet?.dismissAnimated()
|
|
|
|
self?.commitClear(fromCategories: fromCategories)
|
|
})
|
|
]), ActionSheetItemGroup(items: [
|
|
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
|
|
actionSheet?.dismissAnimated()
|
|
})
|
|
])])
|
|
self.controller?()?.present(actionSheet, in: .window(.root))
|
|
}
|
|
|
|
private func commitClear(fromCategories: Bool) {
|
|
guard let component = self.component, let aggregatedData = self.aggregatedData else {
|
|
return
|
|
}
|
|
|
|
if let _ = aggregatedData.peerId {
|
|
var mappedCategories: [StorageUsageStats.CategoryKey] = []
|
|
for category in aggregatedData.selectedCategories {
|
|
switch category {
|
|
case .photos:
|
|
mappedCategories.append(.photos)
|
|
case .videos:
|
|
mappedCategories.append(.videos)
|
|
case .files:
|
|
mappedCategories.append(.files)
|
|
case .music:
|
|
mappedCategories.append(.music)
|
|
case .other:
|
|
break
|
|
case .stickers:
|
|
mappedCategories.append(.stickers)
|
|
case .avatars:
|
|
mappedCategories.append(.avatars)
|
|
case .misc:
|
|
mappedCategories.append(.misc)
|
|
case .stories:
|
|
mappedCategories.append(.stories)
|
|
}
|
|
}
|
|
|
|
self.isClearing = true
|
|
self.state?.updated(transition: .immediate)
|
|
|
|
let totalSize = aggregatedData.selectedSize
|
|
|
|
let _ = (component.context.engine.resources.clearStorage(peerId: component.peer?.id, categories: mappedCategories, includeMessages: aggregatedData.clearIncludeMessages, excludeMessages: aggregatedData.clearExcludeMessages)
|
|
|> deliverOnMainQueue).start(next: { [weak self] progress in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.updateClearProgress(progress: progress)
|
|
}, completed: { [weak self] in
|
|
guard let self, let _ = self.component else {
|
|
return
|
|
}
|
|
|
|
self.reloadStats(firstTime: false, completion: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if totalSize != 0 {
|
|
self.reportClearedStorage(size: totalSize)
|
|
}
|
|
})
|
|
})
|
|
} else {
|
|
if fromCategories {
|
|
var mappedCategories: [StorageUsageStats.CategoryKey] = []
|
|
for category in aggregatedData.selectedCategories {
|
|
switch category {
|
|
case .photos:
|
|
mappedCategories.append(.photos)
|
|
case .videos:
|
|
mappedCategories.append(.videos)
|
|
case .files:
|
|
mappedCategories.append(.files)
|
|
case .music:
|
|
mappedCategories.append(.music)
|
|
case .other:
|
|
break
|
|
case .stickers:
|
|
mappedCategories.append(.stickers)
|
|
case .avatars:
|
|
mappedCategories.append(.avatars)
|
|
case .misc:
|
|
mappedCategories.append(.misc)
|
|
case .stories:
|
|
mappedCategories.append(.stories)
|
|
}
|
|
}
|
|
|
|
self.isClearing = true
|
|
self.state?.updated(transition: .immediate)
|
|
|
|
var totalSize: Int64 = 0
|
|
|
|
let contextStats = aggregatedData.contextStats
|
|
|
|
for category in aggregatedData.selectedCategories {
|
|
let mappedCategory: StorageUsageStats.CategoryKey
|
|
switch category {
|
|
case .photos:
|
|
mappedCategory = .photos
|
|
case .videos:
|
|
mappedCategory = .videos
|
|
case .files:
|
|
mappedCategory = .files
|
|
case .music:
|
|
mappedCategory = .music
|
|
case .other:
|
|
continue
|
|
case .stickers:
|
|
mappedCategory = .stickers
|
|
case .avatars:
|
|
mappedCategory = .avatars
|
|
case .misc:
|
|
mappedCategory = .misc
|
|
case .stories:
|
|
mappedCategory = .stories
|
|
}
|
|
|
|
if let value = contextStats.categories[mappedCategory] {
|
|
totalSize += value.size
|
|
}
|
|
}
|
|
|
|
let _ = (component.context.engine.resources.clearStorage(peerId: component.peer?.id, categories: mappedCategories, includeMessages: [], excludeMessages: [])
|
|
|> deliverOnMainQueue).start(next: { [weak self] progress in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.updateClearProgress(progress: progress)
|
|
}, completed: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
self.reloadStats(firstTime: false, completion: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if totalSize != 0 {
|
|
self.reportClearedStorage(size: totalSize)
|
|
}
|
|
})
|
|
})
|
|
} else {
|
|
self.isClearing = true
|
|
self.state?.updated(transition: .immediate)
|
|
|
|
var totalSize: Int64 = 0
|
|
if let peerItems = aggregatedData.peerItems {
|
|
for item in peerItems.items {
|
|
if aggregatedData.selectionState.selectedPeers.contains(item.peer.id) {
|
|
totalSize += item.size
|
|
}
|
|
}
|
|
}
|
|
|
|
var includeMessages: [Message] = []
|
|
var excludeMessages: [Message] = []
|
|
|
|
for (id, message) in aggregatedData.messages {
|
|
if aggregatedData.selectionState.selectedPeers.contains(id.peerId) {
|
|
if !aggregatedData.selectionState.selectedMessages.contains(id) {
|
|
excludeMessages.append(message)
|
|
}
|
|
} else {
|
|
if aggregatedData.selectionState.selectedMessages.contains(id) {
|
|
includeMessages.append(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
let _ = (component.context.engine.resources.clearStorage(peerIds: aggregatedData.selectionState.selectedPeers, includeMessages: includeMessages, excludeMessages: excludeMessages)
|
|
|> deliverOnMainQueue).start(next: { [weak self] progress in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.updateClearProgress(progress: progress)
|
|
}, completed: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
self.reloadStats(firstTime: false, completion: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if totalSize != 0 {
|
|
self.reportClearedStorage(size: totalSize)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateClearProgress(progress: Float) {
|
|
if let clearingNode = self.clearingNode {
|
|
clearingNode.setProgress(progress)
|
|
}
|
|
}
|
|
|
|
private func openKeepMediaCategory(mappedCategory: CacheStorageSettings.PeerStorageCategory, sourceView: StoragePeerTypeItemComponent.View) {
|
|
guard let component = self.component else {
|
|
return
|
|
}
|
|
let context = component.context
|
|
let makeStorageUsageExceptionsScreen = component.makeStorageUsageExceptionsScreen
|
|
|
|
let pushControllerImpl: ((ViewController) -> Void)? = { [weak self] c in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.controller?()?.push(c)
|
|
}
|
|
let presentInGlobalOverlay: ((ViewController) -> Void)? = { [weak self] c in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.controller?()?.presentInGlobalOverlay(c, with: nil)
|
|
}
|
|
|
|
let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings]))
|
|
let accountSpecificSettings: Signal<AccountSpecificCacheStorageSettings, NoError> = context.account.postbox.combinedView(keys: [viewKey])
|
|
|> map { views -> AccountSpecificCacheStorageSettings in
|
|
let cacheSettings: AccountSpecificCacheStorageSettings
|
|
if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) {
|
|
cacheSettings = value
|
|
} else {
|
|
cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings
|
|
}
|
|
|
|
return cacheSettings
|
|
}
|
|
|> distinctUntilChanged
|
|
|
|
let peerExceptions: Signal<[(peer: FoundPeer, value: Int32)], NoError> = accountSpecificSettings
|
|
|> mapToSignal { accountSpecificSettings -> Signal<[(peer: FoundPeer, value: Int32)], NoError> in
|
|
return context.account.postbox.transaction { transaction -> [(peer: FoundPeer, value: Int32)] in
|
|
var result: [(peer: FoundPeer, value: Int32)] = []
|
|
|
|
for item in accountSpecificSettings.peerStorageTimeoutExceptions {
|
|
let peerId = item.key
|
|
let value = item.value
|
|
|
|
guard let peer = transaction.getPeer(peerId) else {
|
|
continue
|
|
}
|
|
let peerCategory: CacheStorageSettings.PeerStorageCategory
|
|
var subscriberCount: Int32?
|
|
if peer is TelegramUser {
|
|
peerCategory = .privateChats
|
|
} else if peer is TelegramGroup {
|
|
peerCategory = .groups
|
|
|
|
if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedGroupData {
|
|
subscriberCount = (cachedData.participants?.participants.count).flatMap(Int32.init)
|
|
}
|
|
} else if let channel = peer as? TelegramChannel {
|
|
if case .group = channel.info {
|
|
peerCategory = .groups
|
|
} else {
|
|
peerCategory = .channels
|
|
}
|
|
if peerCategory == mappedCategory {
|
|
if let cachedData = transaction.getPeerCachedData(peerId: peerId) as? CachedChannelData {
|
|
subscriberCount = cachedData.participantsSummary.memberCount
|
|
}
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
if peerCategory != mappedCategory {
|
|
continue
|
|
}
|
|
|
|
result.append((peer: FoundPeer(peer: peer, subscribers: subscriberCount), value: value))
|
|
}
|
|
|
|
return result.sorted(by: { lhs, rhs in
|
|
if lhs.value != rhs.value {
|
|
return lhs.value < rhs.value
|
|
}
|
|
return lhs.peer.peer.debugDisplayTitle < rhs.peer.peer.debugDisplayTitle
|
|
})
|
|
}
|
|
}
|
|
|
|
let cacheSettings = context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings])
|
|
|> map { sharedData -> CacheStorageSettings in
|
|
let cacheSettings: CacheStorageSettings
|
|
if let value = sharedData.entries[SharedDataKeys.cacheStorageSettings]?.get(CacheStorageSettings.self) {
|
|
cacheSettings = value
|
|
} else {
|
|
cacheSettings = CacheStorageSettings.defaultSettings
|
|
}
|
|
|
|
return cacheSettings
|
|
}
|
|
|
|
let _ = (combineLatest(
|
|
cacheSettings |> take(1),
|
|
peerExceptions |> take(1)
|
|
)
|
|
|> deliverOnMainQueue).start(next: { cacheSettings, peerExceptions in
|
|
let currentValue: Int32 = cacheSettings.categoryStorageTimeout[mappedCategory] ?? Int32.max
|
|
|
|
let applyValue: (Int32) -> Void = { value in
|
|
let _ = updateCacheStorageSettingsInteractively(accountManager: context.sharedContext.accountManager, { cacheSettings in
|
|
var cacheSettings = cacheSettings
|
|
cacheSettings.categoryStorageTimeout[mappedCategory] = value
|
|
return cacheSettings
|
|
}).start()
|
|
}
|
|
|
|
var subItems: [ContextMenuItem] = []
|
|
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
|
|
|
var presetValues: [Int32]
|
|
// MARK: Swiftgram
|
|
if case .stories = mappedCategory {
|
|
presetValues = [
|
|
7 * 24 * 60 * 60,
|
|
2 * 24 * 60 * 60,
|
|
1 * 24 * 60 * 60,
|
|
1 * 60 * 60
|
|
]
|
|
} else {
|
|
presetValues = [
|
|
Int32.max,
|
|
31 * 24 * 60 * 60,
|
|
7 * 24 * 60 * 60,
|
|
1 * 24 * 60 * 60,
|
|
1 * 60 * 60
|
|
]
|
|
}
|
|
|
|
if currentValue != 0 && !presetValues.contains(currentValue) {
|
|
presetValues.append(currentValue)
|
|
presetValues.sort(by: >)
|
|
}
|
|
|
|
for value in presetValues {
|
|
let optionText: String
|
|
if value == Int32.max {
|
|
optionText = presentationData.strings.ClearCache_Never
|
|
} else {
|
|
optionText = timeIntervalString(strings: presentationData.strings, value: value)
|
|
}
|
|
subItems.append(.action(ContextMenuActionItem(text: optionText, icon: { theme in
|
|
if currentValue == value {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor)
|
|
} else {
|
|
return nil
|
|
}
|
|
}, action: { _, f in
|
|
applyValue(value)
|
|
f(.default)
|
|
})))
|
|
}
|
|
|
|
subItems.append(.separator)
|
|
|
|
if mappedCategory != .stories {
|
|
if peerExceptions.isEmpty {
|
|
let exceptionsText = presentationData.strings.GroupInfo_Permissions_AddException
|
|
subItems.append(.action(ContextMenuActionItem(text: exceptionsText, icon: { theme in
|
|
if case .privateChats = mappedCategory {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor)
|
|
} else {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Location/CreateGroupIcon"), color: theme.contextMenu.primaryColor)
|
|
}
|
|
}, action: { _, f in
|
|
f(.default)
|
|
|
|
if let exceptionsController = makeStorageUsageExceptionsScreen(mappedCategory) {
|
|
pushControllerImpl?(exceptionsController)
|
|
}
|
|
})))
|
|
} else {
|
|
subItems.append(.custom(MultiplePeerAvatarsContextItem(context: context, peers: peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }, totalCount: peerExceptions.count, action: { c, _ in
|
|
c.dismiss(completion: {
|
|
|
|
})
|
|
if let exceptionsController = makeStorageUsageExceptionsScreen(mappedCategory) {
|
|
pushControllerImpl?(exceptionsController)
|
|
}
|
|
}), false))
|
|
}
|
|
}
|
|
|
|
if case .separator = subItems.last {
|
|
subItems.removeLast()
|
|
}
|
|
|
|
if let sourceLabelView = sourceView.labelView {
|
|
let items: Signal<ContextController.Items, NoError> = .single(ContextController.Items(content: .list(subItems)))
|
|
let source: ContextContentSource = .reference(StorageUsageContextReferenceContentSource(sourceView: sourceLabelView))
|
|
|
|
let contextController = ContextController(
|
|
presentationData: presentationData,
|
|
source: source,
|
|
items: items,
|
|
gesture: nil
|
|
)
|
|
sourceView.setHasAssociatedMenu(true)
|
|
contextController.dismissed = { [weak sourceView] in
|
|
sourceView?.setHasAssociatedMenu(false)
|
|
}
|
|
presentInGlobalOverlay?(contextController)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
public final class StorageUsageScreen: ViewControllerComponentContainer {
|
|
private let context: AccountContext
|
|
|
|
private let readyValue = Promise<Bool>()
|
|
override public var ready: Promise<Bool> {
|
|
return self.readyValue
|
|
}
|
|
|
|
fileprivate var childCompleted: ((@escaping () -> Void) -> Void)?
|
|
|
|
public init(context: AccountContext, makeStorageUsageExceptionsScreen: @escaping (CacheStorageSettings.PeerStorageCategory) -> ViewController?, peer: EnginePeer? = nil) {
|
|
self.context = context
|
|
|
|
let componentReady = Promise<Bool>()
|
|
super.init(context: context, component: StorageUsageScreenComponent(context: context, makeStorageUsageExceptionsScreen: makeStorageUsageExceptionsScreen, peer: peer, ready: componentReady), navigationBarAppearance: .transparent)
|
|
|
|
if peer != nil {
|
|
self.navigationPresentation = .modal
|
|
}
|
|
|
|
self.readyValue.set(componentReady.get() |> timeout(0.3, queue: .mainQueue(), alternate: .single(true)))
|
|
}
|
|
|
|
required public init(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override public func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
}
|
|
}
|
|
|
|
private final class StorageUsageContextReferenceContentSource: ContextReferenceContentSource {
|
|
private let sourceView: UIView
|
|
|
|
init(sourceView: UIView) {
|
|
self.sourceView = sourceView
|
|
}
|
|
|
|
func transitionInfo() -> ContextControllerReferenceViewInfo? {
|
|
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, insets: UIEdgeInsets(top: -4.0, left: 0.0, bottom: -4.0, right: 0.0))
|
|
}
|
|
}
|
|
|
|
final class MultiplePeerAvatarsContextItem: ContextMenuCustomItem {
|
|
fileprivate let context: AccountContext
|
|
fileprivate let peers: [EnginePeer]
|
|
fileprivate let totalCount: Int
|
|
fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void
|
|
|
|
init(context: AccountContext, peers: [EnginePeer], totalCount: Int, action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void) {
|
|
self.context = context
|
|
self.peers = peers
|
|
self.totalCount = totalCount
|
|
self.action = action
|
|
}
|
|
|
|
func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode {
|
|
return MultiplePeerAvatarsContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected)
|
|
}
|
|
}
|
|
|
|
private final class MultiplePeerAvatarsContextItemNode: ASDisplayNode, ContextMenuCustomNode, ContextActionNodeProtocol {
|
|
private let item: MultiplePeerAvatarsContextItem
|
|
private var presentationData: PresentationData
|
|
private let getController: () -> ContextControllerProtocol?
|
|
private let actionSelected: (ContextMenuActionResult) -> Void
|
|
|
|
private let backgroundNode: ASDisplayNode
|
|
private let highlightedBackgroundNode: ASDisplayNode
|
|
private let textNode: ImmediateTextNode
|
|
|
|
private let avatarsNode: AnimatedAvatarSetNode
|
|
private let avatarsContext: AnimatedAvatarSetContext
|
|
|
|
private let buttonNode: HighlightTrackingButtonNode
|
|
|
|
private var pointerInteraction: PointerInteraction?
|
|
|
|
init(presentationData: PresentationData, item: MultiplePeerAvatarsContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) {
|
|
self.item = item
|
|
self.presentationData = presentationData
|
|
self.getController = getController
|
|
self.actionSelected = actionSelected
|
|
|
|
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
|
|
|
|
self.backgroundNode = ASDisplayNode()
|
|
self.backgroundNode.isAccessibilityElement = false
|
|
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
|
|
self.highlightedBackgroundNode = ASDisplayNode()
|
|
self.highlightedBackgroundNode.isAccessibilityElement = false
|
|
self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
|
|
self.highlightedBackgroundNode.alpha = 0.0
|
|
|
|
self.textNode = ImmediateTextNode()
|
|
self.textNode.isAccessibilityElement = false
|
|
self.textNode.isUserInteractionEnabled = false
|
|
self.textNode.displaysAsynchronously = false
|
|
self.textNode.attributedText = NSAttributedString(string: " ", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
|
|
self.textNode.maximumNumberOfLines = 1
|
|
|
|
self.buttonNode = HighlightTrackingButtonNode()
|
|
self.buttonNode.isAccessibilityElement = true
|
|
self.buttonNode.accessibilityLabel = presentationData.strings.VoiceChat_StopRecording
|
|
|
|
self.avatarsNode = AnimatedAvatarSetNode()
|
|
self.avatarsContext = AnimatedAvatarSetContext()
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.backgroundNode)
|
|
self.addSubnode(self.highlightedBackgroundNode)
|
|
self.addSubnode(self.textNode)
|
|
self.addSubnode(self.avatarsNode)
|
|
self.addSubnode(self.buttonNode)
|
|
|
|
self.buttonNode.highligthedChanged = { [weak self] highligted in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if highligted {
|
|
strongSelf.highlightedBackgroundNode.alpha = 1.0
|
|
} else {
|
|
strongSelf.highlightedBackgroundNode.alpha = 0.0
|
|
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
|
|
}
|
|
}
|
|
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
|
|
self.buttonNode.isUserInteractionEnabled = true
|
|
}
|
|
|
|
deinit {
|
|
}
|
|
|
|
override func didLoad() {
|
|
super.didLoad()
|
|
|
|
self.pointerInteraction = PointerInteraction(node: self.buttonNode, style: .hover, willEnter: { [weak self] in
|
|
if let strongSelf = self {
|
|
strongSelf.highlightedBackgroundNode.alpha = 0.75
|
|
}
|
|
}, willExit: { [weak self] in
|
|
if let strongSelf = self {
|
|
strongSelf.highlightedBackgroundNode.alpha = 0.0
|
|
}
|
|
})
|
|
}
|
|
|
|
private var validLayout: (calculatedWidth: CGFloat, size: CGSize)?
|
|
|
|
func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
|
|
let sideInset: CGFloat = 14.0
|
|
let verticalInset: CGFloat = 12.0
|
|
|
|
let rightTextInset: CGFloat = sideInset + 36.0
|
|
|
|
let calculatedWidth = min(constrainedWidth, 250.0)
|
|
|
|
let textFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize)
|
|
let text: String = self.presentationData.strings.CacheEvictionMenu_CategoryExceptions(Int32(self.item.totalCount))
|
|
self.textNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: self.presentationData.theme.contextMenu.primaryColor)
|
|
|
|
let textSize = self.textNode.updateLayout(CGSize(width: calculatedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude))
|
|
|
|
let combinedTextHeight = textSize.height
|
|
return (CGSize(width: calculatedWidth, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in
|
|
self.validLayout = (calculatedWidth: calculatedWidth, size: size)
|
|
let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0)
|
|
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalOrigin), size: textSize)
|
|
transition.updateFrameAdditive(node: self.textNode, frame: textFrame)
|
|
|
|
let avatarsContent: AnimatedAvatarSetContext.Content
|
|
|
|
let avatarsPeers: [EnginePeer] = self.item.peers
|
|
|
|
avatarsContent = self.avatarsContext.update(peers: avatarsPeers, animated: false)
|
|
|
|
let avatarsSize = self.avatarsNode.update(context: self.item.context, content: avatarsContent, itemSize: CGSize(width: 24.0, height: 24.0), customSpacing: 10.0, animated: false, synchronousLoad: true)
|
|
self.avatarsNode.frame = CGRect(origin: CGPoint(x: size.width - sideInset - 12.0 - avatarsSize.width, y: floor((size.height - avatarsSize.height) / 2.0)), size: avatarsSize)
|
|
|
|
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
|
|
transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
|
|
transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
|
|
})
|
|
}
|
|
|
|
func updateTheme(presentationData: PresentationData) {
|
|
self.presentationData = presentationData
|
|
|
|
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
|
|
self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
|
|
|
|
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
|
|
|
|
self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
|
|
}
|
|
|
|
@objc private func buttonPressed() {
|
|
self.performAction()
|
|
}
|
|
|
|
private var actionTemporarilyDisabled: Bool = false
|
|
|
|
func canBeHighlighted() -> Bool {
|
|
return self.isActionEnabled
|
|
}
|
|
|
|
func updateIsHighlighted(isHighlighted: Bool) {
|
|
self.setIsHighlighted(isHighlighted)
|
|
}
|
|
|
|
func performAction() {
|
|
if self.actionTemporarilyDisabled {
|
|
return
|
|
}
|
|
self.actionTemporarilyDisabled = true
|
|
Queue.mainQueue().async { [weak self] in
|
|
self?.actionTemporarilyDisabled = false
|
|
}
|
|
|
|
guard let controller = self.getController() else {
|
|
return
|
|
}
|
|
self.item.action(controller, { [weak self] result in
|
|
self?.actionSelected(result)
|
|
})
|
|
}
|
|
|
|
var isActionEnabled: Bool {
|
|
return true
|
|
}
|
|
|
|
func setIsHighlighted(_ value: Bool) {
|
|
if value {
|
|
self.highlightedBackgroundNode.alpha = 1.0
|
|
} else {
|
|
self.highlightedBackgroundNode.alpha = 0.0
|
|
}
|
|
}
|
|
|
|
func actionNode(at point: CGPoint) -> ContextActionNodeProtocol {
|
|
return self
|
|
}
|
|
}
|
|
|
|
private class StorageUsageClearProgressOverlayNode: ASDisplayNode {
|
|
private let presentationData: PresentationData
|
|
|
|
private let blurredView: BlurredBackgroundView
|
|
private let animationNode: AnimatedStickerNode
|
|
private let progressTextNode: ImmediateTextNode
|
|
private let descriptionTextNode: ImmediateTextNode
|
|
private let progressBackgroundNode: ASDisplayNode
|
|
private let progressForegroundNode: ASDisplayNode
|
|
|
|
private let progressDisposable = MetaDisposable()
|
|
|
|
private var validLayout: (CGSize, CGFloat)?
|
|
|
|
init(presentationData: PresentationData) {
|
|
self.presentationData = presentationData
|
|
|
|
self.blurredView = BlurredBackgroundView(color: presentationData.theme.list.plainBackgroundColor.withMultipliedAlpha(0.7), enableBlur: true)
|
|
|
|
self.animationNode = DefaultAnimatedStickerNodeImpl()
|
|
self.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: "ClearCache"), width: 256, height: 256, playbackMode: .loop, mode: .direct(cachePathPrefix: nil))
|
|
self.animationNode.visibility = true
|
|
|
|
self.progressTextNode = ImmediateTextNode()
|
|
self.progressTextNode.textAlignment = .center
|
|
|
|
self.descriptionTextNode = ImmediateTextNode()
|
|
self.descriptionTextNode.textAlignment = .center
|
|
self.descriptionTextNode.maximumNumberOfLines = 0
|
|
|
|
self.progressBackgroundNode = ASDisplayNode()
|
|
self.progressBackgroundNode.backgroundColor = self.presentationData.theme.actionSheet.controlAccentColor.withMultipliedAlpha(0.2)
|
|
self.progressBackgroundNode.cornerRadius = 3.0
|
|
|
|
self.progressForegroundNode = ASDisplayNode()
|
|
self.progressForegroundNode.backgroundColor = self.presentationData.theme.actionSheet.controlAccentColor
|
|
self.progressForegroundNode.cornerRadius = 3.0
|
|
|
|
super.init()
|
|
|
|
self.view.addSubview(self.blurredView)
|
|
self.addSubnode(self.animationNode)
|
|
self.addSubnode(self.progressTextNode)
|
|
self.addSubnode(self.descriptionTextNode)
|
|
self.addSubnode(self.progressBackgroundNode)
|
|
self.addSubnode(self.progressForegroundNode)
|
|
}
|
|
|
|
deinit {
|
|
self.progressDisposable.dispose()
|
|
}
|
|
|
|
func setProgressSignal(_ signal: Signal<Float, NoError>) {
|
|
self.progressDisposable.set((signal
|
|
|> deliverOnMainQueue).start(next: { [weak self] progress in
|
|
if let strongSelf = self {
|
|
strongSelf.setProgress(progress)
|
|
}
|
|
}))
|
|
}
|
|
|
|
private var progress: Float = 0.0
|
|
func setProgress(_ progress: Float) {
|
|
self.progress = progress
|
|
|
|
if let (size, bottomInset) = self.validLayout {
|
|
self.updateLayout(size: size, bottomInset: bottomInset, transition: .animated(duration: 0.5, curve: .linear))
|
|
}
|
|
}
|
|
|
|
func updateLayout(size: CGSize, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
self.validLayout = (size, bottomInset)
|
|
|
|
transition.updateFrame(view: self.blurredView, frame: CGRect(origin: CGPoint(), size: size))
|
|
self.blurredView.update(size: size, transition: transition)
|
|
|
|
let inset: CGFloat = 24.0
|
|
let progressHeight: CGFloat = 6.0
|
|
let spacing: CGFloat = 16.0
|
|
|
|
let imageSide = min(160.0, size.height - 30.0)
|
|
let imageSize = CGSize(width: imageSide, height: imageSide)
|
|
|
|
let animationFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floorToScreenPixels((size.height - imageSize.height) / 2.0) - 50.0), size: imageSize)
|
|
self.animationNode.frame = animationFrame
|
|
self.animationNode.updateLayout(size: imageSize)
|
|
|
|
var bottomInset = bottomInset
|
|
if bottomInset.isZero {
|
|
bottomInset = inset
|
|
}
|
|
|
|
let progressFrame = CGRect(x: inset, y: size.height - bottomInset - progressHeight, width: size.width - inset * 2.0, height: progressHeight)
|
|
self.progressBackgroundNode.frame = progressFrame
|
|
let progressForegroundFrame = CGRect(x: inset, y: size.height - bottomInset - progressHeight, width: floorToScreenPixels(progressFrame.width * CGFloat(self.progress)), height: progressHeight)
|
|
if !self.progressForegroundNode.frame.origin.x.isZero {
|
|
transition.updateFrame(node: self.progressForegroundNode, frame: progressForegroundFrame, beginWithCurrentState: true)
|
|
} else {
|
|
self.progressForegroundNode.frame = progressForegroundFrame
|
|
}
|
|
|
|
self.descriptionTextNode.attributedText = NSAttributedString(string: self.presentationData.strings.ClearCache_KeepOpenedDescription, font: Font.regular(15.0), textColor: self.presentationData.theme.actionSheet.secondaryTextColor)
|
|
let descriptionTextSize = self.descriptionTextNode.updateLayout(CGSize(width: size.width - inset * 3.0, height: size.height))
|
|
var descriptionTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - descriptionTextSize.width) / 2.0), y: animationFrame.maxY + 52.0), size: descriptionTextSize)
|
|
|
|
let progressText: String = "\(Int(self.progress * 100.0))%"
|
|
|
|
self.progressTextNode.attributedText = NSAttributedString(string: progressText, font: Font.with(size: 17.0, design: .regular, weight: .semibold, traits: [.monospacedNumbers]), textColor: self.presentationData.theme.actionSheet.primaryTextColor)
|
|
let progressTextSize = self.progressTextNode.updateLayout(size)
|
|
var progressTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - progressTextSize.width) / 2.0), y: descriptionTextFrame.minY - spacing - progressTextSize.height), size: progressTextSize)
|
|
|
|
let availableHeight = progressTextFrame.minY
|
|
if availableHeight < 100.0 {
|
|
let offset = availableHeight / 2.0 - spacing
|
|
descriptionTextFrame = descriptionTextFrame.offsetBy(dx: 0.0, dy: -offset)
|
|
progressTextFrame = progressTextFrame.offsetBy(dx: 0.0, dy: -offset)
|
|
self.animationNode.alpha = 0.0
|
|
} else {
|
|
self.animationNode.alpha = 1.0
|
|
}
|
|
|
|
self.progressTextNode.frame = progressTextFrame
|
|
self.descriptionTextNode.frame = descriptionTextFrame
|
|
}
|
|
}
|
|
|
|
private final class StorageUsageListContextGalleryContentSourceImpl: ContextControllerContentSource {
|
|
let controller: ViewController
|
|
weak var sourceView: UIView?
|
|
let sourceRect: CGRect
|
|
|
|
let navigationController: NavigationController? = nil
|
|
|
|
let passthroughTouches: Bool
|
|
|
|
init(controller: ViewController, sourceView: UIView?, sourceRect: CGRect = CGRect(origin: CGPoint(), size: CGSize()), passthroughTouches: Bool = false) {
|
|
self.controller = controller
|
|
self.sourceView = sourceView
|
|
self.sourceRect = sourceRect
|
|
self.passthroughTouches = passthroughTouches
|
|
}
|
|
|
|
func transitionInfo() -> ContextControllerTakeControllerInfo? {
|
|
let sourceView = self.sourceView
|
|
let sourceRect = self.sourceRect
|
|
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceView] in
|
|
if let sourceView = sourceView {
|
|
let rect = sourceRect.isEmpty ? sourceView.bounds : sourceRect
|
|
return (sourceView, rect)
|
|
} else {
|
|
return nil
|
|
}
|
|
})
|
|
}
|
|
|
|
func animatedIn() {
|
|
self.controller.didAppearInContextPreview()
|
|
}
|
|
}
|
|
|
|
private final class StorageUsageListContextExtractedContentSource: ContextExtractedContentSource {
|
|
let keepInPlace: Bool = false
|
|
let ignoreContentTouches: Bool = false
|
|
let blurBackground: Bool = true
|
|
|
|
//let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center
|
|
|
|
private let contentView: ContextExtractedContentContainingView
|
|
|
|
init(contentView: ContextExtractedContentContainingView) {
|
|
self.contentView = contentView
|
|
}
|
|
|
|
func takeView() -> ContextControllerTakeViewInfo? {
|
|
return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds)
|
|
}
|
|
|
|
func putBack() -> ContextControllerPutBackViewInfo? {
|
|
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
|
|
}
|
|
}
|