import Foundation import UIKit import Display import AsyncDisplayKit import TelegramCore import SwiftSignalKit import AccountContext import TelegramPresentationData import ComponentFlow import ViewControllerComponent import SheetComponent import MultilineTextComponent import GlassBarButtonComponent import ButtonComponent import PlainButtonComponent import PresentationDataUtils import BundleIconComponent import ListSectionComponent import ListActionItemComponent import AvatarComponent import Markdown import PhoneNumberFormat import ContextUI import AccountUtils import GlassBackgroundComponent import AccountPeerContextItem import ActivityIndicator import LottieComponent import LottieComponentResourceContent private final class AuthConfirmationSheetContent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let requestSubject: MessageActionUrlSubject let subject: MessageActionUrlAuthResult let completion: (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void let cancel: (Bool) -> Void init( context: AccountContext, requestSubject: MessageActionUrlSubject, subject: MessageActionUrlAuthResult, completion: @escaping (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void, cancel: @escaping (Bool) -> Void ) { self.context = context self.requestSubject = requestSubject self.subject = subject self.completion = completion self.cancel = cancel } static func ==(lhs: AuthConfirmationSheetContent, rhs: AuthConfirmationSheetContent) -> Bool { if lhs.context !== rhs.context { return false } return true } final class State: ComponentState { private let context: AccountContext private let requestSubject: MessageActionUrlSubject private let subject: MessageActionUrlAuthResult private let completion: (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void private let disposables = DisposableSet() var peer: EnginePeer? var forcedAccount: (AccountContext, EnginePeer)? fileprivate var inProgress = false var allowWrite = true weak var controller: ViewController? var displayEmoji = false var matchCodes: [String]? var selectedMatchCode: String? init(context: AccountContext, requestSubject: MessageActionUrlSubject, subject: MessageActionUrlAuthResult, completion: @escaping (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void) { self.context = context self.requestSubject = requestSubject self.subject = subject self.completion = completion super.init() let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, let peer else { return } self.peer = peer self.updated() }) if case let .request(_, _, _, flags, matchCodes, _) = self.subject, let matchCodes { if flags.contains(.showMatchCodesFirst) { self.displayEmoji = true self.matchCodes = matchCodes.shuffled() } else { for code in matchCodes { var file: TelegramMediaFile? if let item = context.animatedEmojiStickersValue[code] { file = item.first?.file._parse() } else if let item = context.animatedEmojiStickersValue[code.strippedEmoji] { file = item.first?.file._parse() } if let file { self.disposables.add(freeMediaFileResourceInteractiveFetched(account: context.account, userLocation: .other, fileReference: .standalone(media: file), resource: file.resource).start()) } } } } if case let .request(_, _, _, _, _, userIdHint) = self.subject, let userIdHint, userIdHint != context.account.peerId { let _ = (activeAccountsAndPeers(context: self.context, includePrimary: true) |> take(1) |> deliverOnMainQueue).start(next: { [weak self] primary, other in guard let self else { return } for (accountContext, peer, _) in other { if peer.id == userIdHint { self.forcedAccount = (accountContext, peer) self.updated() accountContext.account.shouldBeServiceTaskMaster.set(.single(.now)) let _ = accountContext.engine.messages.requestMessageActionUrlAuth(subject: requestSubject).start() break } } }) } } deinit { self.disposables.dispose() if !self.inProgress { if let (context, _) = self.forcedAccount, context !== self.context { context.account.shouldBeServiceTaskMaster.set(.single(.never)) } } } func checkMatchCode(_ matchCode: String) { guard case let .url(url, _) = self.requestSubject else { return } self.selectedMatchCode = matchCode self.updated(transition: .easeInOut(duration: 0.2)) let _ = (self.context.engine.messages.checkUrlAuthMatchCode(url: url, matchCode: matchCode) |> deliverOnMainQueue).start(next: { [weak self] result in guard let self else { return } if result { self.displayEmoji = false self.updated(transition: .spring(duration: 0.4)) } else { let accountContext = self.forcedAccount?.0 ?? self.context guard let accountPeer = self.forcedAccount?.1 ?? self.peer else { return } self.completion(accountContext, accountPeer, .failed) } }) } func displayPhoneNumberConfirmation(commit: @escaping (Bool) -> Void) { guard case let .request(domain, _, _, _, _, _) = self.subject else { return } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, case let .user(user) = peer, let phone = user.phone else { return } let phoneNumber = formatPhoneNumber(context: self.context, number: phone).replacingOccurrences(of: " ", with: "\u{00A0}") let alertController = textAlertController( context: self.context, title: presentationData.strings.AuthConfirmation_PhoneNumberConfirmation_Title, text: presentationData.strings.AuthConfirmation_PhoneNumberConfirmation_Text(domain, phoneNumber).string, actions: [ TextAlertAction(type: .genericAction, title: presentationData.strings.AuthConfirmation_PhoneNumberConfirmation_Deny, action: { commit(false) }), TextAlertAction(type: .defaultAction, title: presentationData.strings.AuthConfirmation_PhoneNumberConfirmation_Allow, action: { commit(true) }) ] ) self.controller?.present(alertController, in: .window(.root)) }) } func presentAccountSwitchMenu(sourceView: GlassContextExtractableContainer) { guard let controller = self.controller else { return } let context = self.context let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let items: Signal<[ContextMenuItem], NoError> = activeAccountsAndPeers(context: self.context, includePrimary: true) |> take(1) |> map { primary, other -> [ContextMenuItem] in var items: [ContextMenuItem] = [] var existingIds = Set() if let (_, peer) = primary { items.append(.custom(AccountPeerContextItem(context: context, account: context.account, peer: peer, action: { [weak self] _, f in f(.default) guard let self else { return } if let (context, _) = self.forcedAccount, context !== self.context { context.account.shouldBeServiceTaskMaster.set(.single(.never)) } self.forcedAccount = nil self.updated() }), true)) existingIds.insert(peer.id) } for (accountContext, peer, _) in other { guard !existingIds.contains(peer.id) else { continue } items.append(.custom(AccountPeerContextItem(context: accountContext, account: accountContext.account, peer: peer, action: { [weak self] _, f in f(.default) guard let self else { return } if let (context, _) = self.forcedAccount, context !== self.context { context.account.shouldBeServiceTaskMaster.set(.single(.never)) } accountContext.account.shouldBeServiceTaskMaster.set(.single(.now)) self.forcedAccount = (accountContext, peer) self.updated() }), true)) } return items } let contextController = makeContextController(presentationData: presentationData, source: .reference(AuthConfirmationReferenceContentSource(controller: controller, sourceView: sourceView)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: nil) controller.presentInGlobalOverlay(contextController) } } func makeState() -> State { return State(context: self.context, requestSubject: self.requestSubject, subject: self.subject, completion: self.completion) } static var body: Body { let closeButton = Child(GlassBarButtonComponent.self) let accountButton = Child(AccountSwitchComponent.self) let avatar = Child(AvatarComponent.self) let title = Child(MultilineTextComponent.self) let description = Child(MultilineTextComponent.self) let emojiTitle = Child(MultilineTextComponent.self) let emojiDescription = Child(MultilineTextComponent.self) let emojis = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) let clientSection = Child(ListSectionComponent.self) let optionsSection = Child(ListSectionComponent.self) let cancelButton = Child(ButtonComponent.self) let doneButton = Child(ButtonComponent.self) return { context in let environment = context.environment[ViewControllerComponentContainer.Environment.self].value let component = context.component let theme = environment.theme let strings = environment.strings let state = context.state if state.controller == nil { state.controller = environment.controller() } let presentationData = context.component.context.sharedContext.currentPresentationData.with { $0 } guard case let .request(domain, bot, clientData, flags, matchCodes, _) = component.subject else { fatalError() } let sideInset: CGFloat = 16.0 + environment.safeInsets.left let closeButton = closeButton.update( component: GlassBarButtonComponent( size: CGSize(width: 44.0, height: 44.0), backgroundColor: nil, isDark: theme.overallDarkAppearance, state: .glass, component: AnyComponentWithIdentity(id: "close", component: AnyComponent( BundleIconComponent( name: "Navigation/Close", tintColor: theme.chat.inputPanel.panelControlColor ) )), action: { _ in component.cancel(true) } ), availableSize: CGSize(width: 44.0, height: 44.0), transition: .immediate ) context.add(closeButton .position(CGPoint(x: 16.0 + closeButton.size.width / 2.0, y: 16.0 + closeButton.size.height / 2.0)) ) if let peer = state.peer { let accountButton = accountButton.update( component: AccountSwitchComponent( context: state.forcedAccount?.0 ?? component.context, theme: environment.theme, peer: state.forcedAccount?.1 ?? peer, canSwitch: true, isVisible: true, action: { [weak state] sourceView in state?.presentAccountSwitchMenu(sourceView: sourceView) } ), availableSize: context.availableSize, transition: .immediate ) context.add(accountButton .position(CGPoint(x: context.availableSize.width - 16.0 - accountButton.size.width / 2.0, y: 16.0 + accountButton.size.height / 2.0)) ) } let titleFont = Font.bold(24.0) let textFont = Font.regular(15.0) let boldTextFont = Font.semibold(15.0) let complete: (String?) -> Void = { [weak state] matchCode in guard let state else { return } var allowWrite = false if flags.contains(.requestWriteAccess) && state.allowWrite { allowWrite = true } let accountContext = state.forcedAccount?.0 ?? component.context guard let accountPeer = state.forcedAccount?.1 ?? state.peer else { return } if flags.contains(.requestPhoneNumber) { state.displayPhoneNumberConfirmation(commit: { sharePhoneNumber in component.completion(accountContext, accountPeer, .accept(allowWriteAccess: allowWrite, sharePhoneNumber: sharePhoneNumber, matchCode: matchCode)) state.inProgress = true state.selectedMatchCode = matchCode state.updated() }) } else { component.completion(accountContext, accountPeer, .accept(allowWriteAccess: allowWrite, sharePhoneNumber: false, matchCode: matchCode)) state.inProgress = true state.selectedMatchCode = matchCode state.updated() } } var contentHeight: CGFloat = 32.0 if state.displayEmoji, let matchCodes = state.matchCodes { contentHeight += 36.0 let emojiTitle = emojiTitle.update( component: MultilineTextComponent( text: .markdown(text: strings.AuthConfirmation_Emoji_Title, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: titleFont, textColor: theme.actionSheet.primaryTextColor), bold: MarkdownAttributeSet(font: titleFont, textColor: theme.actionSheet.controlAccentColor), link: MarkdownAttributeSet(font: titleFont, textColor: theme.actionSheet.primaryTextColor), linkAttribute: { _ in return nil })), horizontalAlignment: .center, maximumNumberOfLines: 2, lineSpacing: 0.2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(emojiTitle .position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight + emojiTitle.size.height / 2.0)) .appear(.default(scale: false, alpha: true)) .disappear(.default(scale: false, alpha: true)) ) contentHeight += emojiTitle.size.height contentHeight += 16.0 let emojiDescription = emojiDescription.update( component: MultilineTextComponent( text: .markdown(text: strings.AuthConfirmation_Emoji_Description, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: theme.actionSheet.primaryTextColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: theme.actionSheet.primaryTextColor), link: MarkdownAttributeSet(font: textFont, textColor: theme.actionSheet.primaryTextColor), linkAttribute: { _ in return nil })), horizontalAlignment: .center, maximumNumberOfLines: 3, lineSpacing: 0.2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(emojiDescription .position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight + emojiDescription.size.height / 2.0)) .appear(.default(scale: false, alpha: true)) .disappear(.default(scale: false, alpha: true)) ) contentHeight += emojiDescription.size.height contentHeight += 48.0 var emojiDelay: Double = 0.0 let emojiSize = CGSize(width: 64.0, height: 64.0) let emojiSpacing: CGFloat = 36.0 let totalWidth = CGFloat(matchCodes.count) * emojiSize.width + CGFloat(matchCodes.count - 1) * emojiSpacing var emojiOriginX = context.availableSize.width / 2.0 - totalWidth / 2.0 for code in matchCodes { var items: [AnyComponentWithIdentity] = [] items.append( AnyComponentWithIdentity(id: "background", component: AnyComponent( FilledRoundedRectangleComponent(color: theme.list.itemBlocksBackgroundColor, cornerRadius: .minEdge, smoothCorners: false) )) ) if state.selectedMatchCode == code { items.append( AnyComponentWithIdentity(id: "progress", component: AnyComponent( ActivityIndicatorComponent(color: theme.list.itemAccentColor) )) ) } var file: TelegramMediaFile? if let item = component.context.animatedEmojiStickersValue[code] { file = item.first?.file._parse() } else if let item = component.context.animatedEmojiStickersValue[code.strippedEmoji] { file = item.first?.file._parse() } if let file { items.append( AnyComponentWithIdentity(id: "animatedIcon", component: AnyComponent( LottieComponent(content: LottieComponent.ResourceContent(context: component.context, file: file, attemptSynchronously: true, providesPlaceholder: true), placeholderColor: theme.list.mediaPlaceholderColor, startingPosition: .begin, size: CGSize(width: 32.0, height: 32.0), loop: true, playOnce: nil) )) ) } else { items.append( AnyComponentWithIdentity(id: "staticIcon", component: AnyComponent( Text(text: code, font: Font.regular(32.0), color: .black) )) ) } let subject = component.subject let emoji = emojis[code].update( component: AnyComponent( PlainButtonComponent( content: AnyComponent( ZStack(items) ), minSize: emojiSize, action: { [weak state] in guard let state else { return } if case let .request(_, _, _, flags, _, _) = subject, flags.contains(.showMatchCodesFirst) { state.checkMatchCode(code) } else { complete(code) } }, isEnabled: state.selectedMatchCode == nil, animateAlpha: false, animateScale: true ) ), environment: {}, availableSize: emojiSize, transition: context.transition ) context.add(emoji .position(CGPoint(x: emojiOriginX + emojiSize.width / 2.0, y: contentHeight + emojiSize.height / 2.0)) .opacity(state.selectedMatchCode != nil && state.selectedMatchCode != code ? 0.6 : 1.0) .appear(ComponentTransition.Appear({ _, view, transition in if !transition.animation.isImmediate { transition.animateAlpha(view: view, from: 0.0, to: 1.0, delay: emojiDelay) transition.animateScale(view: view, from: 0.01, to: 1.0, delay: emojiDelay) } })) .disappear(.default(scale: true, alpha: true)) ) emojiOriginX += emojiSize.width + emojiSpacing emojiDelay += 0.08 } contentHeight += emojiSize.height contentHeight += 48.0 } else { let avatar = avatar.update( component: AvatarComponent( context: component.context, theme: environment.theme, peer: EnginePeer(bot), clipStyle: .roundedRect ), availableSize: CGSize(width: 92.0, height: 92.0), transition: .immediate ) context.add(avatar .position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight + avatar.size.height / 2.0)) .appear(.default(scale: false, alpha: true)) .disappear(.default(scale: false, alpha: true)) ) contentHeight += avatar.size.height contentHeight += 18.0 let title = title.update( component: MultilineTextComponent( text: .markdown(text: strings.AuthConfirmation_Title(domain).string, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: titleFont, textColor: theme.actionSheet.primaryTextColor), bold: MarkdownAttributeSet(font: titleFont, textColor: theme.actionSheet.controlAccentColor), link: MarkdownAttributeSet(font: titleFont, textColor: theme.actionSheet.primaryTextColor), linkAttribute: { _ in return nil })), horizontalAlignment: .center, maximumNumberOfLines: 2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(title .position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight + title.size.height / 2.0)) .appear(.default(scale: false, alpha: true)) .disappear(.default(scale: false, alpha: true)) ) contentHeight += title.size.height contentHeight += 16.0 let description = description.update( component: MultilineTextComponent( text: .markdown(text: strings.AuthConfirmation_Description, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: theme.actionSheet.primaryTextColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: theme.actionSheet.primaryTextColor), link: MarkdownAttributeSet(font: textFont, textColor: theme.actionSheet.primaryTextColor), linkAttribute: { _ in return nil })), horizontalAlignment: .center, maximumNumberOfLines: 3, lineSpacing: 0.2 ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0 - 60.0, height: CGFloat.greatestFiniteMagnitude), transition: .immediate ) context.add(description .position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight + description.size.height / 2.0)) .appear(.default(scale: false, alpha: true)) .disappear(.default(scale: false, alpha: true)) ) contentHeight += description.size.height contentHeight += 16.0 var clientSectionItems: [AnyComponentWithIdentity] = [] clientSectionItems.append( AnyComponentWithIdentity(id: "device", component: AnyComponent( ListActionItemComponent( theme: theme, style: .glass, title: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.AuthConfirmation_Device, font: Font.regular(17.0), textColor: theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 )), contentInsets: UIEdgeInsets(top: 19.0, left: 0.0, bottom: 19.0, right: 0.0), accessory: .custom(ListActionItemComponent.CustomAccessory( component: AnyComponentWithIdentity( id: "info", component: AnyComponent( VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: clientData?.platform ?? "", font: Font.regular(presentationData.listsFontSize.itemListBaseFontSize), textColor: theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 ))), AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: clientData?.browser ?? "", font: Font.regular(presentationData.listsFontSize.itemListBaseFontSize / 17.0 * 15.0), textColor: theme.list.itemSecondaryTextColor )), horizontalAlignment: .left, truncationType: .middle, maximumNumberOfLines: 1 ))) ], alignment: .right, spacing: 3.0) ) ), insets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 14.0), isInteractive: true )), action: nil ) )) ) clientSectionItems.append( AnyComponentWithIdentity(id: "region", component: AnyComponent( ListActionItemComponent( theme: theme, style: .glass, title: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.AuthConfirmation_IpAddress, font: Font.regular(17.0), textColor: theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 )), contentInsets: UIEdgeInsets(top: 19.0, left: 0.0, bottom: 19.0, right: 0.0), accessory: .custom(ListActionItemComponent.CustomAccessory( component: AnyComponentWithIdentity( id: "info", component: AnyComponent( VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: clientData?.ip ?? "", font: Font.regular(presentationData.listsFontSize.itemListBaseFontSize), textColor: theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 ))), AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: clientData?.region ?? "", font: Font.regular(presentationData.listsFontSize.itemListBaseFontSize / 17.0 * 15.0), textColor: theme.list.itemSecondaryTextColor )), horizontalAlignment: .left, truncationType: .middle, maximumNumberOfLines: 1 ))) ], alignment: .right, spacing: 3.0) ) ), insets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 14.0), isInteractive: true )), action: nil ) )) ) let clientSection = clientSection.update( component: ListSectionComponent( theme: theme, style: .glass, header: nil, footer: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.AuthConfirmation_Info, font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), items: clientSectionItems ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), transition: context.transition ) context.add(clientSection .position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight + clientSection.size.height / 2.0)) .appear(.default(scale: false, alpha: true)) .disappear(.default(scale: false, alpha: true)) ) contentHeight += clientSection.size.height if flags.contains(.requestWriteAccess) { contentHeight += 38.0 var optionsSectionItems: [AnyComponentWithIdentity] = [] optionsSectionItems.append(AnyComponentWithIdentity(id: "allowWrite", component: AnyComponent(ListActionItemComponent( theme: theme, style: .glass, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.AuthConfirmation_AllowMessages, font: Font.regular(presentationData.listsFontSize.itemListBaseFontSize), textColor: theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: state.allowWrite, action: { [weak state] _ in guard let state else { return } state.allowWrite = !state.allowWrite state.updated() })), action: nil )))) let optionsSection = optionsSection.update( component: ListSectionComponent( theme: theme, style: .glass, header: nil, footer: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: strings.AuthConfirmation_AllowMessagesInfo(EnginePeer(bot).compactDisplayTitle).string, font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), items: optionsSectionItems ), availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), transition: context.transition ) context.add(optionsSection .position(CGPoint(x: context.availableSize.width / 2.0, y: contentHeight + optionsSection.size.height / 2.0)) .appear(.default(scale: false, alpha: true)) .disappear(.default(scale: false, alpha: true)) ) contentHeight += optionsSection.size.height } contentHeight += 32.0 } let buttonSpacing: CGFloat = 10.0 let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0) var buttonWidth = context.availableSize.width - buttonInsets.left - buttonInsets.right if !state.displayEmoji { buttonWidth = (buttonWidth - buttonSpacing) / 2.0 } let cancelButton = cancelButton.update( component: ButtonComponent( background: ButtonComponent.Background( style: .glass, color: theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.1), foreground: theme.list.itemPrimaryTextColor, pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9) ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(text: .plain(NSMutableAttributedString(string: strings.AuthConfirmation_Cancel, font: Font.semibold(17.0), textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: .center)))) ), action: { [weak state] in guard let state else { return } let accountContext = state.forcedAccount?.0 ?? component.context guard let accountPeer = state.forcedAccount?.1 ?? state.peer else { return } component.completion(accountContext, accountPeer, .decline) component.cancel(true) } ), availableSize: CGSize(width: buttonWidth, height: 52.0), transition: context.transition ) context.add(cancelButton .position(CGPoint(x: state.displayEmoji ? context.availableSize.width / 2.0 : context.availableSize.width / 2.0 - buttonSpacing / 2.0 - cancelButton.size.width / 2.0, y: contentHeight + cancelButton.size.height / 2.0)) ) let doneButton = doneButton.update( component: ButtonComponent( background: ButtonComponent.Background( style: .glass, color: theme.list.itemCheckColors.fillColor, foreground: theme.list.itemCheckColors.foregroundColor, pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), cornerRadius: 10.0, ), content: AnyComponentWithIdentity( id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(text: .plain(NSMutableAttributedString(string: strings.AuthConfirmation_LogIn, font: Font.semibold(17.0), textColor: theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center)))) ), displaysProgress: state.inProgress, action: { [weak state] in guard let state else { return } if !flags.contains(.showMatchCodesFirst), let matchCodes, !matchCodes.isEmpty { state.displayEmoji = true state.matchCodes = matchCodes.shuffled() state.updated(transition: .spring(duration: 0.4)) } else { complete(state.selectedMatchCode) } } ), availableSize: CGSize(width: buttonWidth, height: 52.0), transition: context.transition ) context.add(doneButton .position(CGPoint(x: context.availableSize.width / 2.0 + buttonSpacing / 2.0 + doneButton.size.width / 2.0, y: contentHeight + doneButton.size.height / 2.0)) .opacity(state.displayEmoji ? 0.0 : 1.0) .scale(state.displayEmoji ? 0.01 : 1.0) ) contentHeight += cancelButton.size.height contentHeight += buttonInsets.bottom return CGSize(width: context.availableSize.width, height: contentHeight) } } } private final class AuthConfirmationSheetComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let requestSubject: MessageActionUrlSubject let subject: MessageActionUrlAuthResult let completion: (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void init( context: AccountContext, requestSubject: MessageActionUrlSubject, subject: MessageActionUrlAuthResult, completion: @escaping (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void ) { self.context = context self.requestSubject = requestSubject self.subject = subject self.completion = completion } static func ==(lhs: AuthConfirmationSheetComponent, rhs: AuthConfirmationSheetComponent) -> Bool { if lhs.context !== rhs.context { return false } return true } static var body: Body { let sheet = Child(SheetComponent.self) let animateOut = StoredActionSlot(Action.self) return { context in let environment = context.environment[EnvironmentType.self] let controller = environment.controller let sheet = sheet.update( component: SheetComponent( content: AnyComponent(AuthConfirmationSheetContent( context: context.component.context, requestSubject: context.component.requestSubject, subject: context.component.subject, completion: context.component.completion, cancel: { animate in if animate { animateOut.invoke(Action { _ in if let controller = controller() { controller.dismiss(completion: nil) } }) } else if let controller = controller() { controller.dismiss(animated: false, completion: nil) } } )), style: .glass, backgroundColor: .color(environment.theme.list.modalBlocksBackgroundColor), followContentSizeChanges: true, clipsContent: true, animateOut: animateOut ), environment: { environment SheetComponentEnvironment( metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, 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)) ) return context.availableSize } } } public class AuthConfirmationScreen: ViewControllerComponentContainer { public enum Result { case accept(allowWriteAccess: Bool, sharePhoneNumber: Bool, matchCode: String?) case decline case failed } private let context: AccountContext private let requestSubject: MessageActionUrlSubject private let subject: MessageActionUrlAuthResult fileprivate let completion: (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void public init( context: AccountContext, requestSubject: MessageActionUrlSubject, subject: MessageActionUrlAuthResult, completion: @escaping (AccountContext, EnginePeer, AuthConfirmationScreen.Result) -> Void ) { self.context = context self.requestSubject = requestSubject self.subject = subject self.completion = completion super.init( context: context, component: AuthConfirmationSheetComponent( context: context, requestSubject: requestSubject, subject: subject, completion: completion ), navigationBarAppearance: .none, statusBarStyle: .ignore, theme: .default ) self.navigationPresentation = .flatModal } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } public override func viewDidLoad() { super.viewDidLoad() self.view.disablesInteractiveModalDismiss = true } public func dismissAnimated() { if let view = self.node.hostView.findTaggedView(tag: SheetComponent.View.Tag()) as? SheetComponent.View { view.dismissAnimated() } } } private final class AuthConfirmationReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController private let sourceView: UIView let forceDisplayBelowKeyboard = true init(controller: ViewController, sourceView: UIView) { self.controller = controller self.sourceView = sourceView } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) } } private final class ActivityIndicatorComponent: Component { let color: UIColor init( color: UIColor ) { self.color = color } static func ==(lhs: ActivityIndicatorComponent, rhs: ActivityIndicatorComponent) -> Bool { if lhs.color != rhs.color { return false } return true } final class View: UIView { private let background = UIView() private let activityIndicator: ActivityIndicator private var component: ActivityIndicatorComponent? override init(frame: CGRect) { self.activityIndicator = ActivityIndicator(type: .custom(.white, 64.0, 2.0, true)) super.init(frame: frame) self.addSubview(self.background) self.addSubview(self.activityIndicator.view) } required public init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ActivityIndicatorComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize { let size = CGSize(width: 64.0, height: 64.0) self.background.backgroundColor = component.color.withMultipliedAlpha(0.1) self.background.layer.cornerRadius = 32.0 self.background.clipsToBounds = true if self.component == nil { self.activityIndicator.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } self.component = component self.background.frame = CGRect(origin: .zero, size: size) self.activityIndicator.frame = CGRect(origin: .zero, size: size) self.activityIndicator.type = .custom(component.color, 64.0, 2.0, true) return size } } public func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } }