Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,37 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "PasskeysScreen",
module_name = "PasskeysScreen",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display",
"//submodules/Postbox",
"//submodules/TelegramCore",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/ComponentFlow",
"//submodules/Components/ViewControllerComponent",
"//submodules/Components/ComponentDisplayAdapters",
"//submodules/Components/MultilineTextComponent",
"//submodules/TelegramPresentationData",
"//submodules/AccountContext",
"//submodules/AppBundle",
"//submodules/PresentationDataUtils",
"//submodules/Components/BundleIconComponent",
"//submodules/TelegramUI/Components/ButtonComponent",
"//submodules/Components/BalancedTextComponent",
"//submodules/Markdown",
"//submodules/TelegramUI/Components/LottieComponent",
"//submodules/TelegramUI/Components/ListSectionComponent",
"//submodules/TelegramUI/Components/ListActionItemComponent",
"//submodules/TelegramUI/Components/EmojiStatusComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,406 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramStringFormatting
import ViewControllerComponent
import BundleIconComponent
import BalancedTextComponent
import MultilineTextComponent
import ButtonComponent
import AccountContext
import AuthenticationServices
import PresentationDataUtils
final class PasskeysScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let displaySkip: Bool
let initialPasskeysData: [TelegramPasskey]?
let passkeysDataUpdated: ([TelegramPasskey]) -> Void
let completion: () -> Void
let cancel: () -> Void
init(
context: AccountContext,
displaySkip: Bool,
initialPasskeysData: [TelegramPasskey]?,
passkeysDataUpdated: @escaping ([TelegramPasskey]) -> Void,
completion: @escaping () -> Void,
cancel: @escaping () -> Void
) {
self.context = context
self.displaySkip = displaySkip
self.initialPasskeysData = initialPasskeysData
self.passkeysDataUpdated = passkeysDataUpdated
self.completion = completion
self.cancel = cancel
}
static func ==(lhs: PasskeysScreenComponent, rhs: PasskeysScreenComponent) -> Bool {
return true
}
class View: UIView, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
private var intro: ComponentView<Empty>?
private var list: ComponentView<Empty>?
private var activityIndicator: UIActivityIndicatorView?
private var component: PasskeysScreenComponent?
private var environment: EnvironmentType?
private weak var state: EmptyComponentState?
private var passkeysData: [TelegramPasskey]?
private var loadPasskeysDataDisposable: Disposable?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.loadPasskeysDataDisposable?.dispose()
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
Task { @MainActor [weak self] in
guard let self, let component = self.component else {
return
}
let encodeBase64URL: (Data) -> String = { data in
var string = data.base64EncodedString()
string = string
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
string = string.replacingOccurrences(of: "=", with: "")
return string
}
if #available(iOS 17.0, *) {
if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
if let clientData = String(data: credential.rawClientDataJSON, encoding: .utf8), let attestationObject = credential.rawAttestationObject {
let passkey = await component.context.engine.auth.requestCreatePasskey(id: encodeBase64URL(credential.credentialID), clientData: clientData, attestationObject: attestationObject).get()
if let passkey {
if self.passkeysData == nil {
self.passkeysData = []
}
self.passkeysData?.insert(passkey, at: 0)
component.passkeysDataUpdated(self.passkeysData ?? [])
self.state?.updated(transition: .easeInOut(duration: 0.25))
}
}
}
}
}
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) {
}
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
guard let windowScene = self.window?.windowScene else {
preconditionFailure()
}
return ASPresentationAnchor(windowScene: windowScene)
}
private func createPasskey() {
if #available(iOS 15.0, *) {
Task { @MainActor [weak self] in
guard let self, let component = self.component else {
return
}
let decodeBase64: (String) -> Data? = { string in
var string = string.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while string.count % 4 != 0 {
string.append("=")
}
return Data(base64Encoded: string)
}
guard let registrationData = await component.context.engine.auth.requestPasskeyRegistration().get()?.data(using: .utf8) else {
return
}
guard let params = try? JSONSerialization.jsonObject(with: registrationData) as? [String: Any] else {
return
}
guard let pkDict = params["publicKey"] as? [String: Any] else {
return
}
guard let rp = pkDict["rp"] as? [String: Any] else {
return
}
guard let relyingPartyIdentifier = rp["id"] as? String else {
return
}
guard let challengeBase64 = pkDict["challenge"] as? String else {
return
}
guard let challengeData = decodeBase64(challengeBase64) else {
return
}
guard let user = pkDict["user"] as? [String: Any] else {
return
}
guard let userIdData = user["id"] as? String else {
return
}
guard let userId = decodeBase64(userIdData) else {
return
}
guard let userName = user["name"] as? String else {
return
}
let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: relyingPartyIdentifier)
let platformKeyRequest = platformProvider.createCredentialRegistrationRequest(challenge: challengeData, name: userName, userID: userId)
let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest])
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
component.completion()
}
}
}
private func displayDeletePasskey(id: String) {
guard let component = self.component, let environment = self.environment, let controller = environment.controller() else {
return
}
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 })
controller.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: environment.strings.Passkeys_DeleteAlert_Title, text: environment.strings.Passkeys_DeleteAlert_Text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {
}), TextAlertAction(type: .destructiveAction, title: environment.strings.Passkeys_DeleteAlert_Action, action: { [weak self] in
guard let self else {
return
}
self.deletePasskey(id: id)
})]), in: .window(.root))
}
private func deletePasskey(id: String) {
guard let component = self.component else {
return
}
guard let passkey = self.passkeysData?.first(where: { $0.id == id }) else {
return
}
let _ = component.context.engine.auth.deletePasskey(id: id).startStandalone()
self.passkeysData?.removeAll(where: { $0.id == id })
component.passkeysDataUpdated(self.passkeysData ?? [])
self.state?.updated(transition: .spring(duration: 0.4))
if #available(iOS 26.0, *) {
Task { @MainActor in
let updater = ASCredentialUpdater()
let decodeBase64: (String) -> Data? = { string in
var string = string.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
while string.count % 4 != 0 {
string.append("=")
}
return Data(base64Encoded: string)
}
if let credentialId = decodeBase64(passkey.id) {
do {
try await updater.reportUnknownPublicKeyCredential(relyingPartyIdentifier: "telegram.org", credentialID: credentialId)
} catch let e {
Logger.shared.log("Passkeys", "reportUnknownPublicKeyCredential error: \(e)")
}
}
}
}
}
func update(component: PasskeysScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.25)
if self.component == nil {
self.passkeysData = component.initialPasskeysData
if self.passkeysData == nil {
self.loadPasskeysDataDisposable = (component.context.engine.auth.passkeysData()
|> take(1)
|> deliverOnMainQueue).startStrict(next: { [weak self] data in
guard let self, let component = self.component else {
return
}
self.passkeysData = data
component.passkeysDataUpdated(data)
self.state?.updated(transition: .easeInOut(duration: 0.25))
})
}
}
self.component = component
let environment = environment[ViewControllerComponentContainer.Environment.self].value
self.environment = environment
self.state = state
self.backgroundColor = environment.theme.list.plainBackgroundColor
if let passkeysData = self.passkeysData, passkeysData.isEmpty {
let intro: ComponentView<Empty>
var introTransition = transition
if let current = self.intro {
intro = current
} else {
introTransition = transition.withAnimation(.none)
intro = ComponentView()
self.intro = intro
}
let _ = intro.update(
transition: introTransition,
component: AnyComponent(PasskeysScreenIntroComponent(
context: component.context,
theme: environment.theme,
strings: environment.strings,
insets: UIEdgeInsets(top: environment.statusBarHeight + environment.navigationHeight, left: 0.0, bottom: environment.safeInsets.bottom, right: 0.0),
displaySkip: component.displaySkip,
createPasskeyAction: { [weak self] in
guard let self else {
return
}
self.createPasskey()
},
skipAction: { [weak self] in
guard let self, let component = self.component else {
return
}
component.cancel()
self.environment?.controller()?.dismiss()
}
)),
environment: {},
containerSize: availableSize
)
if let introView = intro.view {
if introView.superview == nil {
self.addSubview(introView)
introView.alpha = 0.0
}
transition.setFrame(view: introView, frame: CGRect(origin: CGPoint(), size: availableSize))
alphaTransition.setAlpha(view: introView, alpha: 1.0)
}
} else {
if let intro = self.intro {
self.intro = nil
if let introView = intro.view {
alphaTransition.setAlpha(view: introView, alpha: 0.0, completion: { [weak introView] _ in
introView?.removeFromSuperview()
})
}
}
}
if let passkeysData = self.passkeysData, !passkeysData.isEmpty {
let list: ComponentView<Empty>
var listTransition = transition
if let current = self.list {
list = current
} else {
listTransition = transition.withAnimation(.none)
list = ComponentView()
self.list = list
}
let _ = list.update(
transition: listTransition,
component: AnyComponent(PasskeysScreenListComponent(
context: component.context,
theme: environment.theme,
strings: environment.strings,
insets: UIEdgeInsets(top: environment.statusBarHeight, left: 0.0, bottom: environment.safeInsets.bottom, right: 0.0),
passkeys: passkeysData,
addPasskeyAction: { [weak self] in
guard let self else {
return
}
self.createPasskey()
},
deletePasskeyAction: { [weak self] id in
guard let self else {
return
}
self.displayDeletePasskey(id: id)
}
)),
environment: {},
containerSize: availableSize
)
if let listView = list.view {
if listView.superview == nil {
self.addSubview(listView)
listView.alpha = 0.0
}
transition.setFrame(view: listView, frame: CGRect(origin: CGPoint(), size: availableSize))
alphaTransition.setAlpha(view: listView, alpha: 1.0)
}
} else {
if let list = self.list {
self.list = nil
if let listView = list.view {
alphaTransition.setAlpha(view: listView, alpha: 0.0, completion: { [weak listView] _ in
listView?.removeFromSuperview()
})
}
}
}
if self.passkeysData == nil {
let activityIndicator: UIActivityIndicatorView
if let current = self.activityIndicator {
activityIndicator = current
} else {
activityIndicator = UIActivityIndicatorView(style: .large)
self.activityIndicator = activityIndicator
self.addSubview(activityIndicator)
}
activityIndicator.tintColor = environment.theme.list.itemPrimaryTextColor
let indicatorSize = activityIndicator.bounds.size
activityIndicator.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - indicatorSize.width) / 2.0), y: floor((availableSize.height - indicatorSize.height) / 2.0)), size: indicatorSize)
if !activityIndicator.isAnimating {
activityIndicator.startAnimating()
}
} else if let activityIndicator = self.activityIndicator {
self.activityIndicator = nil
activityIndicator.removeFromSuperview()
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public final class PasskeysScreen: ViewControllerComponentContainer {
private let context: AccountContext
public init(context: AccountContext, displaySkip: Bool, initialPasskeysData: [TelegramPasskey]?, passkeysDataUpdated: @escaping ([TelegramPasskey]) -> Void, completion: @escaping () -> Void, cancel: @escaping () -> Void) {
self.context = context
super.init(context: context, component: PasskeysScreenComponent(context: context, displaySkip: displaySkip, initialPasskeysData: initialPasskeysData, passkeysDataUpdated: passkeysDataUpdated, completion: completion, cancel: cancel), navigationBarAppearance: .transparent)
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
@@ -0,0 +1,394 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import AccountContext
import LottieComponent
import MultilineTextComponent
import BalancedTextComponent
import ButtonComponent
import BundleIconComponent
final class PasskeysScreenIntroComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let displaySkip: Bool
let createPasskeyAction: () -> Void
let skipAction: () -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
displaySkip: Bool,
createPasskeyAction: @escaping () -> Void,
skipAction: @escaping () -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.displaySkip = displaySkip
self.createPasskeyAction = createPasskeyAction
self.skipAction = skipAction
}
static func ==(lhs: PasskeysScreenIntroComponent, rhs: PasskeysScreenIntroComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.displaySkip != rhs.displaySkip {
return false
}
return true
}
private final class ScrollViewImpl: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
private final class Item {
let icon = ComponentView<Empty>()
let title = ComponentView<Empty>()
let text = ComponentView<Empty>()
init() {
}
}
class View: UIView, UIScrollViewDelegate {
private let scrollView: ScrollViewImpl
private let contentContainer: UIView
private let icon = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private let actionButton = ComponentView<Empty>()
private var skipButton: ComponentView<Empty>?
private var items: [Item] = []
private var component: PasskeysScreenIntroComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.scrollView = ScrollViewImpl()
self.contentContainer = UIView()
self.scrollView.addSubview(self.contentContainer)
super.init(frame: frame)
self.scrollView.delaysContentTouches = true
self.scrollView.canCancelContentTouches = true
self.scrollView.clipsToBounds = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: PasskeysScreenIntroComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
self.backgroundColor = component.theme.list.plainBackgroundColor
let sideInset: CGFloat = 16.0
let sideIconInset: CGFloat = 40.0
let itemsSideInset: CGFloat = sideInset + 20.0
var contentHeight: CGFloat = 0.0
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "passkey_logo"),
loop: false
)),
environment: {},
containerSize: CGSize(width: 124.0, height: 124.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight), size: iconSize)
if let iconView = self.icon.view as? LottieComponent.View {
if iconView.superview == nil {
self.contentContainer.addSubview(iconView)
iconView.playOnce()
}
transition.setPosition(view: iconView, position: iconFrame.center)
iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
}
contentHeight += iconSize.height
contentHeight += 10.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.Passkeys_Into_Title, font: Font.bold(27.0), textColor: component.theme.list.itemPrimaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.contentContainer.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.center)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
contentHeight += titleSize.height
contentHeight += 10.0
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(
text: .plain(NSAttributedString(string: component.strings.Passkeys_Subtitle, font: Font.regular(16.0), textColor: component.theme.list.itemPrimaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
self.contentContainer.addSubview(subtitleView)
}
transition.setPosition(view: subtitleView, position: subtitleFrame.center)
subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size)
}
contentHeight += subtitleSize.height
contentHeight += 47.0
struct ItemDesc {
var icon: String
var title: String
var text: String
}
let itemDescs: [ItemDesc] = [
ItemDesc(
icon: "Settings/Passkeys/Intro1",
title: component.strings.Passkeys_Into_Title0,
text: component.strings.Passkeys_Into_Text0
),
ItemDesc(
icon: "Settings/Passkeys/Intro2",
title: component.strings.Passkeys_Into_Title1,
text: component.strings.Passkeys_Into_Text1
),
ItemDesc(
icon: "Settings/Passkeys/Intro3",
title: component.strings.Passkeys_Into_Title2,
text: component.strings.Passkeys_Into_Text2
)
]
for i in 0 ..< itemDescs.count {
if i != 0 {
contentHeight += 24.0
}
let item: Item
if self.items.count > i {
item = self.items[i]
} else {
item = Item()
self.items.append(item)
}
let itemDesc = itemDescs[i]
let iconSize = item.icon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: itemDesc.icon,
tintColor: component.theme.list.itemAccentColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let titleSize = item.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: itemDesc.title, font: Font.semibold(15.0), textColor: component.theme.list.itemPrimaryTextColor)),
maximumNumberOfLines: 0,
lineSpacing: 0.2
)),
environment: {},
containerSize: CGSize(width: availableSize.width - itemsSideInset * 2.0 - sideIconInset, height: 1000.0)
)
let textSize = item.text.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: itemDesc.text, font: Font.regular(15.0), textColor: component.theme.list.itemSecondaryTextColor)),
maximumNumberOfLines: 0,
lineSpacing: 0.18
)),
environment: {},
containerSize: CGSize(width: availableSize.width - itemsSideInset * 2.0 - sideIconInset, height: 1000.0)
)
if let iconView = item.icon.view {
if iconView.superview == nil {
self.contentContainer.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: itemsSideInset, y: contentHeight + 4.0), size: iconSize))
}
if let titleView = item.title.view {
if titleView.superview == nil {
self.contentContainer.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: itemsSideInset + sideIconInset, y: contentHeight), size: titleSize))
}
contentHeight += titleSize.height
contentHeight += 2.0
if let textView = item.text.view {
if textView.superview == nil {
self.contentContainer.addSubview(textView)
}
transition.setFrame(view: textView, frame: CGRect(origin: CGPoint(x: itemsSideInset + sideIconInset, y: contentHeight), size: textSize))
}
contentHeight += textSize.height
}
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: component.insets.bottom, innerDiameter: 52.0, sideInset: 32.0)
let actionButtonSize = self.actionButton.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: component.theme.list.itemCheckColors.fillColor,
foreground: component.theme.list.itemCheckColors.foregroundColor,
pressedColor: component.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9),
cornerRadius: 52.0 * 0.5
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: component.strings.Passkeys_ButtonCreate, font: Font.semibold(17.0), textColor: component.theme.list.itemCheckColors.foregroundColor))))
),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.createPasskeyAction()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0)
)
var skipButtonSize: CGSize?
if component.displaySkip {
let skipButton: ComponentView<Empty>
if let current = self.skipButton {
skipButton = current
} else {
skipButton = ComponentView()
self.skipButton = skipButton
}
skipButtonSize = skipButton.update(
transition: transition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: component.theme.chatList.unreadBadgeInactiveBackgroundColor,
foreground: component.theme.list.itemCheckColors.foregroundColor,
pressedColor: component.theme.chatList.unreadBadgeInactiveBackgroundColor.withMultipliedAlpha(0.9),
cornerRadius: 52.0 * 0.5
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: component.strings.Passkeys_ButtonSkip, font: Font.semibold(17.0), textColor: component.theme.list.itemCheckColors.foregroundColor))))
),
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.skipAction()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0)
)
} else if let skipButton = self.skipButton {
self.skipButton = nil
skipButton.view?.removeFromSuperview()
}
var buttonFrame = CGRect(origin: CGPoint(x: buttonInsets.left, y: availableSize.height - buttonInsets.bottom - actionButtonSize.height), size: actionButtonSize)
if let skipButtonSize, let skipButtonView = self.skipButton?.view {
let skipButtonFrame = buttonFrame
buttonFrame = buttonFrame.offsetBy(dx: 0.0, dy: -8.0 - skipButtonSize.height)
if skipButtonView.superview == nil {
self.addSubview(skipButtonView)
}
transition.setFrame(view: skipButtonView, frame: skipButtonFrame)
}
if let actionButtonView = self.actionButton.view {
if actionButtonView.superview == nil {
self.addSubview(actionButtonView)
}
transition.setFrame(view: actionButtonView, frame: buttonFrame)
}
let contentTopInset = component.insets.top
let contentBottomInset = availableSize.height - buttonFrame.minY
let contentContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: max(contentTopInset, floor((availableSize.height - contentTopInset - contentBottomInset - contentHeight) * 0.5))), size: CGSize(width: availableSize.width, height: contentHeight))
transition.setFrame(view: self.contentContainer, frame: contentContainerFrame)
self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize)
let scrollContentSize = CGSize(width: availableSize.width, height: contentContainerFrame.maxY)
if self.scrollView.contentSize != scrollContentSize {
self.scrollView.contentSize = scrollContentSize
}
let scrollInsets = UIEdgeInsets(top: component.insets.top, left: 0.0, bottom: 0.0, right: 0.0)
if self.scrollView.verticalScrollIndicatorInsets != scrollInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollInsets
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,363 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import AccountContext
import LottieComponent
import MultilineTextComponent
import BalancedTextComponent
import ButtonComponent
import BundleIconComponent
import ListSectionComponent
import ListActionItemComponent
import TelegramCore
import EmojiStatusComponent
final class PasskeysScreenListComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let insets: UIEdgeInsets
let passkeys: [TelegramPasskey]
let addPasskeyAction: () -> Void
let deletePasskeyAction: (String) -> Void
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
insets: UIEdgeInsets,
passkeys: [TelegramPasskey],
addPasskeyAction: @escaping () -> Void,
deletePasskeyAction: @escaping (String) -> Void
) {
self.context = context
self.theme = theme
self.strings = strings
self.insets = insets
self.passkeys = passkeys
self.addPasskeyAction = addPasskeyAction
self.deletePasskeyAction = deletePasskeyAction
}
static func ==(lhs: PasskeysScreenListComponent, rhs: PasskeysScreenListComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.insets != rhs.insets {
return false
}
if lhs.passkeys != rhs.passkeys {
return false
}
return true
}
private final class ScrollViewImpl: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
class View: UIView, UIScrollViewDelegate {
private let scrollView: ScrollViewImpl
private let contentContainer: UIView
private let icon = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private let listSection = ComponentView<Empty>()
private var component: PasskeysScreenListComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.scrollView = ScrollViewImpl()
self.contentContainer = UIView()
self.scrollView.addSubview(self.contentContainer)
super.init(frame: frame)
self.scrollView.delaysContentTouches = true
self.scrollView.canCancelContentTouches = true
self.scrollView.clipsToBounds = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: PasskeysScreenListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
var maxPasskeys = 5
if let data = component.context.currentAppConfiguration.with({ $0 }).data, let maxValue = data["passkeys_account_passkeys_max"] as? Double {
maxPasskeys = Int(maxValue)
}
self.backgroundColor = component.theme.list.blocksBackgroundColor
let sideInset: CGFloat = 16.0 + component.insets.left
var contentHeight: CGFloat = 0.0
contentHeight += component.insets.top
contentHeight += 8.0
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "passkey_logo"),
loop: false
)),
environment: {},
containerSize: CGSize(width: 124.0, height: 124.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight), size: iconSize)
if let iconView = self.icon.view as? LottieComponent.View {
if iconView.superview == nil {
self.contentContainer.addSubview(iconView)
iconView.playOnce()
}
transition.setPosition(view: iconView, position: iconFrame.center)
iconView.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
}
contentHeight += iconSize.height
contentHeight += 10.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.Passkeys_Title, font: Font.bold(27.0), textColor: component.theme.list.itemPrimaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.contentContainer.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.center)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
contentHeight += titleSize.height
contentHeight += 10.0
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(
text: .plain(NSAttributedString(string: component.strings.Passkeys_Subtitle, font: Font.regular(16.0), textColor: component.theme.list.itemPrimaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
let subtitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - subtitleSize.width) * 0.5), y: contentHeight), size: subtitleSize)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
self.contentContainer.addSubview(subtitleView)
}
transition.setPosition(view: subtitleView, position: subtitleFrame.center)
subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size)
}
contentHeight += subtitleSize.height
contentHeight += 32.0
var listSectionItems: [AnyComponentWithIdentity<Empty>] = []
for passkey in component.passkeys {
if listSectionItems.contains(where: { $0.id == AnyHashable(passkey.id) }) {
continue
}
let passkeyId = passkey.id
let dateFormatter = DateFormatter()
dateFormatter.timeStyle = .none
dateFormatter.dateStyle = .medium
let dateString = dateFormatter.string(from: Date(timeIntervalSince1970: Double(passkey.date)))
let iconComponent: AnyComponentWithIdentity<Empty>
if let emojiId = passkey.emojiId {
iconComponent = AnyComponentWithIdentity(
id: "lottie",
component: AnyComponent(TransformContents<Empty>(
content: AnyComponent(EmojiStatusComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
content: .animation(
content: .customEmoji(fileId: emojiId),
size: CGSize(width: 40.0, height: 40.0),
placeholderColor: component.theme.list.mediaPlaceholderColor,
themeColor: nil,
loopMode: .count(1)
),
size: CGSize(width: 40.0, height: 40.0),
isVisibleForAnimations: true,
action: nil
)),
translation: CGPoint(x: 0.0, y: 1.0)
))
)
} else {
iconComponent = AnyComponentWithIdentity(
id: "icon",
component: AnyComponent(BundleIconComponent(name: "Settings/Menu/Passkeys", tintColor: nil))
)
}
let subtitleString: String
if let lastUsageDate = passkey.lastUsageDate {
let lastUsedDateString = dateFormatter.string(from: Date(timeIntervalSince1970: Double(lastUsageDate)))
subtitleString = component.strings.Passkeys_PasskeyCreatedAndUsedPattern(dateString, lastUsedDateString).string
} else {
subtitleString = component.strings.Passkeys_PasskeyCreatedPattern(dateString).string
}
listSectionItems.append(AnyComponentWithIdentity(id: passkey.id, component: AnyComponent(ListActionItemComponent(
theme: component.theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: passkey.name.isEmpty ? component.strings.Passkeys_EmptyName : passkey.name,
font: Font.regular(17.0),
textColor: component.theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: subtitleString,
font: Font.regular(14.0),
textColor: component.theme.list.itemSecondaryTextColor
)),
maximumNumberOfLines: 1
)))
], alignment: .left, spacing: 2.0)),
leftIcon: .custom(
iconComponent,
false
),
accessory: nil,
contextOptions: [ListActionItemComponent.ContextOption(
id: "delete",
title: component.strings.Common_Delete,
color: component.theme.list.itemDisclosureActions.destructive.fillColor,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.deletePasskeyAction(passkeyId)
}
)],
action: nil,
highlighting: .default
))))
}
if component.passkeys.count < maxPasskeys {
listSectionItems.append(AnyComponentWithIdentity(id: "_add", component: AnyComponent(ListActionItemComponent(
theme: component.theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: component.strings.Passkeys_AddPasskey,
font: Font.regular(17.0),
textColor: component.theme.list.itemAccentColor
)),
maximumNumberOfLines: 1
)))
], alignment: .left, spacing: 2.0)),
leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent(
name: "Chat List/AddIcon",
tintColor: component.theme.list.itemAccentColor
))), false),
accessory: nil,
action: { [weak self] _ in
guard let self, let component = self.component else {
return
}
component.addPasskeyAction()
},
highlighting: .default
))))
}
let listSectionSize = self.listSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: component.theme,
style: .glass,
header: nil,
footer: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: component.strings.Passkeys_ListFooter,
font: Font.regular(13.0),
textColor: component.theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
items: listSectionItems
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
)
let listSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: listSectionSize)
if let listSectionView = self.listSection.view as? ListSectionComponent.View {
if listSectionView.superview == nil {
self.contentContainer.addSubview(listSectionView)
self.listSection.parentState = state
}
transition.setFrame(view: listSectionView, frame: listSectionFrame)
}
contentHeight += listSectionSize.height
contentHeight += 8.0
contentHeight += component.insets.bottom
let contentContainerFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: contentHeight))
transition.setFrame(view: self.contentContainer, frame: contentContainerFrame)
self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize)
let scrollContentSize = CGSize(width: availableSize.width, height: contentContainerFrame.maxY)
if self.scrollView.contentSize != scrollContentSize {
self.scrollView.contentSize = scrollContentSize
}
let scrollInsets = UIEdgeInsets(top: component.insets.top, left: 0.0, bottom: 0.0, right: 0.0)
if self.scrollView.verticalScrollIndicatorInsets != scrollInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollInsets
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}