Various improvements

This commit is contained in:
Ilya Laktyushin 2025-03-28 17:19:10 +04:00
parent d423ebeac0
commit 03a604d543
9 changed files with 720 additions and 60 deletions

View File

@ -763,6 +763,10 @@ public extension CALayer {
static func colorInvert() -> NSObject? {
return makeColorInvertFilter()
}
static func monochrome() -> NSObject? {
return makeMonochromeFilter()
}
}
public extension CALayer {

View File

@ -309,6 +309,7 @@ private enum PreferencesKeyValues: Int32 {
case businessLinks = 40
case starGifts = 41
case botStorageState = 42
case secureBotStorageState = 43
}
public func applicationSpecificPreferencesKey(_ value: Int32) -> ValueBoxKey {
@ -546,6 +547,12 @@ public struct PreferencesKeys {
key.setInt64(4, value: peerId.toInt64())
return key
}
public static func secureBotStorageState() -> ValueBoxKey {
let key = ValueBoxKey(length: 4 + 8)
key.setInt32(0, value: PreferencesKeyValues.secureBotStorageState.rawValue)
return key
}
}
private enum SharedDataKeyValues: Int32 {

View File

@ -416,6 +416,38 @@ func _internal_invokeBotCustomMethod(postbox: Postbox, network: Network, botId:
|> switchToLatest
}
public struct TelegramSecureBotStorageState: Codable, Equatable {
public let uuid: String
public init(uuid: String) {
self.uuid = uuid
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: StringCodingKey.self)
self.uuid = try container.decode(String.self, forKey: "uuid")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: StringCodingKey.self)
try container.encode(self.uuid, forKey: "uuid")
}
}
func _internal_secureBotStorageUuid(account: Account) -> Signal<String, NoError> {
return account.postbox.transaction { transaction -> String in
if let current = transaction.getPreferencesEntry(key: PreferencesKeys.secureBotStorageState())?.get(TelegramSecureBotStorageState.self) {
return current.uuid
}
let uuid = "\(Int64.random(in: 0 ..< .max))"
transaction.setPreferencesEntry(key: PreferencesKeys.secureBotStorageState(), value: PreferencesEntry(TelegramSecureBotStorageState(uuid: uuid)))
return uuid
}
}
private let maxBotStorageSize = 5 * 1024 * 1024
public struct TelegramBotStorageState: Codable, Equatable {
public struct KeyValue: Codable, Equatable {

View File

@ -1669,6 +1669,10 @@ public extension TelegramEngine {
return _internal_botsWithBiometricState(account: self.account)
}
public func secureBotStorageUuid() -> Signal<String, NoError> {
return _internal_secureBotStorageUuid(account: self.account)
}
public func setBotStorageValue(peerId: EnginePeer.Id, key: String, value: String?) -> Signal<Never, BotStorageError> {
return _internal_setBotStorageValue(account: self.account, peerId: peerId, key: key, value: value)
}

View File

@ -59,9 +59,11 @@ final class PeerInfoHeaderNavigationButtonContainerNode: SparseNode {
continue
}
button.updateContentsColor(backgroundColor: self.backgroundContentColor, contentsColor: self.contentsColor, canBeExpanded: canBeExpanded, transition: transition)
transition.updateSublayerTransformOffset(layer: button.layer, offset: CGPoint(x: accumulatedRightButtonOffset, y: 0.0))
if self.backgroundContentColor.alpha != 0.0 {
accumulatedRightButtonOffset -= 6.0
if !spec.isForExpandedView {
transition.updateSublayerTransformOffset(layer: button.layer, offset: CGPoint(x: accumulatedRightButtonOffset, y: 0.0))
if self.backgroundContentColor.alpha != 0.0 {
accumulatedRightButtonOffset -= 6.0
}
}
}
for (key, button) in self.rightButtonNodes {

View File

@ -47,6 +47,7 @@ swift_library(
"//submodules/TelegramUI/Components/Gifts/GiftAnimationComponent",
"//submodules/TelegramUI/Components/ListItemComponentAdaptor",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/ListActionItemComponent",
"//submodules/TelegramUI/Components/Chat/ChatMessageItemImpl",
"//submodules/TelegramUI/Components/PremiumPeerShortcutComponent",
"//submodules/DeviceLocationManager",

View File

@ -1681,6 +1681,21 @@ public final class WebAppController: ViewController, AttachmentContainable {
"value": value ?? NSNull()
]
self?.webView?.sendEvent(name: "secure_storage_key_received", data: data.string)
}, error: { [weak self] error in
if case .canRestore = error {
let data: JSON = [
"req_id": requestId,
"value": NSNull(),
"canRestore": true
]
self?.webView?.sendEvent(name: "secure_storage_key_received", data: data.string)
} else {
let data: JSON = [
"req_id": requestId,
"value": NSNull()
]
self?.webView?.sendEvent(name: "secure_storage_key_received", data: data.string)
}
})
} else {
let data: JSON = [
@ -1690,6 +1705,36 @@ public final class WebAppController: ViewController, AttachmentContainable {
self.webView?.sendEvent(name: "secure_storage_failed", data: data.string)
}
}
case "web_app_secure_storage_restore_key":
if let json, let requestId = json["req_id"] as? String {
if let key = json["key"] as? String {
let _ = (WebAppSecureStorage.checkRestoreAvailability(context: self.context, botId: controller.botId, key: key)
|> deliverOnMainQueue).start(next: { [weak self] storedKeys in
guard let self else {
return
}
guard !storedKeys.isEmpty else {
let data: JSON = [
"req_id": requestId,
"error": "RESTORE_UNAVAILABLE"
]
self.webView?.sendEvent(name: "secure_storage_failed", data: data.string)
return
}
self.openSecureBotStorageTransfer(requestId: requestId, key: key, storedKeys: storedKeys)
}, error: { [weak self] error in
var errorValue = "UNKNOWN_ERROR"
if case .storageNotEmpty = error {
errorValue = "STORAGE_NOT_EMPTY"
}
let data: JSON = [
"req_id": requestId,
"error": errorValue
]
self?.webView?.sendEvent(name: "secure_storage_failed", data: data.string)
})
}
}
case "web_app_secure_storage_clear":
if let json, let requestId = json["req_id"] as? String {
let _ = (WebAppSecureStorage.clearStorage(context: self.context, botId: controller.botId)
@ -2942,6 +2987,46 @@ public final class WebAppController: ViewController, AttachmentContainable {
})
}
fileprivate func openSecureBotStorageTransfer(requestId: String, key: String, storedKeys: [WebAppSecureStorage.ExistingKey]) {
guard let controller = self.controller else {
return
}
let transferController = WebAppSecureStorageTransferScreen(
context: self.context,
existingKeys: storedKeys,
completion: { [weak self] uuid in
guard let self else {
return
}
guard let uuid else {
let data: JSON = [
"req_id": requestId,
"error": "RESTORE_CANCELLED"
]
self.webView?.sendEvent(name: "secure_storage_failed", data: data.string)
return
}
let _ = (WebAppSecureStorage.transferAllValues(context: self.context, fromUuid: uuid, botId: controller.botId)
|> deliverOnMainQueue).start(completed: { [weak self] in
guard let self else {
return
}
let _ = (WebAppSecureStorage.getValue(context: self.context, botId: controller.botId, key: key)
|> deliverOnMainQueue).start(next: { [weak self] value in
let data: JSON = [
"req_id": requestId,
"value": value ?? NSNull()
]
self?.webView?.sendEvent(name: "secure_storage_key_restored", data: data.string)
})
})
}
)
controller.parentController()?.push(transferController)
}
fileprivate func openLocationSettings() {
guard let controller = self.controller else {
return

View File

@ -7,20 +7,28 @@ import AccountContext
final class WebAppSecureStorage {
enum Error {
case quotaExceeded
case canRestore
case storageNotEmpty
case unknown
}
struct StorageValue: Codable {
let timestamp: Int32
let accountName: String
let value: String
}
static private let maxKeyCount = 10
private init() {
}
static private func keyPrefix(context: AccountContext, botId: EnginePeer.Id) -> String {
return "WebBot\(UInt64(bitPattern: botId.toInt64()))A\(UInt64(bitPattern: context.account.peerId.toInt64()))Key_"
static private func keyPrefix(uuid: String, botId: EnginePeer.Id) -> String {
return "WebBot\(UInt64(bitPattern: botId.toInt64()))U\(uuid)Key_"
}
static private func makeQuery(context: AccountContext, botId: EnginePeer.Id, key: String) -> [String: Any] {
let identifier = self.keyPrefix(context: context, botId: botId) + key
static private func makeQuery(uuid: String, botId: EnginePeer.Id, key: String) -> [String: Any] {
let identifier = self.keyPrefix(uuid: uuid, botId: botId) + key
return [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: identifier,
@ -28,7 +36,7 @@ final class WebAppSecureStorage {
]
}
static private func countKeys(context: AccountContext, botId: EnginePeer.Id) -> Int {
static private func countKeys(uuid: String, botId: EnginePeer.Id) -> Int {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
@ -40,7 +48,7 @@ final class WebAppSecureStorage {
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
let relevantPrefix = self.keyPrefix(context: context, botId: botId)
let relevantPrefix = self.keyPrefix(uuid: uuid, botId: botId)
let count = items.filter {
if let account = $0[kSecAttrAccount as String] as? String {
return account.hasPrefix(relevantPrefix)
@ -54,70 +62,218 @@ final class WebAppSecureStorage {
}
static func setValue(context: AccountContext, botId: EnginePeer.Id, key: String, value: String?) -> Signal<Never, WebAppSecureStorage.Error> {
var query = makeQuery(context: context, botId: botId, key: key)
if value == nil {
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
return .complete()
return combineLatest(
context.engine.peers.secureBotStorageUuid(),
context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
)
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid, accountPeer in
var query = makeQuery(uuid: uuid, botId: botId, key: key)
guard let value else {
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
return .complete()
} else {
return .fail(.unknown)
}
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let storageValue = StorageValue(
timestamp: Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970),
accountName: accountPeer?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) ?? "",
value: value
)
guard let storageValueData = try? JSONEncoder().encode(storageValue) else {
return .fail(.unknown)
}
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
let status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecSuccess {
let updateQuery: [String: Any] = [
kSecValueData as String: storageValueData
]
let updateStatus = SecItemUpdate(query as CFDictionary, updateQuery as CFDictionary)
if updateStatus == errSecSuccess {
return .complete()
} else {
return .fail(.unknown)
}
} else if status == errSecItemNotFound {
let currentCount = countKeys(uuid: uuid, botId: botId)
if currentCount >= maxKeyCount {
return .fail(.quotaExceeded)
}
query[kSecValueData as String] = storageValueData
let createStatus = SecItemAdd(query as CFDictionary, nil)
if createStatus == errSecSuccess {
return .complete()
} else {
return .fail(.unknown)
}
} else {
return .fail(.unknown)
}
}
guard let valueData = value?.data(using: .utf8) else {
return .fail(.unknown)
}
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
let status = SecItemCopyMatching(query as CFDictionary, nil)
if status == errSecSuccess {
let updateQuery: [String: Any] = [
kSecValueData as String: valueData
]
let updateStatus = SecItemUpdate(query as CFDictionary, updateQuery as CFDictionary)
if updateStatus == errSecSuccess {
return .complete()
} else {
return .fail(.unknown)
}
} else if status == errSecItemNotFound {
let currentCount = countKeys(context: context, botId: botId)
if currentCount >= maxKeyCount {
return .fail(.quotaExceeded)
}
query[kSecValueData as String] = valueData
let createStatus = SecItemAdd(query as CFDictionary, nil)
if createStatus == errSecSuccess {
return .complete()
} else {
return .fail(.unknown)
}
} else {
return .fail(.unknown)
}
}
static func getValue(context: AccountContext, botId: EnginePeer.Id, key: String) -> Signal<String?, WebAppSecureStorage.Error> {
var query = makeQuery(context: context, botId: botId, key: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid in
var query = makeQuery(uuid: uuid, botId: botId, key: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let storageValueData = result as? Data, let storageValue = try? JSONDecoder().decode(StorageValue.self, from: storageValueData) {
return .single(storageValue.value)
} else if status == errSecItemNotFound {
return findRestorableKeys(context: context, botId: botId, key: key)
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { restorableKeys in
if !restorableKeys.isEmpty {
return .fail(.canRestore)
} else {
return .single(nil)
}
}
} else {
return .fail(.unknown)
}
}
}
static func checkRestoreAvailability(context: AccountContext, botId: EnginePeer.Id, key: String) -> Signal<[ExistingKey], WebAppSecureStorage.Error> {
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid in
let currentCount = countKeys(uuid: uuid, botId: botId)
guard currentCount == 0 else {
return .fail(.storageNotEmpty)
}
return findRestorableKeys(context: context, botId: botId, key: key)
|> castError(WebAppSecureStorage.Error.self)
}
}
private static func findRestorableKeys(context: AccountContext, botId: EnginePeer.Id, key: String) -> Signal<[ExistingKey], NoError> {
let storedKeys = getAllStoredKeys(botId: botId, key: key)
guard !storedKeys.isEmpty else {
return .single([])
}
return context.sharedContext.activeAccountContexts
|> take(1)
|> mapToSignal { _, accountContexts, _ in
let signals = accountContexts.map { $0.1.engine.peers.secureBotStorageUuid() }
return combineLatest(signals)
|> map { activeUuids in
let inactiveAccountKeys = storedKeys.filter { !activeUuids.contains($0.uuid) }
return inactiveAccountKeys
}
}
}
static func transferAllValues(context: AccountContext, fromUuid: String, botId: EnginePeer.Id) -> Signal<Never, WebAppSecureStorage.Error> {
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { toUuid in
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
let fromPrefix = keyPrefix(uuid: fromUuid, botId: botId)
let toPrefix = keyPrefix(uuid: toUuid, botId: botId)
for item in items {
if let account = item[kSecAttrAccount as String] as? String, account.hasPrefix(fromPrefix), let data = item[kSecValueData as String] as? Data {
let keySuffix = account.dropFirst(fromPrefix.count)
let newKeyIdentifier = toPrefix + keySuffix
let newKeyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: newKeyIdentifier,
kSecAttrService as String: "TMASecureStorage",
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
]
SecItemAdd(newKeyQuery as CFDictionary, nil)
}
}
return clearStorage(uuid: fromUuid, botId: botId)
} else {
return .complete()
}
}
}
struct ExistingKey: Equatable {
let uuid: String
let accountName: String
let timestamp: Int32
}
private static func getAllStoredKeys(botId: EnginePeer.Id, key: String) -> [ExistingKey] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnData as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data, let value = String(data: data, encoding: .utf8) {
return .single(value)
} else if status == errSecItemNotFound {
return .single(nil)
} else {
return .fail(.unknown)
var storedKeys: [ExistingKey] = []
if status == errSecSuccess, let items = result as? [[String: Any]] {
let botIdString = "\(UInt64(bitPattern: botId.toInt64()))"
for item in items {
if let account = item[kSecAttrAccount as String] as? String, account.contains("WebBot\(botIdString)U"), account.hasSuffix("Key_\(key)"), let valueData = item[kSecValueData as String] as? Data, let value = try? JSONDecoder().decode(StorageValue.self, from: valueData) {
if let range = account.range(of: "WebBot\(botIdString)U"), let endRange = account.range(of: "Key_\(key)") {
let startIndex = range.upperBound
let endIndex = endRange.lowerBound
let uuid = String(account[startIndex..<endIndex])
storedKeys.append(ExistingKey(
uuid: uuid,
accountName: value.accountName,
timestamp: value.timestamp
))
}
}
}
}
return storedKeys
}
static func clearStorage(context: AccountContext, botId: EnginePeer.Id) -> Signal<Never, WebAppSecureStorage.Error> {
return context.engine.peers.secureBotStorageUuid()
|> castError(WebAppSecureStorage.Error.self)
|> mapToSignal { uuid in
return clearStorage(uuid: uuid, botId: botId)
}
}
static func clearStorage(uuid: String, botId: EnginePeer.Id) -> Signal<Never, WebAppSecureStorage.Error> {
let serviceQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "TMASecureStorage",
@ -129,8 +285,7 @@ final class WebAppSecureStorage {
let status = SecItemCopyMatching(serviceQuery as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
let relevantPrefix = self.keyPrefix(context: context, botId: botId)
let relevantPrefix = self.keyPrefix(uuid: uuid, botId: botId)
for item in items {
if let account = item[kSecAttrAccount as String] as? String, account.hasPrefix(relevantPrefix) {
let deleteQuery: [String: Any] = [

View File

@ -0,0 +1,370 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import Markdown
import TextFormat
import TelegramPresentationData
import TelegramStringFormatting
import ViewControllerComponent
import SheetComponent
import BundleIconComponent
import BalancedTextComponent
import MultilineTextComponent
import ButtonComponent
import ListSectionComponent
import ListActionItemComponent
import AccountContext
private final class SheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let existingKeys: [WebAppSecureStorage.ExistingKey]
let completion: (String) -> Void
let dismiss: () -> Void
init(
context: AccountContext,
existingKeys: [WebAppSecureStorage.ExistingKey],
completion: @escaping (String) -> Void,
dismiss: @escaping () -> Void
) {
self.context = context
self.existingKeys = existingKeys
self.completion = completion
self.dismiss = dismiss
}
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.existingKeys != rhs.existingKeys {
return false
}
return true
}
final class State: ComponentState {
var selectedUuid: String?
}
func makeState() -> State {
return State()
}
static var body: Body {
let closeButton = Child(Button.self)
let title = Child(BalancedTextComponent.self)
let text = Child(BalancedTextComponent.self)
let keys = Child(ListSectionComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[EnvironmentType.self]
let component = context.component
let state = context.state
let theme = environment.theme.withModalBlocksBackground()
let strings = environment.strings
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
let textSideInset: CGFloat = 32.0 + environment.safeInsets.left
let titleFont = Font.semibold(17.0)
let subtitleFont = Font.regular(12.0)
let textColor = theme.actionSheet.primaryTextColor
let secondaryTextColor = theme.actionSheet.secondaryTextColor
var contentSize = CGSize(width: context.availableSize.width, height: 10.0)
let closeButton = closeButton.update(
component: Button(
content: AnyComponent(Text(text: strings.Common_Cancel, font: Font.regular(17.0), color: theme.actionSheet.controlAccentColor)),
action: { [weak component] in
component?.dismiss()
}
),
availableSize: CGSize(width: 100.0, height: 30.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: environment.safeInsets.left + 16.0 + closeButton.size.width / 2.0, y: 28.0))
)
//TODO:localize
let title = title.update(
component: BalancedTextComponent(
text: .plain(NSAttributedString(string: "Data Transfer Requested", font: titleFont, textColor: textColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 1,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
let text = text.update(
component: BalancedTextComponent(
text: .plain(NSAttributedString(string: "Choose account to transfer data from:", font: subtitleFont, textColor: secondaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 1,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0))
)
contentSize.height += text.size.height
contentSize.height += 17.0
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
var items: [AnyComponentWithIdentity<Empty>] = []
for key in component.existingKeys {
var titleComponents: [AnyComponentWithIdentity<Empty>] = []
titleComponents.append(
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: key.accountName,
font: Font.semibold(presentationData.listsFontSize.itemListBaseFontSize),
textColor: environment.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
)))
)
titleComponents.append(
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: "Created on \(stringForMediumCompactDate(timestamp: key.timestamp, strings: strings, dateTimeFormat: environment.dateTimeFormat))",
font: Font.regular(floor(presentationData.listsFontSize.itemListBaseFontSize * 14.0 / 17.0)),
textColor: environment.theme.list.itemSecondaryTextColor
)),
maximumNumberOfLines: 1
)))
)
items.append(AnyComponentWithIdentity(id: key.uuid, component: AnyComponent(ListActionItemComponent(
theme: theme,
title: AnyComponent(VStack(titleComponents, alignment: .left, spacing: 2.0)),
leftIcon: .check(ListActionItemComponent.LeftIcon.Check(isSelected: key.uuid == state.selectedUuid, isEnabled: true, toggle: nil)),
accessory: nil,
action: { [weak state] _ in
if let state {
state.selectedUuid = key.uuid
state.updated(transition: .spring(duration: 0.3))
}
}
))))
}
let keys = keys.update(
component: ListSectionComponent(
theme: environment.theme,
header: nil,
footer: nil,
items: items
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 1000.0),
transition: context.transition
)
context.add(keys
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + keys.size.height / 2.0))
)
contentSize.height += keys.size.height
contentSize.height += 17.0
//TODO:localize
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
),
content: AnyComponentWithIdentity(
id: AnyHashable("transfer"),
component: AnyComponent(
MultilineTextComponent(text: .plain(NSAttributedString(string: "Transfer", font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))
)
),
isEnabled: state.selectedUuid != nil,
displaysProgress: false,
action: { [weak state] in
guard let state else {
return
}
if let selectedUuid = state.selectedUuid {
component.completion(selectedUuid)
component.dismiss()
}
}
),
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0),
transition: context.transition
)
context.add(button
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
.cornerRadius(10.0)
)
contentSize.height += button.size.height
contentSize.height += 7.0
let effectiveBottomInset: CGFloat = environment.metrics.isTablet ? 0.0 : environment.safeInsets.bottom
contentSize.height += 5.0 + effectiveBottomInset
return contentSize
}
}
}
private final class SheetContainerComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let existingKeys: [WebAppSecureStorage.ExistingKey]
let completion: (String) -> Void
init(
context: AccountContext,
existingKeys: [WebAppSecureStorage.ExistingKey],
completion: @escaping (String) -> Void
) {
self.context = context
self.existingKeys = existingKeys
self.completion = completion
}
static func ==(lhs: SheetContainerComponent, rhs: SheetContainerComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.existingKeys != rhs.existingKeys {
return false
}
return true
}
static var body: Body {
let sheet = Child(SheetComponent<EnvironmentType>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
let sheetExternalState = SheetComponent<EnvironmentType>.ExternalState()
return { context in
let environment = context.environment[EnvironmentType.self]
let theme = environment.theme.withModalBlocksBackground()
let controller = environment.controller
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(SheetContent(
context: context.component.context,
existingKeys: context.component.existingKeys,
completion: context.component.completion,
dismiss: {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
}
)),
backgroundColor: .color(theme.list.blocksBackgroundColor),
followContentSizeChanges: true,
externalState: sheetExternalState,
animateOut: animateOut
),
environment: {
environment
SheetComponentEnvironment(
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
if animated {
animateOut.invoke(Action { _ in
if let controller = controller() {
controller.dismiss(completion: nil)
}
})
} else {
if let controller = controller() {
controller.dismiss(completion: nil)
}
}
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
if let controller = controller(), !controller.automaticallyControlPresentationContextLayout {
let layout = ContainerViewLayout(
size: context.availableSize,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: max(environment.safeInsets.bottom, sheetExternalState.contentHeight), right: 0.0),
safeInsets: UIEdgeInsets(top: 0.0, left: environment.safeInsets.left, bottom: 0.0, right: environment.safeInsets.right),
additionalInsets: .zero,
statusBarHeight: environment.statusBarHeight,
inputHeight: nil,
inputHeightIsInteractivellyChanging: false,
inVoiceOver: false
)
controller.presentationContext.containerLayoutUpdated(layout, transition: context.transition.containedViewLayoutTransition)
}
return context.availableSize
}
}
}
final class WebAppSecureStorageTransferScreen: ViewControllerComponentContainer {
init(
context: AccountContext,
existingKeys: [WebAppSecureStorage.ExistingKey],
completion: @escaping (String?) -> Void
) {
super.init(
context: context,
component: SheetContainerComponent(
context: context,
existingKeys: existingKeys,
completion: completion
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}