mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 14:17:19 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
540 lines
25 KiB
Swift
540 lines
25 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AccountContext
|
|
import WalletContext
|
|
import WalletConnectScreen
|
|
import SwiftSignalKit
|
|
import TelegramPresentationData
|
|
import PresentationDataUtils
|
|
import ComponentFlow
|
|
import ViewControllerComponent
|
|
import MultilineTextComponent
|
|
import ItemListUI
|
|
import BundleIconComponent
|
|
import ListSectionComponent
|
|
import ListActionItemComponent
|
|
import AlertComponent
|
|
import UndoUI
|
|
|
|
private func connectedAppSessions(_ sessions: [WalletContext.TonConnectSession]) -> [WalletContext.TonConnectSession] {
|
|
return sessions.filter { session in
|
|
guard session.manifest != nil else {
|
|
return false
|
|
}
|
|
return session.status == .connected || session.status == .disconnecting
|
|
}
|
|
}
|
|
|
|
private func presentDisconnectedOverlay(presentationData: PresentationData, controller: ViewController, text: String) {
|
|
controller.present(UndoOverlayController(
|
|
presentationData: presentationData,
|
|
content: .actionSucceeded(title: nil, text: text, cancel: nil, destructive: false),
|
|
position: .bottom,
|
|
action: { _ in false }
|
|
), in: .current)
|
|
}
|
|
|
|
private final class WalletAppsScreenComponent: Component {
|
|
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
|
|
|
let context: AccountContext
|
|
let updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)
|
|
let walletContext: WalletContext
|
|
|
|
init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>), walletContext: WalletContext) {
|
|
self.context = context
|
|
self.updatedPresentationData = updatedPresentationData
|
|
self.walletContext = walletContext
|
|
}
|
|
|
|
static func ==(lhs: WalletAppsScreenComponent, rhs: WalletAppsScreenComponent) -> Bool {
|
|
return lhs.context === rhs.context
|
|
&& lhs.updatedPresentationData.initial === rhs.updatedPresentationData.initial
|
|
&& lhs.updatedPresentationData.signal === rhs.updatedPresentationData.signal
|
|
&& lhs.walletContext === rhs.walletContext
|
|
}
|
|
|
|
final class View: UIView {
|
|
private let scrollView = UIScrollView()
|
|
private let section = ComponentView<Empty>()
|
|
private let sessionsDisposable = MetaDisposable()
|
|
private let operationDisposable = MetaDisposable()
|
|
private var component: WalletAppsScreenComponent?
|
|
private var environment: EnvironmentType?
|
|
private weak var state: EmptyComponentState?
|
|
private var sessions: [WalletContext.TonConnectSession]?
|
|
private var operationId: UUID?
|
|
private var operationSessionIds = Set<Int64>()
|
|
private var operationToast: String?
|
|
private var operationCompletion: ((Bool) -> Void)?
|
|
private weak var infoController: WalletAppInfoScreen?
|
|
private weak var allAppsAlert: AlertScreen?
|
|
private var allAppsProgress: ValuePromise<Bool>?
|
|
private var pendingToast: String?
|
|
private var isUpdating = false
|
|
private var reconciliationScheduled = false
|
|
private var isDismissingAlert = false
|
|
|
|
var isDisconnecting: Bool {
|
|
return self.operationId != nil
|
|
}
|
|
|
|
private func currentPresentationData(for component: WalletAppsScreenComponent) -> (initial: PresentationData, signal: Signal<PresentationData, NoError>) {
|
|
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
|
|
return (
|
|
initial: presentationData.withUpdated(theme: self.environment?.theme ?? component.updatedPresentationData.initial.theme),
|
|
signal: component.updatedPresentationData.signal
|
|
)
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
self.scrollView.showsVerticalScrollIndicator = true
|
|
self.scrollView.showsHorizontalScrollIndicator = false
|
|
self.scrollView.scrollsToTop = true
|
|
self.scrollView.delaysContentTouches = false
|
|
self.scrollView.canCancelContentTouches = true
|
|
self.scrollView.contentInsetAdjustmentBehavior = .never
|
|
self.scrollView.alwaysBounceVertical = true
|
|
if #available(iOS 13.0, *) {
|
|
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
|
}
|
|
self.addSubview(self.scrollView)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.sessionsDisposable.dispose()
|
|
self.operationDisposable.dispose()
|
|
}
|
|
|
|
func scrollToTop() {
|
|
self.scrollView.setContentOffset(.zero, animated: true)
|
|
}
|
|
|
|
func scheduleReconciliation() {
|
|
guard !self.reconciliationScheduled else {
|
|
return
|
|
}
|
|
self.reconciliationScheduled = true
|
|
Queue.mainQueue().async { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.reconciliationScheduled = false
|
|
self.reconcilePresentations()
|
|
}
|
|
}
|
|
|
|
private func reconcilePresentations() {
|
|
guard !self.isDisconnecting, !self.isDismissingAlert,
|
|
let component = self.component, let sessions = self.sessions,
|
|
let controller = self.environment?.controller() as? WalletAppsScreen,
|
|
controller.hasAppeared, !controller.isFinishing else {
|
|
return
|
|
}
|
|
let apps = connectedAppSessions(sessions)
|
|
if let infoController = self.infoController {
|
|
if !apps.contains(where: { $0.id == infoController.sessionId }) {
|
|
infoController.dismissAnimated()
|
|
}
|
|
return
|
|
}
|
|
if apps.isEmpty {
|
|
if self.allAppsAlert != nil {
|
|
self.dismissAllAppsAlert()
|
|
return
|
|
}
|
|
let toast = self.pendingToast
|
|
self.pendingToast = nil
|
|
controller.finish(toast: toast, presentationData: self.currentPresentationData(for: component).initial)
|
|
} else if self.allAppsAlert == nil, let toast = self.pendingToast {
|
|
self.pendingToast = nil
|
|
presentDisconnectedOverlay(presentationData: self.currentPresentationData(for: component).initial, controller: controller, text: toast)
|
|
}
|
|
}
|
|
|
|
private func openApp(_ session: WalletContext.TonConnectSession) {
|
|
guard !self.isDisconnecting, self.infoController == nil, self.allAppsAlert == nil,
|
|
let component = self.component, let manifest = session.manifest,
|
|
let controller = self.environment?.controller() as? WalletAppsScreen,
|
|
!controller.isFinishing,
|
|
connectedAppSessions(self.sessions ?? []).contains(where: { $0.id == session.id }) else {
|
|
return
|
|
}
|
|
let infoController = WalletAppInfoScreen(
|
|
context: component.context,
|
|
updatedPresentationData: self.currentPresentationData(for: component),
|
|
sessionId: session.id,
|
|
manifest: manifest,
|
|
disconnect: { [weak self] completion in
|
|
guard let self else {
|
|
completion(false)
|
|
return
|
|
}
|
|
self.disconnect(ids: [session.id], all: false, toast: self.currentPresentationData(for: component).initial.strings.Wallet_Apps_Disconnected(manifest.name).string, completion: completion)
|
|
},
|
|
closed: { [weak self] in
|
|
self?.infoController = nil
|
|
self?.scheduleReconciliation()
|
|
}
|
|
)
|
|
self.infoController = infoController
|
|
controller.push(infoController)
|
|
}
|
|
|
|
private func presentDisconnectAllAlert() {
|
|
guard !self.isDisconnecting, self.allAppsAlert == nil, self.infoController == nil,
|
|
connectedAppSessions(self.sessions ?? []).count > 1,
|
|
let component = self.component, let controller = self.environment?.controller() else {
|
|
return
|
|
}
|
|
let strings = self.currentPresentationData(for: component).initial.strings
|
|
let progress = ValuePromise<Bool>(false, ignoreRepeated: true)
|
|
let enabled = progress.get() |> map { !$0 }
|
|
let alert = AlertScreen(
|
|
configuration: AlertScreen.Configuration(actionAlignment: .vertical, dismissOnOutsideTap: false),
|
|
content: [
|
|
AnyComponentWithIdentity(id: "title", component: AnyComponent(AlertTitleComponent(title: strings.Wallet_Apps_DisconnectAllTitle))),
|
|
AnyComponentWithIdentity(id: "text", component: AnyComponent(AlertTextComponent(content: .plain(strings.Wallet_Apps_DisconnectAllText))))
|
|
],
|
|
actions: [
|
|
AlertScreen.Action(
|
|
title: strings.Wallet_Apps_DisconnectAllAction,
|
|
type: .destructive,
|
|
action: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let ids = Set(connectedAppSessions(self.sessions ?? []).map(\.id))
|
|
self.disconnect(ids: ids, all: true, toast: strings.Wallet_Apps_AllDisconnected) { [weak self] succeeded in
|
|
if succeeded {
|
|
self?.dismissAllAppsAlert()
|
|
}
|
|
}
|
|
},
|
|
autoDismiss: false,
|
|
isEnabled: enabled,
|
|
progress: progress.get()
|
|
),
|
|
AlertScreen.Action(title: strings.Common_Cancel, action: {}, isEnabled: enabled)
|
|
],
|
|
updatedPresentationData: self.currentPresentationData(for: component)
|
|
)
|
|
self.allAppsAlert = alert
|
|
self.allAppsProgress = progress
|
|
alert.dismissed = { [weak self, weak alert] _ in
|
|
guard let self, self.allAppsAlert === alert else {
|
|
return
|
|
}
|
|
self.allAppsAlert = nil
|
|
self.allAppsProgress = nil
|
|
self.isDismissingAlert = false
|
|
self.scheduleReconciliation()
|
|
}
|
|
controller.present(alert, in: .window(.root))
|
|
}
|
|
|
|
private func dismissAllAppsAlert() {
|
|
guard !self.isDismissingAlert, let alert = self.allAppsAlert else {
|
|
return
|
|
}
|
|
self.isDismissingAlert = true
|
|
alert.dismiss(completion: { [weak self, weak alert] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if self.allAppsAlert === alert {
|
|
self.allAppsAlert = nil
|
|
self.allAppsProgress = nil
|
|
}
|
|
self.isDismissingAlert = false
|
|
self.scheduleReconciliation()
|
|
})
|
|
}
|
|
|
|
private func disconnect(ids: Set<Int64>, all: Bool, toast: String, completion: @escaping (Bool) -> Void) {
|
|
guard !self.isDisconnecting, !ids.isEmpty, let component = self.component else {
|
|
completion(false)
|
|
return
|
|
}
|
|
let operationId = UUID()
|
|
self.operationId = operationId
|
|
self.operationSessionIds = ids
|
|
self.operationToast = toast
|
|
self.operationCompletion = completion
|
|
self.allAppsProgress?.set(true)
|
|
self.environment?.controller()?.view.disablesInteractiveModalDismiss = true
|
|
self.state?.updated(transition: .easeInOut(duration: 0.2))
|
|
|
|
let operation: Signal<Void, NoError>
|
|
if all {
|
|
operation = component.walletContext.disconnectAllTonConnectSessions()
|
|
} else if let id = ids.first {
|
|
operation = component.walletContext.disconnectTonConnectSession(id: id)
|
|
} else {
|
|
return
|
|
}
|
|
self.operationDisposable.set((operation
|
|
|> mapToSignal { component.walletContext.tonConnectState |> take(1) }
|
|
|> deliverOnMainQueue).start(next: { [weak self] tonConnectState in
|
|
guard let self, self.operationId == operationId, self.component?.walletContext === component.walletContext else {
|
|
return
|
|
}
|
|
self.sessions = tonConnectState.sessions
|
|
// NoError only means that the command returned. Check the actual session state.
|
|
if !self.completeAcceptedDisconnect(sessions: tonConnectState.sessions) {
|
|
self.completeDisconnect(succeeded: false)
|
|
self.presentDisconnectError(all: all)
|
|
}
|
|
}))
|
|
}
|
|
|
|
@discardableResult
|
|
private func completeAcceptedDisconnect(sessions: [WalletContext.TonConnectSession]) -> Bool {
|
|
guard self.operationId != nil else {
|
|
return false
|
|
}
|
|
let accepted = self.operationSessionIds.allSatisfy { id in
|
|
!sessions.contains(where: { $0.id == id })
|
|
}
|
|
if accepted {
|
|
self.completeDisconnect(succeeded: true)
|
|
}
|
|
return accepted
|
|
}
|
|
|
|
private func completeDisconnect(succeeded: Bool) {
|
|
let completion = self.operationCompletion
|
|
self.operationCompletion = nil
|
|
self.operationId = nil
|
|
self.operationSessionIds.removeAll()
|
|
self.allAppsProgress?.set(false)
|
|
self.environment?.controller()?.view.disablesInteractiveModalDismiss = false
|
|
if succeeded {
|
|
self.pendingToast = self.operationToast
|
|
}
|
|
self.operationToast = nil
|
|
self.state?.updated(transition: .easeInOut(duration: 0.25))
|
|
completion?(succeeded)
|
|
self.scheduleReconciliation()
|
|
}
|
|
|
|
private func presentDisconnectError(all: Bool) {
|
|
guard let component = self.component, let controller = self.environment?.controller() else {
|
|
return
|
|
}
|
|
let strings = self.currentPresentationData(for: component).initial.strings
|
|
controller.present(AlertScreen(
|
|
content: [AnyComponentWithIdentity(id: "text", component: AnyComponent(AlertTextComponent(content: .plain(all ? strings.Wallet_Apps_DisconnectAllError : strings.Wallet_Apps_DisconnectError))))],
|
|
actions: [AlertScreen.Action(title: strings.Common_OK, action: {})],
|
|
updatedPresentationData: self.currentPresentationData(for: component)
|
|
), in: .window(.root))
|
|
}
|
|
|
|
func update(component: WalletAppsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
|
self.isUpdating = true
|
|
defer { self.isUpdating = false }
|
|
let environment = environment[EnvironmentType.self].value
|
|
let previousContext = self.component?.walletContext
|
|
self.component = component
|
|
self.environment = environment
|
|
self.state = state
|
|
if previousContext !== component.walletContext {
|
|
self.operationDisposable.set(nil)
|
|
self.operationId = nil
|
|
self.operationSessionIds.removeAll()
|
|
self.operationToast = nil
|
|
environment.controller()?.view.disablesInteractiveModalDismiss = false
|
|
let completion = self.operationCompletion
|
|
self.operationCompletion = nil
|
|
completion?(false)
|
|
self.infoController?.dismissAnimated()
|
|
self.allAppsProgress?.set(false)
|
|
self.dismissAllAppsAlert()
|
|
self.pendingToast = nil
|
|
self.sessions = nil
|
|
let walletContext = component.walletContext
|
|
walletContext.refreshTonConnectSessions()
|
|
self.sessionsDisposable.set((walletContext.tonConnectState
|
|
|> deliverOnMainQueue).start(next: { [weak self] tonConnectState in
|
|
guard let self, self.component?.walletContext === walletContext else {
|
|
return
|
|
}
|
|
if self.sessions != tonConnectState.sessions {
|
|
self.sessions = tonConnectState.sessions
|
|
if !self.isUpdating {
|
|
self.state?.updated(transition: .easeInOut(duration: 0.25))
|
|
}
|
|
self.scheduleReconciliation()
|
|
}
|
|
self.completeAcceptedDisconnect(sessions: tonConnectState.sessions)
|
|
}))
|
|
}
|
|
|
|
let theme = environment.theme
|
|
self.backgroundColor = theme.list.blocksBackgroundColor
|
|
let apps = connectedAppSessions(self.sessions ?? [])
|
|
let sideInset = 16.0 + max(environment.safeInsets.left, environment.safeInsets.right)
|
|
var items: [AnyComponentWithIdentity<Empty>] = []
|
|
if apps.count > 1 {
|
|
items.append(AnyComponentWithIdentity(id: "disconnectAll", component: AnyComponent(ListActionItemComponent(
|
|
theme: theme,
|
|
style: .glass,
|
|
title: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: environment.strings.Wallet_Apps_DisconnectAll, font: Font.regular(17.0), textColor: theme.list.itemDestructiveColor)),
|
|
maximumNumberOfLines: 0
|
|
)),
|
|
leftIcon: .custom(AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent(name: "Item List/Block", tintColor: theme.list.itemDestructiveColor))), false),
|
|
action: { [weak self] _ in self?.presentDisconnectAllAlert() }
|
|
))))
|
|
}
|
|
for session in apps {
|
|
guard let manifest = session.manifest else {
|
|
continue
|
|
}
|
|
items.append(AnyComponentWithIdentity(id: session.id, component: AnyComponent(ListActionItemComponent(
|
|
theme: theme,
|
|
style: .glass,
|
|
title: AnyComponent(VStack<Empty>([
|
|
AnyComponentWithIdentity(id: "name", component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: manifest.name, font: Font.semibold(17.0), textColor: theme.list.itemPrimaryTextColor)),
|
|
maximumNumberOfLines: 1
|
|
))),
|
|
AnyComponentWithIdentity(id: "domain", component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: manifest.domain, font: Font.regular(15.0), textColor: theme.list.itemSecondaryTextColor)),
|
|
maximumNumberOfLines: 1
|
|
)))
|
|
], alignment: .left, spacing: 2.0)),
|
|
contentInsets: UIEdgeInsets(top: 10.0, left: 0.0, bottom: 10.0, right: 0.0),
|
|
leftIcon: .custom(AnyComponentWithIdentity(id: "icon", component: AnyComponent(WalletConnectAppIconComponent(
|
|
context: component.context,
|
|
applicationName: manifest.name,
|
|
icon: manifest.icon,
|
|
size: 30.0,
|
|
cornerRadius: 9.0
|
|
))), false),
|
|
accessory: .arrow,
|
|
action: { [weak self] _ in self?.openApp(session) }
|
|
))))
|
|
}
|
|
|
|
self.section.parentState = state
|
|
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
|
|
let headerFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize)
|
|
let sectionSize = self.section.update(
|
|
transition: transition,
|
|
component: AnyComponent(ListSectionComponent(
|
|
theme: theme,
|
|
style: .glass,
|
|
header: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: environment.strings.Wallet_Apps_ActiveConnections.uppercased(), font: headerFont, textColor: theme.list.freeTextColor)),
|
|
maximumNumberOfLines: 0
|
|
)),
|
|
footer: AnyComponent(MultilineTextComponent(
|
|
text: .plain(NSAttributedString(string: environment.strings.Wallet_Apps_AllPermissions, font: Font.regular(13.0), textColor: theme.list.freeTextColor)),
|
|
maximumNumberOfLines: 0
|
|
)),
|
|
items: items,
|
|
isModal: true
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: max(1.0, availableSize.width - sideInset * 2.0), height: 10000.0)
|
|
)
|
|
let contentY = environment.navigationHeight + 32.0
|
|
if let sectionView = self.section.view {
|
|
if sectionView.superview == nil {
|
|
self.scrollView.addSubview(sectionView)
|
|
}
|
|
transition.setFrame(view: sectionView, frame: CGRect(origin: CGPoint(x: sideInset, y: contentY), size: sectionSize))
|
|
}
|
|
transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: availableSize))
|
|
let contentSize = CGSize(width: availableSize.width, height: max(availableSize.height + 1.0, contentY + sectionSize.height + 24.0 + environment.safeInsets.bottom))
|
|
if self.scrollView.contentSize != contentSize {
|
|
self.scrollView.contentSize = contentSize
|
|
}
|
|
self.scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: environment.safeInsets.bottom, right: 0.0)
|
|
return availableSize
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: .zero)
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
public final class WalletAppsScreen: ViewControllerComponentContainer {
|
|
fileprivate var hasAppeared = false
|
|
fileprivate var isFinishing = false
|
|
|
|
public init(context: AccountContext, walletContext: WalletContext) {
|
|
let updatedPresentationData = presentationDataWithDefaultAccent((
|
|
initial: context.sharedContext.currentPresentationData.with { $0 },
|
|
signal: context.sharedContext.presentationData
|
|
))
|
|
super.init(
|
|
context: context,
|
|
component: WalletAppsScreenComponent(context: context, updatedPresentationData: updatedPresentationData, walletContext: walletContext),
|
|
navigationBarAppearance: .default,
|
|
theme: .default,
|
|
updatedPresentationData: updatedPresentationData
|
|
)
|
|
self.title = updatedPresentationData.initial.strings.Wallet_Apps_Title
|
|
self.attemptNavigation = { [weak self] _ in
|
|
guard let self else {
|
|
return true
|
|
}
|
|
return (self.node.hostView.componentView as? WalletAppsScreenComponent.View)?.isDisconnecting != true
|
|
}
|
|
self.scrollToTop = { [weak self] in
|
|
(self?.node.hostView.componentView as? WalletAppsScreenComponent.View)?.scrollToTop()
|
|
}
|
|
}
|
|
|
|
required public init(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override public func preferredContentSizeForLayout(_ layout: ContainerViewLayout) -> CGSize? {
|
|
guard layout.metrics.widthClass == .regular else {
|
|
return nil
|
|
}
|
|
return CGSize(
|
|
width: min(480.0, layout.size.width - 20.0),
|
|
height: min(layout.size.width, layout.size.height) - 88.0
|
|
)
|
|
}
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
self.hasAppeared = true
|
|
(self.node.hostView.componentView as? WalletAppsScreenComponent.View)?.scheduleReconciliation()
|
|
}
|
|
|
|
fileprivate func finish(toast: String?, presentationData: PresentationData) {
|
|
guard !self.isFinishing else {
|
|
return
|
|
}
|
|
self.isFinishing = true
|
|
if let navigationController = self.navigationController as? NavigationController,
|
|
let index = navigationController.viewControllers.firstIndex(where: { $0 === self }) {
|
|
let previousController = navigationController.viewControllers.prefix(upTo: index).last as? ViewController
|
|
navigationController.setViewControllers(navigationController.viewControllers.filter { $0 !== self }, animated: true, completion: { [weak previousController] in
|
|
if let toast, let previousController {
|
|
presentDisconnectedOverlay(presentationData: presentationData, controller: previousController, text: toast)
|
|
}
|
|
})
|
|
} else {
|
|
self.dismiss()
|
|
}
|
|
}
|
|
}
|