mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-11-07 17:30:12 +00:00
Storage calculation and UI improvements
This commit is contained in:
parent
d4a3568686
commit
eb1947b8b3
@ -19,14 +19,24 @@ private func md5Hash(_ data: Data) -> HashId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public final class StorageBox {
|
public final class StorageBox {
|
||||||
public struct Stats {
|
public final class Stats {
|
||||||
public var contentTypes: [UInt8: Int64]
|
public fileprivate(set) var contentTypes: [UInt8: Int64]
|
||||||
|
|
||||||
public init(contentTypes: [UInt8: Int64]) {
|
public init(contentTypes: [UInt8: Int64]) {
|
||||||
self.contentTypes = contentTypes
|
self.contentTypes = contentTypes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public final class AllStats {
|
||||||
|
public fileprivate(set) var total: Stats
|
||||||
|
public fileprivate(set) var peers: [PeerId: Stats]
|
||||||
|
|
||||||
|
public init(total: Stats, peers: [PeerId: Stats]) {
|
||||||
|
self.total = total
|
||||||
|
self.peers = peers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct Reference {
|
public struct Reference {
|
||||||
public var peerId: Int64
|
public var peerId: Int64
|
||||||
public var messageNamespace: UInt8
|
public var messageNamespace: UInt8
|
||||||
@ -122,6 +132,10 @@ public final class StorageBox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct Metadata: Codable {
|
||||||
|
var version: Int32
|
||||||
|
}
|
||||||
|
|
||||||
private final class Impl {
|
private final class Impl {
|
||||||
let queue: Queue
|
let queue: Queue
|
||||||
let logger: StorageBox.Logger
|
let logger: StorageBox.Logger
|
||||||
@ -133,6 +147,7 @@ public final class StorageBox {
|
|||||||
let peerIdTable: ValueBoxTable
|
let peerIdTable: ValueBoxTable
|
||||||
let peerContentTypeStatsTable: ValueBoxTable
|
let peerContentTypeStatsTable: ValueBoxTable
|
||||||
let contentTypeStatsTable: ValueBoxTable
|
let contentTypeStatsTable: ValueBoxTable
|
||||||
|
let metadataTable: ValueBoxTable
|
||||||
|
|
||||||
init(queue: Queue, logger: StorageBox.Logger, basePath: String) {
|
init(queue: Queue, logger: StorageBox.Logger, basePath: String) {
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
@ -157,6 +172,54 @@ public final class StorageBox {
|
|||||||
self.peerIdTable = ValueBoxTable(id: 18, keyType: .binary, compactValuesOnCreation: true)
|
self.peerIdTable = ValueBoxTable(id: 18, keyType: .binary, compactValuesOnCreation: true)
|
||||||
self.peerContentTypeStatsTable = ValueBoxTable(id: 19, keyType: .binary, compactValuesOnCreation: true)
|
self.peerContentTypeStatsTable = ValueBoxTable(id: 19, keyType: .binary, compactValuesOnCreation: true)
|
||||||
self.contentTypeStatsTable = ValueBoxTable(id: 20, keyType: .binary, compactValuesOnCreation: true)
|
self.contentTypeStatsTable = ValueBoxTable(id: 20, keyType: .binary, compactValuesOnCreation: true)
|
||||||
|
self.metadataTable = ValueBoxTable(id: 21, keyType: .binary, compactValuesOnCreation: true)
|
||||||
|
|
||||||
|
self.performUpdatesIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performUpdatesIfNeeded() {
|
||||||
|
self.valueBox.begin()
|
||||||
|
|
||||||
|
let mainMetadataKey = ValueBoxKey(length: 2)
|
||||||
|
mainMetadataKey.setUInt8(0, value: 0)
|
||||||
|
mainMetadataKey.setUInt8(1, value: 0)
|
||||||
|
|
||||||
|
var metadata: Metadata
|
||||||
|
if let value = self.valueBox.get(self.metadataTable, key: mainMetadataKey), let parsedValue = try? JSONDecoder().decode(Metadata.self, from: value.makeData()) {
|
||||||
|
metadata = parsedValue
|
||||||
|
} else {
|
||||||
|
metadata = Metadata(version: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.version != 2 {
|
||||||
|
self.reindexPeerStats()
|
||||||
|
|
||||||
|
metadata.version = 2
|
||||||
|
if let data = try? JSONEncoder().encode(metadata) {
|
||||||
|
self.valueBox.set(self.metadataTable, key: mainMetadataKey, value: MemoryBuffer(data: data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.valueBox.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reindexPeerStats() {
|
||||||
|
self.valueBox.removeAllFromTable(self.peerContentTypeStatsTable)
|
||||||
|
|
||||||
|
let mainKey = ValueBoxKey(length: 16)
|
||||||
|
self.valueBox.scan(self.peerIdToIdTable, keys: { key in
|
||||||
|
let peerId = key.getInt64(0)
|
||||||
|
let hashId = key.getData(8, length: 16)
|
||||||
|
|
||||||
|
mainKey.setData(0, value: hashId)
|
||||||
|
|
||||||
|
if let currentInfoValue = self.valueBox.get(self.hashIdToInfoTable, key: mainKey) {
|
||||||
|
let info = ItemInfo(buffer: currentInfoValue)
|
||||||
|
self.internalAddSize(peerId: peerId, contentType: info.contentType, delta: info.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func internalAddSize(contentType: UInt8, delta: Int64) {
|
private func internalAddSize(contentType: UInt8, delta: Int64) {
|
||||||
@ -171,7 +234,7 @@ public final class StorageBox {
|
|||||||
currentSize += delta
|
currentSize += delta
|
||||||
|
|
||||||
if currentSize < 0 {
|
if currentSize < 0 {
|
||||||
assertionFailure()
|
//assertionFailure()
|
||||||
currentSize = 0
|
currentSize = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,18 +247,18 @@ public final class StorageBox {
|
|||||||
key.setUInt8(8, value: contentType)
|
key.setUInt8(8, value: contentType)
|
||||||
|
|
||||||
var currentSize: Int64 = 0
|
var currentSize: Int64 = 0
|
||||||
if let value = self.valueBox.get(self.contentTypeStatsTable, key: key) {
|
if let value = self.valueBox.get(self.peerContentTypeStatsTable, key: key) {
|
||||||
value.read(¤tSize, offset: 0, length: 8)
|
value.read(¤tSize, offset: 0, length: 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSize += delta
|
currentSize += delta
|
||||||
|
|
||||||
if currentSize < 0 {
|
if currentSize < 0 {
|
||||||
assertionFailure()
|
//assertionFailure()
|
||||||
currentSize = 0
|
currentSize = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
self.valueBox.set(self.contentTypeStatsTable, key: key, value: MemoryBuffer(memory: ¤tSize, capacity: 8, length: 8, freeWhenDone: false))
|
self.valueBox.set(self.peerContentTypeStatsTable, key: key, value: MemoryBuffer(memory: ¤tSize, capacity: 8, length: 8, freeWhenDone: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(reference: Reference, to id: Data, contentType: UInt8) {
|
func add(reference: Reference, to id: Data, contentType: UInt8) {
|
||||||
@ -224,6 +287,10 @@ public final class StorageBox {
|
|||||||
if size != 0 {
|
if size != 0 {
|
||||||
self.internalAddSize(contentType: previousContentType, delta: -size)
|
self.internalAddSize(contentType: previousContentType, delta: -size)
|
||||||
self.internalAddSize(contentType: contentType, delta: size)
|
self.internalAddSize(contentType: contentType, delta: size)
|
||||||
|
|
||||||
|
for peerId in self.peerIdsReferencing(hashId: hashId) {
|
||||||
|
self.internalAddSize(peerId: peerId, contentType: previousContentType, delta: -size)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,9 +341,31 @@ public final class StorageBox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let previousContentType = previousContentType, previousContentType != contentType {
|
||||||
|
if size != 0 {
|
||||||
|
for peerId in self.peerIdsReferencing(hashId: hashId) {
|
||||||
|
self.internalAddSize(peerId: peerId, contentType: contentType, delta: size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.valueBox.commit()
|
self.valueBox.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func peerIdsReferencing(hashId: HashId) -> Set<Int64> {
|
||||||
|
let mainKey = ValueBoxKey(length: 16)
|
||||||
|
mainKey.setData(0, value: hashId.data)
|
||||||
|
|
||||||
|
var peerIds = Set<Int64>()
|
||||||
|
self.valueBox.range(self.idToReferenceTable, start: mainKey, end: mainKey.successor, keys: { key in
|
||||||
|
let peerId = key.getInt64(16)
|
||||||
|
peerIds.insert(peerId)
|
||||||
|
return true
|
||||||
|
}, limit: 0)
|
||||||
|
|
||||||
|
return peerIds
|
||||||
|
}
|
||||||
|
|
||||||
func update(id: Data, size: Int64) {
|
func update(id: Data, size: Int64) {
|
||||||
self.valueBox.begin()
|
self.valueBox.begin()
|
||||||
|
|
||||||
@ -300,14 +389,8 @@ public final class StorageBox {
|
|||||||
self.internalAddSize(contentType: info.contentType, delta: sizeDelta)
|
self.internalAddSize(contentType: info.contentType, delta: sizeDelta)
|
||||||
}
|
}
|
||||||
|
|
||||||
var peerIds: [Int64] = []
|
for peerId in self.peerIdsReferencing(hashId: hashId) {
|
||||||
self.valueBox.range(self.idToReferenceTable, start: mainKey, end: mainKey.successor, keys: { key in
|
self.internalAddSize(peerId: peerId, contentType: info.contentType, delta: sizeDelta)
|
||||||
peerIds.append(key.getInt64(0))
|
|
||||||
return true
|
|
||||||
}, limit: 0)
|
|
||||||
|
|
||||||
for peerId in peerIds {
|
|
||||||
let _ = peerId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,6 +417,8 @@ public final class StorageBox {
|
|||||||
|
|
||||||
self.valueBox.set(self.hashIdToInfoTable, key: mainKey, value: ItemInfo(id: id, contentType: contentType, size: size).serialize())
|
self.valueBox.set(self.hashIdToInfoTable, key: mainKey, value: ItemInfo(id: id, contentType: contentType, size: size).serialize())
|
||||||
|
|
||||||
|
self.internalAddSize(contentType: contentType, delta: size)
|
||||||
|
|
||||||
let idKey = ValueBoxKey(length: hashId.data.count + 8 + 1 + 4)
|
let idKey = ValueBoxKey(length: hashId.data.count + 8 + 1 + 4)
|
||||||
idKey.setData(0, value: hashId.data)
|
idKey.setData(0, value: hashId.data)
|
||||||
idKey.setInt64(hashId.data.count, value: reference.peerId)
|
idKey.setInt64(hashId.data.count, value: reference.peerId)
|
||||||
@ -378,6 +463,8 @@ public final class StorageBox {
|
|||||||
}
|
}
|
||||||
peerIdCount += 1
|
peerIdCount += 1
|
||||||
self.valueBox.set(self.peerIdTable, key: peerIdKey, value: MemoryBuffer(memory: &peerIdCount, capacity: 4, length: 4, freeWhenDone: false))
|
self.valueBox.set(self.peerIdTable, key: peerIdKey, value: MemoryBuffer(memory: &peerIdCount, capacity: 4, length: 4, freeWhenDone: false))
|
||||||
|
|
||||||
|
self.internalAddSize(peerId: 0, contentType: contentType, delta: size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -564,18 +651,39 @@ public final class StorageBox {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStats() -> Stats {
|
func getAllStats() -> AllStats {
|
||||||
var contentTypes: [UInt8: Int64] = [:]
|
self.valueBox.begin()
|
||||||
|
|
||||||
|
let allStats = AllStats(total: StorageBox.Stats(contentTypes: [:]), peers: [:])
|
||||||
|
|
||||||
self.valueBox.scan(self.contentTypeStatsTable, values: { key, value in
|
self.valueBox.scan(self.contentTypeStatsTable, values: { key, value in
|
||||||
var size: Int64 = 0
|
var size: Int64 = 0
|
||||||
value.read(&size, offset: 0, length: 8)
|
value.read(&size, offset: 0, length: 8)
|
||||||
contentTypes[key.getUInt8(0)] = size
|
allStats.total.contentTypes[key.getUInt8(0)] = size
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return Stats(contentTypes: contentTypes)
|
self.valueBox.scan(self.peerContentTypeStatsTable, values: { key, value in
|
||||||
|
var size: Int64 = 0
|
||||||
|
value.read(&size, offset: 0, length: 8)
|
||||||
|
|
||||||
|
let peerId = key.getInt64(0)
|
||||||
|
if peerId != 0 {
|
||||||
|
assert(true)
|
||||||
|
}
|
||||||
|
let contentType = key.getUInt8(0)
|
||||||
|
if allStats.peers[PeerId(peerId)] == nil {
|
||||||
|
allStats.peers[PeerId(peerId)] = StorageBox.Stats(contentTypes: [:])
|
||||||
|
}
|
||||||
|
allStats.peers[PeerId(peerId)]?.contentTypes[contentType] = size
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
self.valueBox.commit()
|
||||||
|
|
||||||
|
return allStats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -651,9 +759,9 @@ public final class StorageBox {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getStats() -> Signal<Stats, NoError> {
|
public func getAllStats() -> Signal<AllStats, NoError> {
|
||||||
return self.impl.signalWith { impl, subscriber in
|
return self.impl.signalWith { impl, subscriber in
|
||||||
subscriber.putNext(impl.getStats())
|
subscriber.putNext(impl.getAllStats())
|
||||||
subscriber.putCompletion()
|
subscriber.putCompletion()
|
||||||
|
|
||||||
return EmptyDisposable
|
return EmptyDisposable
|
||||||
|
|||||||
@ -52,7 +52,7 @@ private final class CacheUsageStatsState {
|
|||||||
var upperBound: MessageIndex?
|
var upperBound: MessageIndex?
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct StorageUsageStats: Equatable {
|
public final class StorageUsageStats {
|
||||||
public enum CategoryKey: Hashable {
|
public enum CategoryKey: Hashable {
|
||||||
case photos
|
case photos
|
||||||
case videos
|
case videos
|
||||||
@ -71,17 +71,17 @@ public struct StorageUsageStats: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var categories: [CategoryKey: CategoryData]
|
public fileprivate(set) var categories: [CategoryKey: CategoryData]
|
||||||
|
|
||||||
public init(categories: [CategoryKey: CategoryData]) {
|
public init(categories: [CategoryKey: CategoryData]) {
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct AllStorageUsageStats: Equatable {
|
public final class AllStorageUsageStats {
|
||||||
public struct PeerStats: Equatable {
|
public final class PeerStats {
|
||||||
public var peer: EnginePeer
|
public let peer: EnginePeer
|
||||||
public var stats: StorageUsageStats
|
public let stats: StorageUsageStats
|
||||||
|
|
||||||
public init(peer: EnginePeer, stats: StorageUsageStats) {
|
public init(peer: EnginePeer, stats: StorageUsageStats) {
|
||||||
self.peer = peer
|
self.peer = peer
|
||||||
@ -89,8 +89,8 @@ public struct AllStorageUsageStats: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var totalStats: StorageUsageStats
|
public fileprivate(set) var totalStats: StorageUsageStats
|
||||||
public var peers: [EnginePeer.Id: PeerStats]
|
public fileprivate(set) var peers: [EnginePeer.Id: PeerStats]
|
||||||
|
|
||||||
public init(totalStats: StorageUsageStats, peers: [EnginePeer.Id: PeerStats]) {
|
public init(totalStats: StorageUsageStats, peers: [EnginePeer.Id: PeerStats]) {
|
||||||
self.totalStats = totalStats
|
self.totalStats = totalStats
|
||||||
@ -98,53 +98,10 @@ public struct AllStorageUsageStats: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func _internal_collectStorageUsageStats(account: Account) -> Signal<AllStorageUsageStats, NoError> {
|
private extension StorageUsageStats {
|
||||||
let additionalStats = Signal<Int64, NoError> { subscriber in
|
convenience init(_ stats: StorageBox.Stats) {
|
||||||
DispatchQueue.global().async {
|
|
||||||
var totalSize: Int64 = 0
|
|
||||||
|
|
||||||
let additionalPaths: [String] = [
|
|
||||||
"cache",
|
|
||||||
"animation-cache",
|
|
||||||
"short-cache",
|
|
||||||
]
|
|
||||||
|
|
||||||
for path in additionalPaths {
|
|
||||||
let fullPath: String
|
|
||||||
if path.isEmpty {
|
|
||||||
fullPath = account.postbox.mediaBox.basePath
|
|
||||||
} else {
|
|
||||||
fullPath = account.postbox.mediaBox.basePath + "/\(path)"
|
|
||||||
}
|
|
||||||
|
|
||||||
var s = darwin_dirstat()
|
|
||||||
var result = dirstat_np(fullPath, 1, &s, MemoryLayout<darwin_dirstat>.size)
|
|
||||||
if result != -1 {
|
|
||||||
totalSize += Int64(s.total_size)
|
|
||||||
} else {
|
|
||||||
result = dirstat_np(fullPath, 0, &s, MemoryLayout<darwin_dirstat>.size)
|
|
||||||
if result != -1 {
|
|
||||||
totalSize += Int64(s.total_size)
|
|
||||||
print(s.descendants)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriber.putNext(totalSize)
|
|
||||||
subscriber.putCompletion()
|
|
||||||
}
|
|
||||||
|
|
||||||
return EmptyDisposable
|
|
||||||
}
|
|
||||||
|
|
||||||
return combineLatest(
|
|
||||||
additionalStats,
|
|
||||||
account.postbox.mediaBox.storageBox.getStats()
|
|
||||||
)
|
|
||||||
|> deliverOnMainQueue
|
|
||||||
|> mapToSignal { additionalStats, allStats -> Signal<AllStorageUsageStats, NoError> in
|
|
||||||
var mappedCategories: [StorageUsageStats.CategoryKey: StorageUsageStats.CategoryData] = [:]
|
var mappedCategories: [StorageUsageStats.CategoryKey: StorageUsageStats.CategoryData] = [:]
|
||||||
for (key, value) in allStats.contentTypes {
|
for (key, value) in stats.contentTypes {
|
||||||
let mappedCategory: StorageUsageStats.CategoryKey
|
let mappedCategory: StorageUsageStats.CategoryKey
|
||||||
switch key {
|
switch key {
|
||||||
case MediaResourceUserContentType.image.rawValue:
|
case MediaResourceUserContentType.image.rawValue:
|
||||||
@ -165,14 +122,126 @@ func _internal_collectStorageUsageStats(account: Account) -> Signal<AllStorageUs
|
|||||||
mappedCategories[mappedCategory] = StorageUsageStats.CategoryData(size: value)
|
mappedCategories[mappedCategory] = StorageUsageStats.CategoryData(size: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if additionalStats != 0 {
|
self.init(categories: mappedCategories)
|
||||||
mappedCategories[.misc, default: StorageUsageStats.CategoryData(size: 0)].size += additionalStats
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return .single(AllStorageUsageStats(
|
func _internal_collectStorageUsageStats(account: Account) -> Signal<AllStorageUsageStats, NoError> {
|
||||||
totalStats: StorageUsageStats(categories: mappedCategories),
|
let additionalStats = Signal<Int64, NoError> { subscriber in
|
||||||
peers: [:]
|
DispatchQueue.global().async {
|
||||||
))
|
var totalSize: Int64 = 0
|
||||||
|
|
||||||
|
let additionalPaths: [String] = [
|
||||||
|
"cache",
|
||||||
|
"animation-cache",
|
||||||
|
"short-cache",
|
||||||
|
]
|
||||||
|
|
||||||
|
func statForDirectory(path: String) -> Int64 {
|
||||||
|
var s = darwin_dirstat()
|
||||||
|
var result = dirstat_np(path, 1, &s, MemoryLayout<darwin_dirstat>.size)
|
||||||
|
if result != -1 {
|
||||||
|
return Int64(s.total_size)
|
||||||
|
} else {
|
||||||
|
result = dirstat_np(path, 0, &s, MemoryLayout<darwin_dirstat>.size)
|
||||||
|
if result != -1 {
|
||||||
|
return Int64(s.total_size)
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var delayedDirs: [String] = []
|
||||||
|
|
||||||
|
for path in additionalPaths {
|
||||||
|
let fullPath: String
|
||||||
|
if path.isEmpty {
|
||||||
|
fullPath = account.postbox.mediaBox.basePath
|
||||||
|
} else {
|
||||||
|
fullPath = account.postbox.mediaBox.basePath + "/\(path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "animation-cache" {
|
||||||
|
if let enumerator = FileManager.default.enumerator(at: URL(fileURLWithPath: fullPath), includingPropertiesForKeys: [.isDirectoryKey], options: .skipsSubdirectoryDescendants) {
|
||||||
|
for url in enumerator {
|
||||||
|
guard let url = url as? URL else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delayedDirs.append(fullPath + "/" + url.lastPathComponent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalSize += statForDirectory(path: fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !delayedDirs.isEmpty {
|
||||||
|
let concurrentSize = Atomic<[Int64]>(value: [])
|
||||||
|
|
||||||
|
DispatchQueue.concurrentPerform(iterations: delayedDirs.count, execute: { index in
|
||||||
|
let directorySize = statForDirectory(path: delayedDirs[index])
|
||||||
|
let result = concurrentSize.modify { current in
|
||||||
|
return current + [directorySize]
|
||||||
|
}
|
||||||
|
if result.count == delayedDirs.count {
|
||||||
|
var aggregatedCount: Int64 = 0
|
||||||
|
for item in result {
|
||||||
|
aggregatedCount += item
|
||||||
|
}
|
||||||
|
subscriber.putNext(totalSize + aggregatedCount)
|
||||||
|
subscriber.putCompletion()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
subscriber.putNext(totalSize)
|
||||||
|
subscriber.putCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EmptyDisposable
|
||||||
|
}
|
||||||
|
|
||||||
|
return combineLatest(
|
||||||
|
additionalStats,
|
||||||
|
account.postbox.mediaBox.storageBox.getAllStats()
|
||||||
|
)
|
||||||
|
|> deliverOnMainQueue
|
||||||
|
|> mapToSignal { additionalStats, allStats -> Signal<AllStorageUsageStats, NoError> in
|
||||||
|
return account.postbox.transaction { transaction -> AllStorageUsageStats in
|
||||||
|
let total = StorageUsageStats(allStats.total)
|
||||||
|
if additionalStats != 0 {
|
||||||
|
total.categories[.misc, default: StorageUsageStats.CategoryData(size: 0)].size += additionalStats
|
||||||
|
}
|
||||||
|
|
||||||
|
var peers: [EnginePeer.Id: AllStorageUsageStats.PeerStats] = [:]
|
||||||
|
|
||||||
|
for (peerId, peerStats) in allStats.peers {
|
||||||
|
if peerId.id._internalGetInt64Value() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var peerSize: Int64 = 0
|
||||||
|
for (_, size) in peerStats.contentTypes {
|
||||||
|
peerSize += size
|
||||||
|
}
|
||||||
|
if peerSize == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let peer = transaction.getPeer(peerId), transaction.getPeerChatListIndex(peerId) != nil {
|
||||||
|
peers[peerId] = AllStorageUsageStats.PeerStats(
|
||||||
|
peer: EnginePeer(peer),
|
||||||
|
stats: StorageUsageStats(peerStats)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AllStorageUsageStats(
|
||||||
|
totalStats: total,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ swift_library(
|
|||||||
"//submodules/Markdown",
|
"//submodules/Markdown",
|
||||||
"//submodules/ContextUI",
|
"//submodules/ContextUI",
|
||||||
"//submodules/AnimatedAvatarSetNode",
|
"//submodules/AnimatedAvatarSetNode",
|
||||||
|
"//submodules/AvatarNode",
|
||||||
],
|
],
|
||||||
visibility = [
|
visibility = [
|
||||||
"//visibility:public",
|
"//visibility:public",
|
||||||
|
|||||||
@ -110,10 +110,12 @@ final class PieChartComponent: Component {
|
|||||||
for i in 0 ..< component.chartData.items.count {
|
for i in 0 ..< component.chartData.items.count {
|
||||||
let item = component.chartData.items[i]
|
let item = component.chartData.items[i]
|
||||||
var angle = item.value / valueSum * CGFloat.pi * 2.0
|
var angle = item.value / valueSum * CGFloat.pi * 2.0
|
||||||
|
if angle > .ulpOfOne {
|
||||||
if angle < minAngle {
|
if angle < minAngle {
|
||||||
angle = minAngle
|
angle = minAngle
|
||||||
}
|
}
|
||||||
totalAngle += angle
|
totalAngle += angle
|
||||||
|
}
|
||||||
angles.append(angle)
|
angles.append(angle)
|
||||||
}
|
}
|
||||||
if totalAngle > CGFloat.pi * 2.0 {
|
if totalAngle > CGFloat.pi * 2.0 {
|
||||||
@ -207,41 +209,160 @@ final class PieChartComponent: Component {
|
|||||||
}
|
}
|
||||||
let labelSize = label.update(transition: .immediate, component: AnyComponent(Text(text: "\(fractionString)%", font: Font.with(size: 16.0, design: .round, weight: .semibold), color: component.theme.list.itemCheckColors.foregroundColor)), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0))
|
let labelSize = label.update(transition: .immediate, component: AnyComponent(Text(text: "\(fractionString)%", font: Font.with(size: 16.0, design: .round, weight: .semibold), color: component.theme.list.itemCheckColors.foregroundColor)), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0))
|
||||||
|
|
||||||
var centerOffset: CGFloat = 0.5
|
var labelFrame: CGRect?
|
||||||
|
|
||||||
var labelScale: CGFloat = 1.0
|
for step in 0 ... 6 {
|
||||||
if angleValue < 0.38 {
|
let stepFraction: CGFloat = CGFloat(step) / 6.0
|
||||||
labelScale = angleValue / 0.38
|
let centerOffset: CGFloat = 0.5 * (1.0 - stepFraction) + 0.65 * stepFraction
|
||||||
centerOffset = labelScale * 0.6 + (1.0 - labelScale) * 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
let midAngle: CGFloat = (innerStartAngle + innerEndAngle) * 0.5
|
let midAngle: CGFloat = (innerStartAngle + innerEndAngle) * 0.5
|
||||||
let centerDistance: CGFloat = (innerDiameter * 0.5 + (diameter * 0.5 - innerDiameter * 0.5) * centerOffset)
|
let centerDistance: CGFloat = (innerDiameter * 0.5 + (diameter * 0.5 - innerDiameter * 0.5) * centerOffset)
|
||||||
let labelCenter = CGPoint(
|
|
||||||
x: shapeLayerFrame.midX + cos(midAngle) * centerDistance,
|
|
||||||
y: shapeLayerFrame.midY + sin(midAngle) * centerDistance
|
|
||||||
)
|
|
||||||
let labelFrame = CGRect(origin: CGPoint(x: labelCenter.x - labelSize.width * 0.5, y: labelCenter.y - labelSize.height * 0.5), size: labelSize)
|
|
||||||
|
|
||||||
if let labelView = label.view {
|
let relLabelCenter = CGPoint(
|
||||||
|
x: cos(midAngle) * centerDistance,
|
||||||
|
y: sin(midAngle) * centerDistance
|
||||||
|
)
|
||||||
|
|
||||||
|
let labelCenter = CGPoint(
|
||||||
|
x: shapeLayerFrame.midX + relLabelCenter.x,
|
||||||
|
y: shapeLayerFrame.midY + relLabelCenter.y
|
||||||
|
)
|
||||||
|
|
||||||
|
func lineCircleIntersection(_ center: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ r: CGFloat) -> CGFloat {
|
||||||
|
let dx: CGFloat = p2.x - p1.x
|
||||||
|
let dy: CGFloat = p2.y - p1.y
|
||||||
|
let dr: CGFloat = sqrt(dx * dx + dy * dy)
|
||||||
|
let D: CGFloat = p1.x * p2.y - p2.x * p1.y
|
||||||
|
|
||||||
|
var minDistance: CGFloat = 10000.0
|
||||||
|
|
||||||
|
for i in 0 ..< 2 {
|
||||||
|
let signFactor: CGFloat = i == 0 ? 1.0 : (-1.0)
|
||||||
|
let dysign: CGFloat = dy < 0.0 ? -1.0 : 1.0
|
||||||
|
let ix: CGFloat = (D * dy + signFactor * dysign * dx * sqrt(r * r * dr * dr - D * D)) / (dr * dr)
|
||||||
|
let iy: CGFloat = (-D * dx + signFactor * abs(dy) * sqrt(r * r * dr * dr - D * D)) / (dr * dr)
|
||||||
|
let distance: CGFloat = sqrt(pow(ix - center.x, 2.0) + pow(iy - center.y, 2.0))
|
||||||
|
minDistance = min(minDistance, distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineLineIntersection(_ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ p4: CGPoint) -> CGFloat {
|
||||||
|
let x1 = p1.x
|
||||||
|
let y1 = p1.y
|
||||||
|
let x2 = p2.x
|
||||||
|
let y2 = p2.y
|
||||||
|
let x3 = p3.x
|
||||||
|
let y3 = p3.y
|
||||||
|
let x4 = p4.x
|
||||||
|
let y4 = p4.y
|
||||||
|
|
||||||
|
let d: CGFloat = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
|
||||||
|
if abs(d) <= 0.00001 {
|
||||||
|
return 10000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
let px: CGFloat = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / d
|
||||||
|
let py: CGFloat = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / d
|
||||||
|
|
||||||
|
let distance: CGFloat = sqrt(pow(px - p1.x, 2.0) + pow(py - p1.y, 2.0))
|
||||||
|
return distance
|
||||||
|
}
|
||||||
|
|
||||||
|
let intersectionOuterTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), diameter * 0.5)
|
||||||
|
let intersectionInnerTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), innerDiameter * 0.5)
|
||||||
|
let intersectionOuterBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), diameter * 0.5)
|
||||||
|
let intersectionInnerBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), innerDiameter * 0.5)
|
||||||
|
|
||||||
|
let intersectionLine1TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerStartAngle), y: sin(innerStartAngle)))
|
||||||
|
let intersectionLine1BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerStartAngle), y: sin(innerStartAngle)))
|
||||||
|
let intersectionLine2TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerEndAngle), y: sin(innerEndAngle)))
|
||||||
|
let intersectionLine2BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerEndAngle), y: sin(innerEndAngle)))
|
||||||
|
|
||||||
|
var distances: [CGFloat] = [
|
||||||
|
intersectionOuterTopRight,
|
||||||
|
intersectionInnerTopRight,
|
||||||
|
intersectionOuterBottomRight,
|
||||||
|
intersectionInnerBottomRight
|
||||||
|
]
|
||||||
|
|
||||||
|
if angleValue < CGFloat.pi / 2.0 {
|
||||||
|
distances.append(contentsOf: [
|
||||||
|
intersectionLine1TopRight,
|
||||||
|
intersectionLine1BottomRight,
|
||||||
|
intersectionLine2TopRight,
|
||||||
|
intersectionLine2BottomRight
|
||||||
|
] as [CGFloat])
|
||||||
|
}
|
||||||
|
|
||||||
|
var minDistance: CGFloat = 1000.0
|
||||||
|
for distance in distances {
|
||||||
|
minDistance = min(minDistance, distance + 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let diagonalAngle = atan2(labelSize.height, labelSize.width)
|
||||||
|
|
||||||
|
let maxHalfWidth = cos(diagonalAngle) * minDistance
|
||||||
|
let maxHalfHeight = sin(diagonalAngle) * minDistance
|
||||||
|
|
||||||
|
let maxSize = CGSize(width: maxHalfWidth * 2.0, height: maxHalfHeight * 2.0)
|
||||||
|
let finalSize = CGSize(width: min(labelSize.width, maxSize.width), height: min(labelSize.height, maxSize.height))
|
||||||
|
|
||||||
|
let currentFrame = CGRect(origin: CGPoint(x: labelCenter.x - finalSize.width * 0.5, y: labelCenter.y - finalSize.height * 0.5), size: finalSize)
|
||||||
|
|
||||||
|
if finalSize.width >= labelSize.width {
|
||||||
|
labelFrame = currentFrame
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if let labelFrame {
|
||||||
|
if labelFrame.width > finalSize.width {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelFrame = currentFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
if let labelView = label.view, let labelFrame {
|
||||||
if labelView.superview == nil {
|
if labelView.superview == nil {
|
||||||
self.addSubview(labelView)
|
self.addSubview(labelView)
|
||||||
}
|
}
|
||||||
labelView.bounds = CGRect(origin: CGPoint(), size: labelFrame.size)
|
|
||||||
transition.setPosition(view: labelView, position: labelFrame.center)
|
|
||||||
transition.setScale(view: labelView, scale: labelScale)
|
|
||||||
|
|
||||||
let normalAlpha: CGFloat = labelScale < 0.5 ? 0.0 : 1.0
|
labelView.bounds = CGRect(origin: CGPoint(), size: labelSize)
|
||||||
|
var labelScale = labelFrame.width / labelSize.width
|
||||||
|
|
||||||
|
let normalAlpha: CGFloat = labelScale < 0.4 ? 0.0 : 1.0
|
||||||
|
|
||||||
|
var relLabelCenter = CGPoint(
|
||||||
|
x: labelFrame.midX - shapeLayerFrame.midX,
|
||||||
|
y: labelFrame.midY - shapeLayerFrame.midY
|
||||||
|
)
|
||||||
|
|
||||||
if let selectedKey = self.selectedKey {
|
if let selectedKey = self.selectedKey {
|
||||||
if selectedKey == item.id {
|
if selectedKey == item.id {
|
||||||
transition.setAlpha(view: labelView, alpha: normalAlpha)
|
transition.setAlpha(view: labelView, alpha: normalAlpha)
|
||||||
} else {
|
} else {
|
||||||
transition.setAlpha(view: labelView, alpha: 0.0)
|
transition.setAlpha(view: labelView, alpha: 0.0)
|
||||||
|
|
||||||
|
let reducedFactor: CGFloat = (reducedDiameter - innerDiameter) / (diameter - innerDiameter)
|
||||||
|
let reducedDiameterFactor: CGFloat = reducedDiameter / diameter
|
||||||
|
|
||||||
|
labelScale *= reducedFactor
|
||||||
|
|
||||||
|
relLabelCenter.x *= reducedDiameterFactor
|
||||||
|
relLabelCenter.y *= reducedDiameterFactor
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
transition.setAlpha(view: labelView, alpha: normalAlpha)
|
transition.setAlpha(view: labelView, alpha: normalAlpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let labelCenter = CGPoint(
|
||||||
|
x: shapeLayerFrame.midX + relLabelCenter.x,
|
||||||
|
y: shapeLayerFrame.midY + relLabelCenter.y
|
||||||
|
)
|
||||||
|
|
||||||
|
transition.setPosition(view: labelView, position: labelCenter)
|
||||||
|
transition.setScale(view: labelView, scale: labelScale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,435 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import Display
|
||||||
|
import AsyncDisplayKit
|
||||||
|
import ComponentFlow
|
||||||
|
import SwiftSignalKit
|
||||||
|
import ViewControllerComponent
|
||||||
|
import ComponentDisplayAdapters
|
||||||
|
import TelegramPresentationData
|
||||||
|
import AccountContext
|
||||||
|
import TelegramCore
|
||||||
|
import MultilineTextComponent
|
||||||
|
import EmojiStatusComponent
|
||||||
|
import Postbox
|
||||||
|
import TelegramStringFormatting
|
||||||
|
import CheckNode
|
||||||
|
import AvatarNode
|
||||||
|
|
||||||
|
private let avatarFont = avatarPlaceholderFont(size: 15.0)
|
||||||
|
|
||||||
|
private final class PeerListItemComponent: Component {
|
||||||
|
let context: AccountContext
|
||||||
|
let theme: PresentationTheme
|
||||||
|
let sideInset: CGFloat
|
||||||
|
let title: String
|
||||||
|
let peer: EnginePeer?
|
||||||
|
let label: String
|
||||||
|
let hasNext: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AccountContext,
|
||||||
|
theme: PresentationTheme,
|
||||||
|
sideInset: CGFloat,
|
||||||
|
title: String,
|
||||||
|
peer: EnginePeer?,
|
||||||
|
label: String,
|
||||||
|
hasNext: Bool
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.theme = theme
|
||||||
|
self.sideInset = sideInset
|
||||||
|
self.title = title
|
||||||
|
self.peer = peer
|
||||||
|
self.label = label
|
||||||
|
self.hasNext = hasNext
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: PeerListItemComponent, rhs: PeerListItemComponent) -> Bool {
|
||||||
|
if lhs.context !== rhs.context {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.theme !== rhs.theme {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.sideInset != rhs.sideInset {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.title != rhs.title {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.peer != rhs.peer {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.label != rhs.label {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.hasNext != rhs.hasNext {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
final class View: HighlightTrackingButton {
|
||||||
|
private let title = ComponentView<Empty>()
|
||||||
|
private let label = ComponentView<Empty>()
|
||||||
|
private let separatorLayer: SimpleLayer
|
||||||
|
private let avatarNode: AvatarNode
|
||||||
|
|
||||||
|
private var component: PeerListItemComponent?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
self.separatorLayer = SimpleLayer()
|
||||||
|
self.avatarNode = AvatarNode(font: avatarFont)
|
||||||
|
self.avatarNode.isLayerBacked = true
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.layer.addSublayer(self.separatorLayer)
|
||||||
|
self.layer.addSublayer(self.avatarNode.layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(component: PeerListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||||
|
let themeUpdated = self.component?.theme !== component.theme
|
||||||
|
self.component = component
|
||||||
|
|
||||||
|
let height: CGFloat = 52.0
|
||||||
|
let leftInset: CGFloat = 62.0 + component.sideInset
|
||||||
|
let rightInset: CGFloat = 16.0 + component.sideInset
|
||||||
|
|
||||||
|
let avatarSize: CGFloat = 40.0
|
||||||
|
|
||||||
|
self.avatarNode.frame = CGRect(origin: CGPoint(x: component.sideInset + 10.0, y: floor((height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize))
|
||||||
|
if let peer = component.peer {
|
||||||
|
self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
let labelSize = self.label.update(
|
||||||
|
transition: .immediate,
|
||||||
|
component: AnyComponent(MultilineTextComponent(
|
||||||
|
text: .plain(NSAttributedString(string: component.label, font: Font.regular(17.0), textColor: component.theme.list.itemSecondaryTextColor))
|
||||||
|
)),
|
||||||
|
environment: {},
|
||||||
|
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
let titleSize = self.title.update(
|
||||||
|
transition: .immediate,
|
||||||
|
component: AnyComponent(MultilineTextComponent(
|
||||||
|
text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor))
|
||||||
|
)),
|
||||||
|
environment: {},
|
||||||
|
containerSize: CGSize(width: availableSize.width - leftInset - rightInset - labelSize.width - 4.0, height: 100.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let titleView = self.title.view {
|
||||||
|
if titleView.superview == nil {
|
||||||
|
self.addSubview(titleView)
|
||||||
|
}
|
||||||
|
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: leftInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize))
|
||||||
|
}
|
||||||
|
if let labelView = self.label.view {
|
||||||
|
if labelView.superview == nil {
|
||||||
|
self.addSubview(labelView)
|
||||||
|
}
|
||||||
|
transition.setFrame(view: labelView, frame: CGRect(origin: CGPoint(x: availableSize.width - rightInset - labelSize.width, y: floor((height - labelSize.height) / 2.0)), size: labelSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
if themeUpdated {
|
||||||
|
self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor
|
||||||
|
}
|
||||||
|
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel)))
|
||||||
|
self.separatorLayer.isHidden = !component.hasNext
|
||||||
|
|
||||||
|
return CGSize(width: availableSize.width, height: height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeView() -> View {
|
||||||
|
return View(frame: CGRect())
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||||
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class StoragePeerListPanelComponent: Component {
|
||||||
|
typealias EnvironmentType = StorageUsagePanelEnvironment
|
||||||
|
|
||||||
|
final class Item: Equatable {
|
||||||
|
let peer: EnginePeer
|
||||||
|
let size: Int64
|
||||||
|
|
||||||
|
init(
|
||||||
|
peer: EnginePeer,
|
||||||
|
size: Int64
|
||||||
|
) {
|
||||||
|
self.peer = peer
|
||||||
|
self.size = size
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
|
if lhs.peer != rhs.peer {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.size != rhs.size {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Items: Equatable {
|
||||||
|
let items: [Item]
|
||||||
|
|
||||||
|
init(items: [Item]) {
|
||||||
|
self.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: Items, rhs: Items) -> Bool {
|
||||||
|
if lhs === rhs {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return lhs.items == rhs.items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: AccountContext
|
||||||
|
let items: Items?
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: AccountContext,
|
||||||
|
items: Items?
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: StoragePeerListPanelComponent, rhs: StoragePeerListPanelComponent) -> Bool {
|
||||||
|
if lhs.context !== rhs.context {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.items != rhs.items {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ItemLayout: Equatable {
|
||||||
|
let containerInsets: UIEdgeInsets
|
||||||
|
let containerWidth: CGFloat
|
||||||
|
let itemHeight: CGFloat
|
||||||
|
let itemCount: Int
|
||||||
|
|
||||||
|
let contentHeight: CGFloat
|
||||||
|
|
||||||
|
init(
|
||||||
|
containerInsets: UIEdgeInsets,
|
||||||
|
containerWidth: CGFloat,
|
||||||
|
itemHeight: CGFloat,
|
||||||
|
itemCount: Int
|
||||||
|
) {
|
||||||
|
self.containerInsets = containerInsets
|
||||||
|
self.containerWidth = containerWidth
|
||||||
|
self.itemHeight = itemHeight
|
||||||
|
self.itemCount = itemCount
|
||||||
|
|
||||||
|
self.contentHeight = containerInsets.top + containerInsets.bottom + CGFloat(itemCount) * itemHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleItems(for rect: CGRect) -> Range<Int>? {
|
||||||
|
let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: -self.containerInsets.top)
|
||||||
|
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemHeight)))
|
||||||
|
minVisibleRow = max(0, minVisibleRow)
|
||||||
|
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemHeight)))
|
||||||
|
|
||||||
|
let minVisibleIndex = minVisibleRow
|
||||||
|
let maxVisibleIndex = maxVisibleRow
|
||||||
|
|
||||||
|
if maxVisibleIndex >= minVisibleIndex {
|
||||||
|
return minVisibleIndex ..< (maxVisibleIndex + 1)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemFrame(for index: Int) -> CGRect {
|
||||||
|
return CGRect(origin: CGPoint(x: 0.0, y: self.containerInsets.top + CGFloat(index) * self.itemHeight), size: CGSize(width: self.containerWidth, height: self.itemHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class View: UIView, UIScrollViewDelegate {
|
||||||
|
private let scrollView: UIScrollView
|
||||||
|
|
||||||
|
private let measureItem = ComponentView<Empty>()
|
||||||
|
private var visibleItems: [EnginePeer.Id: ComponentView<Empty>] = [:]
|
||||||
|
|
||||||
|
private var ignoreScrolling: Bool = false
|
||||||
|
|
||||||
|
private var component: StoragePeerListPanelComponent?
|
||||||
|
private var environment: StorageUsagePanelEnvironment?
|
||||||
|
private var itemLayout: ItemLayout?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
self.scrollView = UIScrollView()
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.scrollView.delaysContentTouches = true
|
||||||
|
self.scrollView.canCancelContentTouches = true
|
||||||
|
self.scrollView.clipsToBounds = false
|
||||||
|
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
|
||||||
|
self.scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
}
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
||||||
|
}
|
||||||
|
self.scrollView.showsVerticalScrollIndicator = true
|
||||||
|
self.scrollView.showsHorizontalScrollIndicator = false
|
||||||
|
self.scrollView.alwaysBounceHorizontal = false
|
||||||
|
self.scrollView.scrollsToTop = false
|
||||||
|
self.scrollView.delegate = self
|
||||||
|
self.scrollView.clipsToBounds = true
|
||||||
|
self.addSubview(self.scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
if !self.ignoreScrolling {
|
||||||
|
self.updateScrolling(transition: .immediate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateScrolling(transition: Transition) {
|
||||||
|
guard let component = self.component, let environment = self.environment, let items = component.items, let itemLayout = self.itemLayout else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -100.0)
|
||||||
|
|
||||||
|
let dataSizeFormatting = DataSizeStringFormatting(strings: environment.strings, decimalSeparator: ".")
|
||||||
|
|
||||||
|
var validIds = Set<EnginePeer.Id>()
|
||||||
|
if let visibleItems = itemLayout.visibleItems(for: visibleBounds) {
|
||||||
|
for index in visibleItems.lowerBound ..< visibleItems.upperBound {
|
||||||
|
if index >= items.items.count {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let item = items.items[index]
|
||||||
|
let id = item.peer.id
|
||||||
|
validIds.insert(id)
|
||||||
|
|
||||||
|
var itemTransition = transition
|
||||||
|
let itemView: ComponentView<Empty>
|
||||||
|
if let current = self.visibleItems[id] {
|
||||||
|
itemView = current
|
||||||
|
} else {
|
||||||
|
itemTransition = .immediate
|
||||||
|
itemView = ComponentView()
|
||||||
|
self.visibleItems[id] = itemView
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = itemView.update(
|
||||||
|
transition: itemTransition,
|
||||||
|
component: AnyComponent(PeerListItemComponent(
|
||||||
|
context: component.context,
|
||||||
|
theme: environment.theme,
|
||||||
|
sideInset: environment.containerInsets.left,
|
||||||
|
title: item.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast),
|
||||||
|
peer: item.peer,
|
||||||
|
label: dataSizeString(item.size, formatting: dataSizeFormatting),
|
||||||
|
hasNext: index != items.items.count - 1
|
||||||
|
)),
|
||||||
|
environment: {},
|
||||||
|
containerSize: CGSize(width: itemLayout.containerWidth, height: itemLayout.itemHeight)
|
||||||
|
)
|
||||||
|
let itemFrame = itemLayout.itemFrame(for: index)
|
||||||
|
if let itemComponentView = itemView.view {
|
||||||
|
if itemComponentView.superview == nil {
|
||||||
|
self.scrollView.addSubview(itemComponentView)
|
||||||
|
}
|
||||||
|
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeIds: [EnginePeer.Id] = []
|
||||||
|
for (id, itemView) in self.visibleItems {
|
||||||
|
if !validIds.contains(id) {
|
||||||
|
removeIds.append(id)
|
||||||
|
if let itemComponentView = itemView.view {
|
||||||
|
transition.setAlpha(view: itemComponentView, alpha: 0.0, completion: { [weak itemComponentView] _ in
|
||||||
|
itemComponentView?.removeFromSuperview()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in removeIds {
|
||||||
|
self.visibleItems.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(component: StoragePeerListPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StorageUsagePanelEnvironment>, transition: Transition) -> CGSize {
|
||||||
|
self.component = component
|
||||||
|
|
||||||
|
let environment = environment[StorageUsagePanelEnvironment.self].value
|
||||||
|
self.environment = environment
|
||||||
|
|
||||||
|
let measureItemSize = self.measureItem.update(
|
||||||
|
transition: .immediate,
|
||||||
|
component: AnyComponent(PeerListItemComponent(
|
||||||
|
context: component.context,
|
||||||
|
theme: environment.theme,
|
||||||
|
sideInset: environment.containerInsets.left,
|
||||||
|
title: "ABCDEF",
|
||||||
|
peer: nil,
|
||||||
|
label: "1000",
|
||||||
|
hasNext: false
|
||||||
|
)),
|
||||||
|
environment: {},
|
||||||
|
containerSize: CGSize(width: availableSize.width, height: 1000.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
let itemLayout = ItemLayout(
|
||||||
|
containerInsets: environment.containerInsets,
|
||||||
|
containerWidth: availableSize.width,
|
||||||
|
itemHeight: measureItemSize.height,
|
||||||
|
itemCount: component.items?.items.count ?? 0
|
||||||
|
)
|
||||||
|
self.itemLayout = itemLayout
|
||||||
|
|
||||||
|
self.ignoreScrolling = true
|
||||||
|
let contentOffset = self.scrollView.bounds.minY
|
||||||
|
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize))
|
||||||
|
let contentSize = CGSize(width: availableSize.width, height: itemLayout.contentHeight)
|
||||||
|
if self.scrollView.contentSize != contentSize {
|
||||||
|
self.scrollView.contentSize = contentSize
|
||||||
|
}
|
||||||
|
self.scrollView.scrollIndicatorInsets = environment.containerInsets
|
||||||
|
if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset {
|
||||||
|
let deltaOffset = self.scrollView.bounds.minY - contentOffset
|
||||||
|
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true)
|
||||||
|
}
|
||||||
|
self.ignoreScrolling = false
|
||||||
|
self.updateScrolling(transition: .immediate)
|
||||||
|
|
||||||
|
return availableSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeView() -> View {
|
||||||
|
return View(frame: CGRect())
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StorageUsagePanelEnvironment>, transition: Transition) -> CGSize {
|
||||||
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -205,7 +205,6 @@ final class StoragePeerTypeItemComponent: Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel)))
|
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel)))
|
||||||
|
|
||||||
transition.setAlpha(layer: self.separatorLayer, alpha: component.hasNext ? 1.0 : 0.0)
|
transition.setAlpha(layer: self.separatorLayer, alpha: component.hasNext ? 1.0 : 0.0)
|
||||||
|
|
||||||
self.highlightBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height + (component.hasNext ? UIScreenPixel : 0.0)))
|
self.highlightBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height + (component.hasNext ? UIScreenPixel : 0.0)))
|
||||||
|
|||||||
@ -0,0 +1,377 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import Display
|
||||||
|
import ComponentFlow
|
||||||
|
import ComponentDisplayAdapters
|
||||||
|
import TelegramPresentationData
|
||||||
|
|
||||||
|
final class StorageUsagePanelEnvironment: Equatable {
|
||||||
|
let theme: PresentationTheme
|
||||||
|
let strings: PresentationStrings
|
||||||
|
let containerInsets: UIEdgeInsets
|
||||||
|
|
||||||
|
init(
|
||||||
|
theme: PresentationTheme,
|
||||||
|
strings: PresentationStrings,
|
||||||
|
containerInsets: UIEdgeInsets
|
||||||
|
) {
|
||||||
|
self.theme = theme
|
||||||
|
self.strings = strings
|
||||||
|
self.containerInsets = containerInsets
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: StorageUsagePanelEnvironment, rhs: StorageUsagePanelEnvironment) -> Bool {
|
||||||
|
if lhs.theme !== rhs.theme {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.strings !== rhs.strings {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.containerInsets != rhs.containerInsets {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class StorageUsageHeaderItemComponent: CombinedComponent {
|
||||||
|
let theme: PresentationTheme
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
theme: PresentationTheme,
|
||||||
|
title: String
|
||||||
|
) {
|
||||||
|
self.theme = theme
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: StorageUsageHeaderItemComponent, rhs: StorageUsageHeaderItemComponent) -> Bool {
|
||||||
|
if lhs.theme !== rhs.theme {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.title != rhs.title {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
static var body: Body {
|
||||||
|
let text = Child(Text.self)
|
||||||
|
|
||||||
|
return { context in
|
||||||
|
let text = text.update(
|
||||||
|
component: Text(text: context.component.title, font: Font.semibold(15.0), color: context.component.theme.list.itemAccentColor),
|
||||||
|
availableSize: context.availableSize,
|
||||||
|
transition: .immediate
|
||||||
|
)
|
||||||
|
|
||||||
|
context.add(text.position(CGPoint(x: text.size.width * 0.5, y: text.size.height * 0.5)))
|
||||||
|
|
||||||
|
return text.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class StorageUsageHeaderComponent: Component {
|
||||||
|
struct Item: Equatable {
|
||||||
|
let id: AnyHashable
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: AnyHashable,
|
||||||
|
title: String
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let theme: PresentationTheme
|
||||||
|
let items: [Item]
|
||||||
|
|
||||||
|
init(
|
||||||
|
theme: PresentationTheme,
|
||||||
|
items: [Item]
|
||||||
|
) {
|
||||||
|
self.theme = theme
|
||||||
|
self.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: StorageUsageHeaderComponent, rhs: StorageUsageHeaderComponent) -> Bool {
|
||||||
|
if lhs.theme !== rhs.theme {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.items != rhs.items {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
class View: UIView {
|
||||||
|
private var component: StorageUsageHeaderComponent?
|
||||||
|
|
||||||
|
private var visibleItems: [AnyHashable: ComponentView<Empty>] = [:]
|
||||||
|
private let activeItemLayer: SimpleLayer
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
self.activeItemLayer = SimpleLayer()
|
||||||
|
self.activeItemLayer.cornerRadius = 2.0
|
||||||
|
self.activeItemLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.layer.addSublayer(self.activeItemLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(component: StorageUsageHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||||
|
let themeUpdated = self.component?.theme !== component.theme
|
||||||
|
|
||||||
|
self.component = component
|
||||||
|
|
||||||
|
var validIds = Set<AnyHashable>()
|
||||||
|
for item in component.items {
|
||||||
|
validIds.insert(item.id)
|
||||||
|
|
||||||
|
let itemView: ComponentView<Empty>
|
||||||
|
var itemTransition = transition
|
||||||
|
if let current = self.visibleItems[item.id] {
|
||||||
|
itemView = current
|
||||||
|
} else {
|
||||||
|
itemTransition = .immediate
|
||||||
|
itemView = ComponentView()
|
||||||
|
self.visibleItems[item.id] = itemView
|
||||||
|
}
|
||||||
|
let itemSize = itemView.update(
|
||||||
|
transition: itemTransition,
|
||||||
|
component: AnyComponent(StorageUsageHeaderItemComponent(
|
||||||
|
theme: component.theme,
|
||||||
|
title: item.title
|
||||||
|
)),
|
||||||
|
environment: {},
|
||||||
|
containerSize: availableSize
|
||||||
|
)
|
||||||
|
let itemFrame = CGRect(origin: CGPoint(x: 34.0, y: floor((availableSize.height - itemSize.height) / 2.0)), size: itemSize)
|
||||||
|
if let itemComponentView = itemView.view {
|
||||||
|
if itemComponentView.superview == nil {
|
||||||
|
self.addSubview(itemComponentView)
|
||||||
|
}
|
||||||
|
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
transition.setFrame(layer: self.activeItemLayer, frame: CGRect(origin: CGPoint(x: itemFrame.minX, y: availableSize.height - 3.0), size: CGSize(width: itemFrame.width, height: 3.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if themeUpdated {
|
||||||
|
self.activeItemLayer.backgroundColor = component.theme.list.itemAccentColor.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeIds: [AnyHashable] = []
|
||||||
|
for (id, itemView) in self.visibleItems {
|
||||||
|
if !validIds.contains(id) {
|
||||||
|
removeIds.append(id)
|
||||||
|
if let itemComponentView = itemView.view {
|
||||||
|
itemComponentView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in removeIds {
|
||||||
|
self.visibleItems.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeView() -> View {
|
||||||
|
return View(frame: CGRect())
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||||
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class StorageUsagePanelContainerComponent: Component {
|
||||||
|
struct Item: Equatable {
|
||||||
|
let id: AnyHashable
|
||||||
|
let title: String
|
||||||
|
let panel: AnyComponent<StorageUsagePanelEnvironment>
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: AnyHashable,
|
||||||
|
title: String,
|
||||||
|
panel: AnyComponent<StorageUsagePanelEnvironment>
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.panel = panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let theme: PresentationTheme
|
||||||
|
let strings: PresentationStrings
|
||||||
|
let insets: UIEdgeInsets
|
||||||
|
let items: [Item]
|
||||||
|
|
||||||
|
init(
|
||||||
|
theme: PresentationTheme,
|
||||||
|
strings: PresentationStrings,
|
||||||
|
insets: UIEdgeInsets,
|
||||||
|
items: [Item]
|
||||||
|
) {
|
||||||
|
self.theme = theme
|
||||||
|
self.strings = strings
|
||||||
|
self.insets = insets
|
||||||
|
self.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ==(lhs: StorageUsagePanelContainerComponent, rhs: StorageUsagePanelContainerComponent) -> Bool {
|
||||||
|
if lhs.theme !== rhs.theme {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.strings !== rhs.strings {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.insets != rhs.insets {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lhs.items != rhs.items {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
class View: UIView {
|
||||||
|
private let topPanelBackgroundView: BlurredBackgroundView
|
||||||
|
private let topPanelSeparatorLayer: SimpleLayer
|
||||||
|
private let header = ComponentView<Empty>()
|
||||||
|
|
||||||
|
private var component: StorageUsagePanelContainerComponent?
|
||||||
|
|
||||||
|
private var visiblePanels: [AnyHashable: ComponentView<StorageUsagePanelEnvironment>] = [:]
|
||||||
|
private var currentId: AnyHashable?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
self.topPanelBackgroundView = BlurredBackgroundView(color: nil, enableBlur: true)
|
||||||
|
self.topPanelSeparatorLayer = SimpleLayer()
|
||||||
|
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.addSubview(self.topPanelBackgroundView)
|
||||||
|
self.layer.addSublayer(self.topPanelSeparatorLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(component: StorageUsagePanelContainerComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||||
|
let themeUpdated = self.component?.theme !== component.theme
|
||||||
|
|
||||||
|
self.component = component
|
||||||
|
|
||||||
|
if themeUpdated {
|
||||||
|
self.backgroundColor = component.theme.list.itemBlocksBackgroundColor
|
||||||
|
self.topPanelBackgroundView.updateColor(color: component.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.8), transition: .immediate)
|
||||||
|
self.topPanelSeparatorLayer.backgroundColor = component.theme.list.itemBlocksSeparatorColor.cgColor
|
||||||
|
}
|
||||||
|
|
||||||
|
let topPanelFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: 44.0))
|
||||||
|
transition.setFrame(view: self.topPanelBackgroundView, frame: topPanelFrame)
|
||||||
|
self.topPanelBackgroundView.update(size: topPanelFrame.size, transition: transition.containedViewLayoutTransition)
|
||||||
|
|
||||||
|
transition.setFrame(layer: self.topPanelSeparatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY), size: CGSize(width: availableSize.width, height: UIScreenPixel)))
|
||||||
|
|
||||||
|
let _ = self.header.update(
|
||||||
|
transition: transition,
|
||||||
|
component: AnyComponent(StorageUsageHeaderComponent(
|
||||||
|
theme: component.theme,
|
||||||
|
items: component.items.map { item -> StorageUsageHeaderComponent.Item in
|
||||||
|
return StorageUsageHeaderComponent.Item(
|
||||||
|
id: item.id,
|
||||||
|
title: item.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
environment: {},
|
||||||
|
containerSize: topPanelFrame.size
|
||||||
|
)
|
||||||
|
if let headerView = self.header.view {
|
||||||
|
if headerView.superview == nil {
|
||||||
|
self.addSubview(headerView)
|
||||||
|
}
|
||||||
|
transition.setFrame(view: headerView, frame: topPanelFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentIdValue = self.currentId, !component.items.contains(where: { $0.id == currentIdValue }) {
|
||||||
|
self.currentId = nil
|
||||||
|
}
|
||||||
|
if self.currentId == nil {
|
||||||
|
self.currentId = component.items.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
let childEnvironment = StorageUsagePanelEnvironment(
|
||||||
|
theme: component.theme,
|
||||||
|
strings: component.strings,
|
||||||
|
containerInsets: UIEdgeInsets(top: topPanelFrame.height, left: component.insets.left, bottom: component.insets.bottom, right: component.insets.right)
|
||||||
|
)
|
||||||
|
|
||||||
|
var validIds = Set<AnyHashable>()
|
||||||
|
if let currentId = self.currentId, let panelItem = component.items.first(where: { $0.id == currentId }) {
|
||||||
|
validIds.insert(panelItem.id)
|
||||||
|
|
||||||
|
let panel: ComponentView<StorageUsagePanelEnvironment>
|
||||||
|
var panelTransition = transition
|
||||||
|
if let current = self.visiblePanels[panelItem.id] {
|
||||||
|
panel = current
|
||||||
|
} else {
|
||||||
|
panelTransition = .immediate
|
||||||
|
panel = ComponentView()
|
||||||
|
self.visiblePanels[panelItem.id] = panel
|
||||||
|
}
|
||||||
|
let _ = panel.update(
|
||||||
|
transition: panelTransition,
|
||||||
|
component: panelItem.panel,
|
||||||
|
environment: {
|
||||||
|
childEnvironment
|
||||||
|
},
|
||||||
|
containerSize: availableSize
|
||||||
|
)
|
||||||
|
if let panelView = panel.view {
|
||||||
|
if panelView.superview == nil {
|
||||||
|
self.insertSubview(panelView, belowSubview: self.topPanelBackgroundView)
|
||||||
|
}
|
||||||
|
panelTransition.setFrame(view: panelView, frame: CGRect(origin: CGPoint(), size: availableSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeIds: [AnyHashable] = []
|
||||||
|
for (id, panel) in self.visiblePanels {
|
||||||
|
if !validIds.contains(id) {
|
||||||
|
removeIds.append(id)
|
||||||
|
if let panelView = panel.view {
|
||||||
|
panelView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in removeIds {
|
||||||
|
self.visiblePanels.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeView() -> View {
|
||||||
|
return View(frame: CGRect())
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
||||||
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,6 +41,21 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
override func touchesShouldCancel(in view: UIView) -> Bool {
|
override func touchesShouldCancel(in view: UIView) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var contentOffset: CGPoint {
|
||||||
|
set(value) {
|
||||||
|
var value = value
|
||||||
|
if value.y > self.contentSize.height - self.bounds.height {
|
||||||
|
value.y = self.contentSize.height - self.bounds.height
|
||||||
|
self.bounces = false
|
||||||
|
} else {
|
||||||
|
self.bounces = true
|
||||||
|
}
|
||||||
|
super.contentOffset = value
|
||||||
|
} get {
|
||||||
|
return super.contentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class AnimationHint {
|
private final class AnimationHint {
|
||||||
@ -56,6 +71,7 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
|
|
||||||
private var currentStats: AllStorageUsageStats?
|
private var currentStats: AllStorageUsageStats?
|
||||||
private var cacheSettings: CacheStorageSettings?
|
private var cacheSettings: CacheStorageSettings?
|
||||||
|
private var peerItems: StoragePeerListPanelComponent.Items?
|
||||||
|
|
||||||
private var selectedCategories: Set<AnyHashable> = Set()
|
private var selectedCategories: Set<AnyHashable> = Set()
|
||||||
|
|
||||||
@ -79,11 +95,17 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
private var keepDurationSectionContainerView: UIView
|
private var keepDurationSectionContainerView: UIView
|
||||||
private var keepDurationItems: [AnyHashable: ComponentView<Empty>] = [:]
|
private var keepDurationItems: [AnyHashable: ComponentView<Empty>] = [:]
|
||||||
|
|
||||||
|
private let panelContainer = ComponentView<Empty>()
|
||||||
|
|
||||||
private var component: StorageUsageScreenComponent?
|
private var component: StorageUsageScreenComponent?
|
||||||
private weak var state: EmptyComponentState?
|
private weak var state: EmptyComponentState?
|
||||||
private var navigationMetrics: (navigationHeight: CGFloat, statusBarHeight: CGFloat)?
|
private var navigationMetrics: (navigationHeight: CGFloat, statusBarHeight: CGFloat)?
|
||||||
private var controller: (() -> ViewController?)?
|
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 ignoreScrolling: Bool = false
|
||||||
|
|
||||||
private var statsDisposable: Disposable?
|
private var statsDisposable: Disposable?
|
||||||
@ -107,7 +129,6 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
|
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
self.scrollView.layer.anchorPoint = CGPoint()
|
|
||||||
self.scrollView.delaysContentTouches = true
|
self.scrollView.delaysContentTouches = true
|
||||||
self.scrollView.canCancelContentTouches = true
|
self.scrollView.canCancelContentTouches = true
|
||||||
self.scrollView.clipsToBounds = false
|
self.scrollView.clipsToBounds = false
|
||||||
@ -144,12 +165,49 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
self.statsDisposable?.dispose()
|
self.statsDisposable?.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
self.enableVelocityTracking = true
|
||||||
|
}
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
if !self.ignoreScrolling {
|
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)
|
self.updateScrolling(transition: .immediate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
||||||
|
guard let navigationMetrics = self.navigationMetrics else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let _ = navigationMetrics
|
||||||
|
|
||||||
|
let paneAreaExpansionDistance: CGFloat = 32.0
|
||||||
|
let paneAreaExpansionFinalPoint: CGFloat = scrollView.contentSize.height - scrollView.bounds.height
|
||||||
|
if targetContentOffset.pointee.y > paneAreaExpansionFinalPoint - paneAreaExpansionDistance && targetContentOffset.pointee.y < paneAreaExpansionFinalPoint {
|
||||||
|
targetContentOffset.pointee.y = paneAreaExpansionFinalPoint
|
||||||
|
self.enableVelocityTracking = false
|
||||||
|
self.previousVelocity = 0.0
|
||||||
|
self.previousVelocityM1 = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
if let panelContainerView = self.panelContainer.view as? StorageUsagePanelContainerComponent.View {
|
||||||
|
let _ = panelContainerView
|
||||||
|
let paneAreaExpansionFinalPoint: CGFloat = scrollView.contentSize.height - scrollView.bounds.height
|
||||||
|
if abs(scrollView.contentOffset.y - paneAreaExpansionFinalPoint) < .ulpOfOne {
|
||||||
|
//panelContainerView.transferVelocity(self.previousVelocityM1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateScrolling(transition: Transition) {
|
private func updateScrolling(transition: Transition) {
|
||||||
let scrollBounds = self.scrollView.bounds
|
let scrollBounds = self.scrollView.bounds
|
||||||
|
|
||||||
@ -183,6 +241,8 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
self.component = component
|
self.component = component
|
||||||
self.state = state
|
self.state = state
|
||||||
|
|
||||||
|
let environment = environment[ViewControllerComponentContainer.Environment.self].value
|
||||||
|
|
||||||
if self.statsDisposable == nil {
|
if self.statsDisposable == nil {
|
||||||
self.cacheSettingsDisposable = (component.context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings])
|
self.cacheSettingsDisposable = (component.context.sharedContext.accountManager.sharedData(keys: [SharedDataKeys.cacheStorageSettings])
|
||||||
|> map { sharedData -> CacheStorageSettings in
|
|> map { sharedData -> CacheStorageSettings in
|
||||||
@ -210,10 +270,40 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.currentStats = stats
|
self.currentStats = stats
|
||||||
|
|
||||||
|
var peerItems: [StoragePeerListPanelComponent.Item] = []
|
||||||
|
|
||||||
|
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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
self.peerItems = StoragePeerListPanelComponent.Items(items: peerItems)
|
||||||
|
|
||||||
self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(isFirstStatsUpdate: true)))
|
self.state?.updated(transition: Transition(animation: .none).withUserData(AnimationHint(isFirstStatsUpdate: true)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wasLockedAtPanels = false
|
||||||
|
if let panelContainerView = self.panelContainer.view, let navigationMetrics = self.navigationMetrics {
|
||||||
|
if abs(self.scrollView.bounds.minY - (panelContainerView.frame.minY - navigationMetrics.navigationHeight)) <= UIScreenPixel {
|
||||||
|
wasLockedAtPanels = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let animationHint = transition.userData(AnimationHint.self)
|
let animationHint = transition.userData(AnimationHint.self)
|
||||||
|
|
||||||
if let animationHint, animationHint.isFirstStatsUpdate {
|
if let animationHint, animationHint.isFirstStatsUpdate {
|
||||||
@ -228,8 +318,6 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
transition.setAlpha(view: self.headerOffsetContainer, alpha: self.currentStats != nil ? 1.0 : 0.0)
|
transition.setAlpha(view: self.headerOffsetContainer, alpha: self.currentStats != nil ? 1.0 : 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
let environment = environment[ViewControllerComponentContainer.Environment.self].value
|
|
||||||
|
|
||||||
self.controller = environment.controller
|
self.controller = environment.controller
|
||||||
|
|
||||||
self.navigationMetrics = (environment.navigationHeight, environment.statusBarHeight)
|
self.navigationMetrics = (environment.navigationHeight, environment.statusBarHeight)
|
||||||
@ -300,7 +388,7 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
var contentHeight: CGFloat = 0.0
|
var contentHeight: CGFloat = 0.0
|
||||||
|
|
||||||
let topInset: CGFloat = 19.0
|
let topInset: CGFloat = 19.0
|
||||||
let sideInset: CGFloat = 16.0
|
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
|
||||||
|
|
||||||
contentHeight += environment.statusBarHeight + topInset
|
contentHeight += environment.statusBarHeight + topInset
|
||||||
|
|
||||||
@ -682,17 +770,52 @@ private final class StorageUsageScreenComponent: Component {
|
|||||||
contentHeight += keepDurationDescriptionSize.height
|
contentHeight += keepDurationDescriptionSize.height
|
||||||
contentHeight += 40.0
|
contentHeight += 40.0
|
||||||
|
|
||||||
contentHeight += availableSize.height
|
//TODO:localize
|
||||||
|
let panelContainerSize = self.panelContainer.update(
|
||||||
|
transition: transition,
|
||||||
|
component: AnyComponent(StorageUsagePanelContainerComponent(
|
||||||
|
theme: environment.theme,
|
||||||
|
strings: environment.strings,
|
||||||
|
insets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: environment.safeInsets.bottom, right: environment.safeInsets.right),
|
||||||
|
items: [
|
||||||
|
StorageUsagePanelContainerComponent.Item(
|
||||||
|
id: "peers",
|
||||||
|
title: "Chats",
|
||||||
|
panel: AnyComponent(StoragePeerListPanelComponent(
|
||||||
|
context: component.context,
|
||||||
|
items: self.peerItems
|
||||||
|
))
|
||||||
|
)
|
||||||
|
])
|
||||||
|
),
|
||||||
|
environment: {},
|
||||||
|
containerSize: CGSize(width: availableSize.width, height: availableSize.height - environment.navigationHeight)
|
||||||
|
)
|
||||||
|
if let panelContainerView = self.panelContainer.view {
|
||||||
|
if panelContainerView.superview == nil {
|
||||||
|
self.scrollView.addSubview(panelContainerView)
|
||||||
|
}
|
||||||
|
transition.setFrame(view: panelContainerView, frame: CGRect(origin: CGPoint(x: 0.0, y: contentHeight), size: panelContainerSize))
|
||||||
|
}
|
||||||
|
contentHeight += panelContainerSize.height
|
||||||
|
|
||||||
self.ignoreScrolling = true
|
self.ignoreScrolling = true
|
||||||
|
|
||||||
let contentOffset = self.scrollView.bounds.minY
|
let contentOffset = self.scrollView.bounds.minY
|
||||||
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize))
|
transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center)
|
||||||
let contentSize = CGSize(width: availableSize.width, height: contentHeight)
|
let contentSize = CGSize(width: availableSize.width, height: contentHeight)
|
||||||
if self.scrollView.contentSize != contentSize {
|
if self.scrollView.contentSize != contentSize {
|
||||||
self.scrollView.contentSize = contentSize
|
self.scrollView.contentSize = contentSize
|
||||||
}
|
}
|
||||||
if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset {
|
|
||||||
|
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
|
let deltaOffset = self.scrollView.bounds.minY - contentOffset
|
||||||
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true)
|
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,6 +116,9 @@ static bool notyfyingShiftState = false;
|
|||||||
@implementation UIScrollView (FrameRateRangeOverride)
|
@implementation UIScrollView (FrameRateRangeOverride)
|
||||||
|
|
||||||
- (void)fixScrollDisplayLink {
|
- (void)fixScrollDisplayLink {
|
||||||
|
if (@available(iOS 16.0, *)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
static NSString *scrollHeartbeatKey = nil;
|
static NSString *scrollHeartbeatKey = nil;
|
||||||
static dispatch_once_t onceToken;
|
static dispatch_once_t onceToken;
|
||||||
dispatch_once(&onceToken, ^{
|
dispatch_once(&onceToken, ^{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user