From 765aa49f41ef7edeec04d536b39fd091fb03e0d3 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Tue, 2 Dec 2025 17:01:07 +0800 Subject: [PATCH] Passkeys --- .../Components/ListActionItemComponent/BUILD | 1 + .../Sources/ContentContainer.swift | 260 ++++++++++++++++++ .../Sources/ListActionItemComponent.swift | 74 ++++- .../Sources/PasskeysScreen.swift | 7 + .../Sources/PasskeysScreenListComponent.swift | 52 +++- 5 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 submodules/TelegramUI/Components/ListActionItemComponent/Sources/ContentContainer.swift diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/BUILD b/submodules/TelegramUI/Components/ListActionItemComponent/BUILD index e9b7043eb5..c656c7429c 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/BUILD +++ b/submodules/TelegramUI/Components/ListActionItemComponent/BUILD @@ -16,6 +16,7 @@ swift_library( "//submodules/TelegramUI/Components/ListSectionComponent", "//submodules/SwitchNode", "//submodules/CheckNode", + "//submodules/Components/MultilineTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ContentContainer.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ContentContainer.swift new file mode 100644 index 0000000000..aa6362378b --- /dev/null +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ContentContainer.swift @@ -0,0 +1,260 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import MultilineTextComponent + +private final class ContextOptionComponent: Component { + let title: String + let color: UIColor + let isLast: Bool + let action: () -> Void + + init( + title: String, + color: UIColor, + isLast: Bool, + action: @escaping () -> Void + ) { + self.title = title + self.color = color + self.isLast = isLast + self.action = action + } + + static func ==(lhs: ContextOptionComponent, rhs: ContextOptionComponent) -> Bool { + if lhs.title != rhs.title { + return false + } + if lhs.color != rhs.color { + return false + } + if lhs.isLast != rhs.isLast { + return false + } + return true + } + + final class View: UIView { + let backgroundView: UIView + let title = ComponentView() + + var component: ContextOptionComponent? + + override init(frame: CGRect) { + self.backgroundView = UIView() + + super.init(frame: frame) + + self.addSubview(self.backgroundView) + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action() + } + } + + func update(component: ContextOptionComponent, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + let sideInset: CGFloat = 8.0 + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + ) + let size = CGSize(width: sideInset * 2.0 + titleSize.width, height: availableSize.height) + let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: floorToScreenPixels((size.height - titleSize.height) * 0.5)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.center) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + } + + self.backgroundView.backgroundColor = component.color + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width + (component.isLast ? 1000.0 : 0.0), height: size.height))) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +final class ContentContainer: UIScrollView, UIScrollViewDelegate { + private var itemViews: [AnyHashable: ComponentView] = [:] + + private var ignoreScrollingEvents: Bool = false + private var draggingBeganInClosedState: Bool = false + + private var contextOptions: [ListActionItemComponent.ContextOption] = [] + private var optionsWidth: CGFloat = 0.0 + + private var revealedStateTapRecognizer: UITapGestureRecognizer? + + override init(frame: CGRect) { + super.init(frame: frame) + + let revealedStateTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))) + self.revealedStateTapRecognizer = revealedStateTapRecognizer + revealedStateTapRecognizer.isEnabled = false + self.addGestureRecognizer(revealedStateTapRecognizer) + + self.delaysContentTouches = false + self.canCancelContentTouches = true + self.clipsToBounds = false + self.contentInsetAdjustmentBehavior = .never + self.automaticallyAdjustsScrollIndicatorInsets = false + self.showsVerticalScrollIndicator = false + self.showsHorizontalScrollIndicator = false + self.alwaysBounceHorizontal = false + self.alwaysBounceVertical = false + self.scrollsToTop = false + self.delegate = self + + self.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in + guard let self else { + return false + } + + if self.contentOffset.x != 0.0 { + return true + } + + return false + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func onTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true) + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.revealedStateTapRecognizer?.isEnabled = self.contentOffset.x > 0.0 + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + self.draggingBeganInClosedState = self.contentOffset.x == 0.0 + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + targetContentOffset.pointee.x = self.contentOffset.x + + if self.contentOffset.x >= self.optionsWidth + 30.0 { + self.contextOptions.last?.action() + } else { + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + if self.draggingBeganInClosedState { + if self.contentOffset.x > 20.0 { + self.setContentOffset(CGPoint(x: self.optionsWidth, y: 0.0), animated: true) + } else { + self.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true) + } + } else { + if self.contentOffset.x < self.optionsWidth - 20.0 { + self.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true) + } else { + self.setContentOffset(CGPoint(x: self.optionsWidth, y: 0.0), animated: true) + } + } + } + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let revealedStateTapRecognizer = self.revealedStateTapRecognizer, revealedStateTapRecognizer.isEnabled { + if self.bounds.contains(point), point.x < self.bounds.width { + return self + } + } + guard let result = super.hitTest(point, with: event) else { + return nil + } + return result + } + + func update(size: CGSize, contextOptions: [ListActionItemComponent.ContextOption], transition: ComponentTransition) { + self.contextOptions = contextOptions + + var validIds: [AnyHashable] = [] + var optionsWidth: CGFloat = 0.0 + for i in 0 ..< contextOptions.count { + let option = contextOptions[i] + validIds.append(option.id) + + let itemView: ComponentView + var itemTransition = transition + if let current = self.itemViews[option.id] { + itemView = current + } else { + itemTransition = itemTransition.withAnimation(.none) + itemView = ComponentView() + self.itemViews[option.id] = itemView + } + + let itemSize = itemView.update( + transition: itemTransition, + component: AnyComponent(ContextOptionComponent( + title: option.title, + color: option.color, + isLast: i == contextOptions.count - 1, + action: option.action + )), + environment: {}, + containerSize: CGSize(width: 10000.0, height: size.height) + ) + let itemFrame = CGRect(origin: CGPoint(x: size.width + optionsWidth, y: 0.0), size: itemSize) + optionsWidth += itemSize.width + if let itemComponentView = itemView.view { + self.addSubview(itemComponentView) + itemTransition.setFrame(view: itemComponentView, frame: itemFrame) + } + } + var removedIds: [AnyHashable] = [] + for (id, itemView) in self.itemViews { + if !validIds.contains(id) { + removedIds.append(id) + if let itemComponentView = itemView.view { + itemComponentView.removeFromSuperview() + } + } + } + for id in removedIds { + self.itemViews.removeValue(forKey: id) + } + self.optionsWidth = optionsWidth + + let contentSize = CGSize(width: size.width + optionsWidth, height: size.height) + if self.contentSize != contentSize { + self.contentSize = contentSize + } + self.isScrollEnabled = optionsWidth != 0.0 + } +} diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index 05b33fcc04..e578f095b1 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -138,6 +138,33 @@ public final class ListActionItemComponent: Component { case center } + public final class ContextOption: Equatable { + public let id: AnyHashable + public let title: String + public let color: UIColor + public let action: () -> Void + + public init(id: AnyHashable, title: String, color: UIColor, action: @escaping () -> Void) { + self.id = id + self.title = title + self.color = color + self.action = action + } + + public static func ==(lhs: ContextOption, rhs: ContextOption) -> Bool { + if lhs.id != rhs.id { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.color != rhs.color { + return false + } + return true + } + } + public let theme: PresentationTheme public let style: Style public let background: AnyComponent? @@ -147,6 +174,7 @@ public final class ListActionItemComponent: Component { public let leftIcon: LeftIcon? public let icon: Icon? public let accessory: Accessory? + public let contextOptions: [ContextOption] public let action: ((UIView) -> Void)? public let highlighting: Highlighting public let updateIsHighlighted: ((UIView, Bool) -> Void)? @@ -161,6 +189,7 @@ public final class ListActionItemComponent: Component { leftIcon: LeftIcon? = nil, icon: Icon? = nil, accessory: Accessory? = .arrow, + contextOptions: [ContextOption] = [], action: ((UIView) -> Void)?, highlighting: Highlighting = .default, updateIsHighlighted: ((UIView, Bool) -> Void)? = nil @@ -174,6 +203,7 @@ public final class ListActionItemComponent: Component { self.leftIcon = leftIcon self.icon = icon self.accessory = accessory + self.contextOptions = contextOptions self.action = action self.highlighting = highlighting self.updateIsHighlighted = updateIsHighlighted @@ -207,6 +237,9 @@ public final class ListActionItemComponent: Component { if lhs.accessory != rhs.accessory { return false } + if lhs.contextOptions != rhs.contextOptions { + return false + } if (lhs.action == nil) != (rhs.action == nil) { return false } @@ -289,7 +322,9 @@ public final class ListActionItemComponent: Component { } } - public final class View: HighlightTrackingButton, ListSectionComponent.ChildView { + public final class View: UIView, ListSectionComponent.ChildView { + private let container: ContentContainer + private let button: HighlightTrackingButton private var background: ComponentView? private let title = ComponentView() private var leftIcon: ComponentView? @@ -316,10 +351,16 @@ public final class ListActionItemComponent: Component { public var separatorInset: CGFloat = 0.0 public override init(frame: CGRect) { + self.container = ContentContainer(frame: CGRect()) + self.button = HighlightTrackingButton() + super.init(frame: CGRect()) - self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) - self.internalHighligthedChanged = { [weak self] isHighlighted in + self.addSubview(self.container) + self.container.addSubview(self.button) + + self.button.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) + self.button.internalHighligthedChanged = { [weak self] isHighlighted in guard let self, let component = self.component, component.action != nil else { return } @@ -479,7 +520,7 @@ public final class ListActionItemComponent: Component { let iconFrame = CGRect(origin: CGPoint(x: availableSize.width - contentRightInset - iconSize.width + iconOffset, y: floor((contentHeight - iconSize.height) * 0.5)), size: iconSize) if let iconView = icon.view { if iconView.superview == nil { - self.addSubview(iconView) + self.button.addSubview(iconView) transition.animateAlpha(view: iconView, from: 0.0, to: 1.0) } iconView.isUserInteractionEnabled = iconValue.allowUserInteraction @@ -516,7 +557,7 @@ public final class ListActionItemComponent: Component { animateIn = true leftCheckView = CheckView() self.leftCheckView = leftCheckView - self.addSubview(leftCheckView) + self.button.addSubview(leftCheckView) leftCheckView.action = { [weak self] in guard let self, let component = self.component else { @@ -596,7 +637,7 @@ public final class ListActionItemComponent: Component { if let leftIconView = leftIcon.view { if leftIconView.superview == nil { leftIconView.isUserInteractionEnabled = false - self.addSubview(leftIconView) + self.button.addSubview(leftIconView) transition.animateAlpha(view: leftIconView, from: 0.0, to: 1.0) } leftIconTransition.setFrame(view: leftIconView, frame: leftIconFrame) @@ -634,7 +675,7 @@ public final class ListActionItemComponent: Component { arrowTransition = arrowTransition.withAnimation(.none) arrowView = UIImageView(image: PresentationResourcesItemList.disclosureArrowImage(component.theme)) self.arrowView = arrowView - self.addSubview(arrowView) + self.button.addSubview(arrowView) } if let image = arrowView.image { @@ -660,7 +701,7 @@ public final class ListActionItemComponent: Component { arrowTransition = arrowTransition.withAnimation(.none) arrowView = UIImageView(image: PresentationResourcesItemList.disclosureOptionArrowsImage(component.theme)) self.arrowView = arrowView - self.addSubview(arrowView) + self.button.addSubview(arrowView) } if let image = arrowView.image { @@ -689,7 +730,7 @@ public final class ListActionItemComponent: Component { switchNode = SwitchNode() switchNode.setOn(toggle.isOn, animated: false) self.switchNode = switchNode - self.addSubview(switchNode.view) + self.button.addSubview(switchNode.view) switchNode.valueUpdated = { [weak self] value in guard let self, let component = self.component else { @@ -739,7 +780,7 @@ public final class ListActionItemComponent: Component { switchNode.updateIsLocked(toggle.style == .lock) switchNode.setOn(toggle.isOn, animated: false) self.iconSwitchNode = switchNode - self.addSubview(switchNode.view) + self.button.addSubview(switchNode.view) switchNode.valueUpdated = { [weak self] value in guard let self, let component = self.component else { @@ -792,7 +833,7 @@ public final class ListActionItemComponent: Component { activityIndicatorView = UIActivityIndicatorView(style: .gray) } self.activityIndicatorView = activityIndicatorView - self.addSubview(activityIndicatorView) + self.button.addSubview(activityIndicatorView) activityIndicatorView.sizeToFit() } @@ -818,7 +859,7 @@ public final class ListActionItemComponent: Component { if let customAccessoryComponentView = customAccessoryView.view { if customAccessoryComponentView.superview == nil { customAccessoryComponentView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.0) - self.addSubview(customAccessoryComponentView) + self.button.addSubview(customAccessoryComponentView) } customAccessoryComponentView.isUserInteractionEnabled = customAccessory.isInteractive customAccessoryTransition.setPosition(view: customAccessoryComponentView, position: CGPoint(x: activityAccessoryFrame.maxX, y: activityAccessoryFrame.minY)) @@ -835,7 +876,7 @@ public final class ListActionItemComponent: Component { if let titleView = self.title.view { if titleView.superview == nil { titleView.isUserInteractionEnabled = false - self.addSubview(titleView) + self.button.addSubview(titleView) } transition.setFrame(view: titleView, frame: titleFrame) } @@ -879,7 +920,12 @@ public final class ListActionItemComponent: Component { } } - return CGSize(width: availableSize.width, height: contentHeight) + let size = CGSize(width: availableSize.width, height: contentHeight) + self.container.update(size: size, contextOptions: component.contextOptions, transition: transition) + transition.setFrame(view: self.container, frame: CGRect(origin: CGPoint(), size: size)) + transition.setFrame(view: self.button, frame: CGRect(origin: CGPoint(), size: size)) + + return size } } diff --git a/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreen.swift b/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreen.swift index bedfb253ea..06d143f3de 100644 --- a/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreen.swift +++ b/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreen.swift @@ -299,8 +299,15 @@ final class PasskeysScreenComponent: Component { 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 diff --git a/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreenListComponent.swift b/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreenListComponent.swift index dfafe38578..862e6600ed 100644 --- a/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreenListComponent.swift +++ b/submodules/TelegramUI/Components/Settings/PasskeysScreen/Sources/PasskeysScreenListComponent.swift @@ -17,21 +17,27 @@ 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 } @@ -42,6 +48,9 @@ final class PasskeysScreenListComponent: Component { if lhs.theme !== rhs.theme { return false } + if lhs.strings !== rhs.strings { + return false + } if lhs.insets != rhs.insets { return false } @@ -101,6 +110,11 @@ final class PasskeysScreenListComponent: Component { 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 @@ -227,7 +241,7 @@ final class PasskeysScreenListComponent: Component { title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: passkey.name, + string: passkey.name.isEmpty ? "Passkey" : passkey.name, //TODO:localize font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor )), @@ -247,11 +261,45 @@ final class PasskeysScreenListComponent: Component { 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: "Create Passkey", //TODO:localize + 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.deletePasskeyAction(passkeyId) + component.addPasskeyAction() }, highlighting: .default ))))