From 3eb4c041f901c953f8fcc31c021b97b40af6f146 Mon Sep 17 00:00:00 2001 From: matiasbzurovski <164921079+matiasbzurovski@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:21:09 +0100 Subject: [PATCH] [ABW-3830] Poll PreAuthorization status (#1397) --- RadixWallet.xcodeproj/project.pbxproj | 48 +++-- .../PreAuthorizationClient+Interface.swift | 7 + .../PreAuthorizationClient+Live.swift | 7 +- .../PreAuthorizationClient+Test.swift | 6 +- .../AddressView/AddressView.swift | 22 ++- .../SharedModels/LedgerIdentifiable.swift | 5 + .../Children/Completion/Completion+View.swift | 128 ------------- .../Children/Completion/Completion.swift | 36 ---- .../DappInteractionCompletion+View.swift | 75 ++++++++ .../DappInteractionCompletion.swift | 39 ++++ .../DappInteractionCoordinator.swift | 12 +- .../Coordinator/DappInteractionFlow.swift | 26 +-- .../Coordinator/DappInteractionModels.swift | 11 ++ .../DappInteractor+ViewModifier.swift | 23 ++- .../Interactor/DappInteractor.swift | 172 ++++++++++++------ .../InteractionInProgressView.swift | 23 +++ .../PollPreAuthorizationStatus+View.swift | 149 +++++++++++++++ .../PollPreAuthorizationStatus.swift | 115 ++++++++++++ .../PreAuthorizationReview+View.swift | 24 +-- .../PreAuthorizationReview.swift | 28 ++- .../TimeFormatter.swift | 0 .../SubmitTransaction+View.swift | 30 +-- 22 files changed, 677 insertions(+), 309 deletions(-) delete mode 100644 RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion+View.swift delete mode 100644 RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion.swift create mode 100644 RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion+View.swift create mode 100644 RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion.swift create mode 100644 RadixWallet/Features/InteractionReview/Components/InteractionInProgressView.swift create mode 100644 RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus+View.swift create mode 100644 RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus.swift rename RadixWallet/Features/PreAuthorizationReview/{TImeFormatter => TimeFormatter}/TimeFormatter.swift (100%) diff --git a/RadixWallet.xcodeproj/project.pbxproj b/RadixWallet.xcodeproj/project.pbxproj index 8092be18f5..c8a362d728 100644 --- a/RadixWallet.xcodeproj/project.pbxproj +++ b/RadixWallet.xcodeproj/project.pbxproj @@ -264,8 +264,8 @@ 48CFC3422ADC10D900E77A5C /* AccountPermissionChooseAccounts+Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE002ADC10D800E77A5C /* AccountPermissionChooseAccounts+Reducer.swift */; }; 48CFC3432ADC10D900E77A5C /* AccountPermission+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE022ADC10D800E77A5C /* AccountPermission+View.swift */; }; 48CFC3442ADC10D900E77A5C /* AccountPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE032ADC10D800E77A5C /* AccountPermission.swift */; }; - 48CFC3452ADC10D900E77A5C /* Completion+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE052ADC10D800E77A5C /* Completion+View.swift */; }; - 48CFC3462ADC10D900E77A5C /* Completion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE062ADC10D800E77A5C /* Completion.swift */; }; + 48CFC3452ADC10D900E77A5C /* DappInteractionCompletion+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE052ADC10D800E77A5C /* DappInteractionCompletion+View.swift */; }; + 48CFC3462ADC10D900E77A5C /* DappInteractionCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE062ADC10D800E77A5C /* DappInteractionCompletion.swift */; }; 48CFC3472ADC10D900E77A5C /* PersonaDataPermissionBox+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE082ADC10D800E77A5C /* PersonaDataPermissionBox+View.swift */; }; 48CFC3482ADC10D900E77A5C /* PersonaDataPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE092ADC10D800E77A5C /* PersonaDataPermission.swift */; }; 48CFC3492ADC10D900E77A5C /* PersonaDataPermissionBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48CFBE0A2ADC10D800E77A5C /* PersonaDataPermissionBox.swift */; }; @@ -737,6 +737,9 @@ 5B3C48B92C80D23F00DB160D /* AccountLockersClient+Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3C48B82C80D23F00DB160D /* AccountLockersClient+Test.swift */; }; 5B3C48C12C85CEAA00DB160D /* AccountBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3C48C02C85CEAA00DB160D /* AccountBannerView.swift */; }; 5B3C48C32C874C8D00DB160D /* Dispatch+Extra.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3C48C22C874C8D00DB160D /* Dispatch+Extra.swift */; }; + 5B3FDC0E2CF0A4D40024BFAF /* PollPreAuthorizationStatus+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3FDC0D2CF0A4D40024BFAF /* PollPreAuthorizationStatus+View.swift */; }; + 5B3FDC0F2CF0A4D40024BFAF /* PollPreAuthorizationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3FDC0C2CF0A4D40024BFAF /* PollPreAuthorizationStatus.swift */; }; + 5B3FDC112CF0B9DB0024BFAF /* InteractionInProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3FDC102CF0B9C60024BFAF /* InteractionInProgressView.swift */; }; 5B43B08B2BDAAD4B00AA1E92 /* AddressDetails+View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B43B0892BDAAD4B00AA1E92 /* AddressDetails+View.swift */; }; 5B43B08C2BDAAD4B00AA1E92 /* AddressDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B43B08A2BDAAD4B00AA1E92 /* AddressDetails.swift */; }; 5B447E0B2CAAFC2D0063AE39 /* Sargon in Frameworks */ = {isa = PBXBuildFile; productRef = 5B447E0A2CAAFC2D0063AE39 /* Sargon */; }; @@ -1543,8 +1546,8 @@ 48CFBE002ADC10D800E77A5C /* AccountPermissionChooseAccounts+Reducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AccountPermissionChooseAccounts+Reducer.swift"; sourceTree = ""; }; 48CFBE022ADC10D800E77A5C /* AccountPermission+View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AccountPermission+View.swift"; sourceTree = ""; }; 48CFBE032ADC10D800E77A5C /* AccountPermission.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountPermission.swift; sourceTree = ""; }; - 48CFBE052ADC10D800E77A5C /* Completion+View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Completion+View.swift"; sourceTree = ""; }; - 48CFBE062ADC10D800E77A5C /* Completion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Completion.swift; sourceTree = ""; }; + 48CFBE052ADC10D800E77A5C /* DappInteractionCompletion+View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DappInteractionCompletion+View.swift"; sourceTree = ""; }; + 48CFBE062ADC10D800E77A5C /* DappInteractionCompletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DappInteractionCompletion.swift; sourceTree = ""; }; 48CFBE082ADC10D800E77A5C /* PersonaDataPermissionBox+View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PersonaDataPermissionBox+View.swift"; sourceTree = ""; }; 48CFBE092ADC10D800E77A5C /* PersonaDataPermission.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonaDataPermission.swift; sourceTree = ""; }; 48CFBE0A2ADC10D800E77A5C /* PersonaDataPermissionBox.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonaDataPermissionBox.swift; sourceTree = ""; }; @@ -1985,6 +1988,9 @@ 5B3C48B82C80D23F00DB160D /* AccountLockersClient+Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountLockersClient+Test.swift"; sourceTree = ""; }; 5B3C48C02C85CEAA00DB160D /* AccountBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBannerView.swift; sourceTree = ""; }; 5B3C48C22C874C8D00DB160D /* Dispatch+Extra.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dispatch+Extra.swift"; sourceTree = ""; }; + 5B3FDC0C2CF0A4D40024BFAF /* PollPreAuthorizationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollPreAuthorizationStatus.swift; sourceTree = ""; }; + 5B3FDC0D2CF0A4D40024BFAF /* PollPreAuthorizationStatus+View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollPreAuthorizationStatus+View.swift"; sourceTree = ""; }; + 5B3FDC102CF0B9C60024BFAF /* InteractionInProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionInProgressView.swift; sourceTree = ""; }; 5B43B0892BDAAD4B00AA1E92 /* AddressDetails+View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddressDetails+View.swift"; sourceTree = ""; }; 5B43B08A2BDAAD4B00AA1E92 /* AddressDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressDetails.swift; sourceTree = ""; }; 5B45E2F82BC45770007C4C84 /* FactorSourceAccess+View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FactorSourceAccess+View.swift"; sourceTree = ""; }; @@ -3858,7 +3864,7 @@ 831F0CF02C2576AE00D6F5BF /* DappOriginVerification */, 48CFBDFE2ADC10D800E77A5C /* AccountPermissionChooseAccounts */, 48CFBE012ADC10D800E77A5C /* AccountPermission */, - 48CFBE042ADC10D800E77A5C /* Completion */, + 48CFBE042ADC10D800E77A5C /* DappInteractionCompletion */, 48CFBE072ADC10D800E77A5C /* PersonaDataPermission */, 48CFBE0C2ADC10D800E77A5C /* OneTimePersonaData */, 48CFBE0F2ADC10D800E77A5C /* Login */, @@ -3885,13 +3891,13 @@ path = AccountPermission; sourceTree = ""; }; - 48CFBE042ADC10D800E77A5C /* Completion */ = { + 48CFBE042ADC10D800E77A5C /* DappInteractionCompletion */ = { isa = PBXGroup; children = ( - 48CFBE052ADC10D800E77A5C /* Completion+View.swift */, - 48CFBE062ADC10D800E77A5C /* Completion.swift */, + 48CFBE052ADC10D800E77A5C /* DappInteractionCompletion+View.swift */, + 48CFBE062ADC10D800E77A5C /* DappInteractionCompletion.swift */, ); - path = Completion; + path = DappInteractionCompletion; sourceTree = ""; }; 48CFBE072ADC10D800E77A5C /* PersonaDataPermission */ = { @@ -5553,6 +5559,7 @@ 5B03E3D02CC127B100E10A64 /* RawManifestView.swift */, 5B4A1AC52CC0150D00679EE6 /* HeaderView.swift */, 5B27FBE92CC70C9D002975BE /* ValidatorsView.swift */, + 5B3FDC102CF0B9C60024BFAF /* InteractionInProgressView.swift */, ); path = Components; sourceTree = ""; @@ -5612,6 +5619,15 @@ path = AccountLockersClient; sourceTree = ""; }; + 5B3FDC0B2CF0A4890024BFAF /* PollPreAuthorizationStatus */ = { + isa = PBXGroup; + children = ( + 5B3FDC0C2CF0A4D40024BFAF /* PollPreAuthorizationStatus.swift */, + 5B3FDC0D2CF0A4D40024BFAF /* PollPreAuthorizationStatus+View.swift */, + ); + path = PollPreAuthorizationStatus; + sourceTree = ""; + }; 5B45E2F32BC45706007C4C84 /* FactorSourceAccess */ = { isa = PBXGroup; children = ( @@ -5646,7 +5662,8 @@ children = ( 5B4A1ABD2CC00FB800679EE6 /* PreAuthorizationReview.swift */, 5B4A1ABE2CC00FB800679EE6 /* PreAuthorizationReview+View.swift */, - 5B8F770E2CDA413000154A76 /* TImeFormatter */, + 5B3FDC0B2CF0A4890024BFAF /* PollPreAuthorizationStatus */, + 5B8F770E2CDA413000154A76 /* TimeFormatter */, ); path = PreAuthorizationReview; sourceTree = ""; @@ -5754,12 +5771,12 @@ path = Models; sourceTree = ""; }; - 5B8F770E2CDA413000154A76 /* TImeFormatter */ = { + 5B8F770E2CDA413000154A76 /* TimeFormatter */ = { isa = PBXGroup; children = ( 5B8F770F2CDA41B100154A76 /* TimeFormatter.swift */, ); - path = TImeFormatter; + path = TimeFormatter; sourceTree = ""; }; 5B8F77112CDA41DC00154A76 /* PreAuthorizationReviewTests */ = { @@ -7319,6 +7336,8 @@ 48CFC3332ADC10D900E77A5C /* ImportMnemonic+View.swift in Sources */, 5B758D532BCD721D00348722 /* SettingsRow.swift in Sources */, 48CFC3842ADC10D900E77A5C /* AssetResourceDetails.swift in Sources */, + 5B3FDC0E2CF0A4D40024BFAF /* PollPreAuthorizationStatus+View.swift in Sources */, + 5B3FDC0F2CF0A4D40024BFAF /* PollPreAuthorizationStatus.swift in Sources */, 48CFC6932ADC10DB00E77A5C /* ImportLegacyWalletErrors.swift in Sources */, 48CFC61E2ADC10DA00E77A5C /* LedgerIdentifiable.swift in Sources */, A462B57B2B8210A900C26D20 /* TransactionHistoryClient+Mock.swift in Sources */, @@ -7375,7 +7394,7 @@ 5B27FBE02CC6CCBE002975BE /* Sections+Data.swift in Sources */, 83D663B02B271D0100D1AB9E /* TruncationMask.swift in Sources */, E79DD1B62CE4933100B1EB86 /* DeleteAccountConfirmation+Reducer.swift in Sources */, - 48CFC3462ADC10D900E77A5C /* Completion.swift in Sources */, + 48CFC3462ADC10D900E77A5C /* DappInteractionCompletion.swift in Sources */, A40816002C7E0D08005E65B9 /* StateAccountLockersTouchedAtResponse.swift in Sources */, 48CFC25A2ADC10D900E77A5C /* OnboardingCoordinator+Reducer.swift in Sources */, 5BB7C1782BC81F61001216EB /* ImportOlympiaNameLedger+View.swift in Sources */, @@ -7553,6 +7572,7 @@ 4813AFE22BC9A9AD0046BCAD /* Stage1MigrateToSargon+NetworkDefinition.swift in Sources */, A40816522C7E0D08005E65B9 /* TransactionSubmitRequest.swift in Sources */, A40816012C7E0D08005E65B9 /* StateAccountLockersTouchedAtResponseItem.swift in Sources */, + 5B3FDC112CF0B9DB0024BFAF /* InteractionInProgressView.swift in Sources */, 48D5F38B2BD8DDB9000DE964 /* DebugKeychainContents+View.swift in Sources */, A40816232C7E0D08005E65B9 /* StateKeyValueStoreDataResponse.swift in Sources */, E75C5F342C1C1632002E3DFF /* Stage1MigrateToSargon+SignedAuthChallenge.swift in Sources */, @@ -8085,7 +8105,7 @@ A40815DC2C7E0D08005E65B9 /* ProgrammaticScryptoSborValueMapEntry.swift in Sources */, A40816112C7E0D08005E65B9 /* StateEntityFungibleResourceVaultsPageResponse.swift in Sources */, 48CFC2CA2ADC10D900E77A5C /* NonFungibleResourceAsset+View.swift in Sources */, - 48CFC3452ADC10D900E77A5C /* Completion+View.swift in Sources */, + 48CFC3452ADC10D900E77A5C /* DappInteractionCompletion+View.swift in Sources */, 5B43B08B2BDAAD4B00AA1E92 /* AddressDetails+View.swift in Sources */, 5BE2776B2C9C7FE0005FF976 /* TransactionPreviewOptIns.swift in Sources */, 5B43B08C2BDAAD4B00AA1E92 /* AddressDetails.swift in Sources */, diff --git a/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Interface.swift b/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Interface.swift index 922b78c78c..755cb8f8fb 100644 --- a/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Interface.swift +++ b/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Interface.swift @@ -2,12 +2,14 @@ struct PreAuthorizationClient: Sendable { var getPreview: GetPreview var buildSubintent: BuildSubintent + var pollStatus: PollStatus } // MARK: PreAuthorizationClient.GetPreview extension PreAuthorizationClient { typealias GetPreview = @Sendable (GetPreviewRequest) async throws -> PreAuthorizationPreview typealias BuildSubintent = @Sendable (BuildSubintentRequest) async throws -> Subintent + typealias PollStatus = @Sendable (PollStatusRequest) async throws -> PreAuthorizationStatus } // MARK: PreAuthorizationClient.GetPreviewRequest @@ -24,4 +26,9 @@ extension PreAuthorizationClient { let expiration: DappToWalletInteractionSubintentExpiration let message: String? } + + struct PollStatusRequest: Sendable { + let subintentHash: SubintentHash + let expiration: DappToWalletInteractionSubintentExpiration + } } diff --git a/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Live.swift b/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Live.swift index faad34fa6b..11a26440ea 100644 --- a/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Live.swift +++ b/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Live.swift @@ -68,9 +68,14 @@ extension PreAuthorizationClient: DependencyKey { ) } + let pollStatus: PollStatus = { request in + try await SargonOS.shared.pollPreAuthorizationStatus(intentHash: request.subintentHash, expiration: request.expiration) + } + return Self( getPreview: getPreview, - buildSubintent: buildSubintent + buildSubintent: buildSubintent, + pollStatus: pollStatus ) } } diff --git a/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Test.swift b/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Test.swift index 3d05f88ed9..167bff61ff 100644 --- a/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Test.swift +++ b/RadixWallet/Clients/PreAuthorizationClient/PreAuthorizationClient+Test.swift @@ -11,11 +11,13 @@ extension PreAuthorizationClient: TestDependencyKey { static let noop = Self( getPreview: { _ in throw NoopError() }, - buildSubintent: { _ in throw NoopError() } + buildSubintent: { _ in throw NoopError() }, + pollStatus: { _ in throw NoopError() } ) static let testValue = Self( getPreview: unimplemented("\(Self.self).getPreview"), - buildSubintent: unimplemented("\(Self.self).buildSubintent") + buildSubintent: unimplemented("\(Self.self).buildSubintent"), + pollStatus: unimplemented("\(Self.self).pollStatus") ) } diff --git a/RadixWallet/Core/FeaturePrelude/AddressView/AddressView.swift b/RadixWallet/Core/FeaturePrelude/AddressView/AddressView.swift index e753cff4f1..546717e871 100644 --- a/RadixWallet/Core/FeaturePrelude/AddressView/AddressView.swift +++ b/RadixWallet/Core/FeaturePrelude/AddressView/AddressView.swift @@ -56,6 +56,7 @@ extension AddressView { AddressDetails.View(store: store) } } + case .transaction: Menu { Button(L10n.AddressAction.copyTransactionId, image: .copyBig) { @@ -68,12 +69,20 @@ extension AddressView { } label: { addressView } + + case .preAuthorization: + addressView + .onTapGesture(perform: copyToPasteboard) } } } private var addressView: some View { HStack(spacing: .small3) { + Text(prefix) + .textStyle(.body1Header) + .foregroundStyle(.app.gray1) + Text(formattedText) .lineLimit(1) @@ -88,9 +97,20 @@ extension AddressView { } } - private var imageResource: ImageResource { + private var prefix: String? { switch identifiable { case .address: + nil + case .transaction: + L10n.TransactionReview.SubmitTransaction.txID + case .preAuthorization: + L10n.PreAuthorizationReview.UnknownStatus.identifier + } + } + + private var imageResource: ImageResource { + switch identifiable { + case .address, .preAuthorization: .copy case .transaction: .iconLinkOut diff --git a/RadixWallet/Core/SharedModels/LedgerIdentifiable.swift b/RadixWallet/Core/SharedModels/LedgerIdentifiable.swift index 0f8b85cd8f..7dbd13d7ee 100644 --- a/RadixWallet/Core/SharedModels/LedgerIdentifiable.swift +++ b/RadixWallet/Core/SharedModels/LedgerIdentifiable.swift @@ -2,6 +2,7 @@ enum LedgerIdentifiable: Sendable { case address(Address) case transaction(TransactionIntentHash) + case preAuthorization(SubintentHash) static func address(of account: Account) -> Self { .address(.account(account.address)) @@ -17,6 +18,8 @@ enum LedgerIdentifiable: Sendable { address.formatted(format) case let .transaction(identifier): identifier.formatted(format) + case let .preAuthorization(identifier): + identifier.formatted(format) } } @@ -26,6 +29,8 @@ enum LedgerIdentifiable: Sendable { address.addressPrefix case .transaction: "transaction" + case .preAuthorization: + "" // Subintent cannot be tracked on dashboard } } } diff --git a/RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion+View.swift b/RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion+View.swift deleted file mode 100644 index 01b7f6b08b..0000000000 --- a/RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion+View.swift +++ /dev/null @@ -1,128 +0,0 @@ -import ComposableArchitecture -import SwiftUI - -extension DappMetadata { - var name: String { - switch self { - case let .ledger(ledger): - ledger.name?.rawValue ?? L10n.DAppRequest.Metadata.unknownName - case .request: - L10n.DAppRequest.Metadata.unknownName - case .wallet: - L10n.DAppRequest.Metadata.wallet - } - } -} - -// MARK: - Completion.View -extension Completion { - struct ViewState: Equatable { - /// `nil` is a valid value for Persona Data requests - let txID: TransactionIntentHash? - let title: String - let subtitle: String - let showSwitchBackToBrowserMessage: Bool - - init(state: Completion.State) { - self.txID = state.txID - self.title = L10n.DAppRequest.Completion.title - self.subtitle = L10n.DAppRequest.Completion.subtitle(state.dappMetadata.name) - self.showSwitchBackToBrowserMessage = state.p2pRoute.isDeepLink - } - } - - @MainActor - struct View: SwiftUI.View { - let store: StoreOf - - @ScaledMetric private var height: CGFloat = 360 - - var body: some SwiftUI.View { - WithViewStore(store, observe: ViewState.init) { viewStore in - WithNavigationBar { - store.send(.view(.dismissTapped)) - } content: { - VStack(spacing: .zero) { - Spacer() - - Image(asset: AssetResource.successCheckmark) - - Text(viewStore.title) - .foregroundColor(.app.gray1) - .textStyle(.sheetTitle) - .padding([.top, .horizontal], .medium3) - - Text(viewStore.subtitle) - .foregroundColor(.app.gray1) - .textStyle(.body1Regular) - .multilineTextAlignment(.center) - .padding([.top, .horizontal], .medium3) - - if let txID = viewStore.txID { - HStack { - Text(L10n.TransactionReview.SubmitTransaction.txID) - .foregroundColor(.app.gray1) - AddressView(.transaction(txID), imageColor: .app.gray2) - .foregroundColor(.app.blue1) - } - .textStyle(.body1Header) - .padding(.top, .small2) - } - - Spacer() - - if viewStore.showSwitchBackToBrowserMessage { - Text(L10n.MobileConnect.interactionSuccess) - .foregroundColor(.app.gray1) - .textStyle(.body1Regular) - .multilineTextAlignment(.center) - .padding(.vertical, .medium1) - .frame(maxWidth: .infinity) - .background(.app.gray5) - } - } - .frame(maxWidth: .infinity) - } - } - .presentationDragIndicator(.visible) - .presentationDetents([.height(height)]) - .presentationBackground(.blur) - } - } -} - -#if DEBUG -import struct SwiftUINavigation.WithState - -// MARK: - Completion_Preview -struct Completion_Preview: PreviewProvider { - static var previews: some SwiftUI.View { - WithState(initialValue: false) { $isPresented in - ZStack { - Color.red - Button("Present") { isPresented = true } - } - .sheet(isPresented: $isPresented) { - Completion.View( - store: .init( - initialState: .previewValue, - reducer: Completion.init - ) - ) - } - .task { - try? await Task.sleep(for: .seconds(2)) - isPresented = true - } - } - } -} - -extension Completion.State { - static let previewValue: Self = .init( - txID: nil, - dappMetadata: .previewValue, - p2pRoute: .wallet - ) -} -#endif diff --git a/RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion.swift b/RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion.swift deleted file mode 100644 index d0ab7ae264..0000000000 --- a/RadixWallet/Features/DappInteractionFeature/Children/Completion/Completion.swift +++ /dev/null @@ -1,36 +0,0 @@ -import ComposableArchitecture -import SwiftUI - -// MARK: - Completion -struct Completion: Sendable, FeatureReducer { - struct State: Sendable, Hashable { - let txID: TransactionIntentHash? - let dappMetadata: DappMetadata - let p2pRoute: P2P.Route - - init( - txID: TransactionIntentHash?, - dappMetadata: DappMetadata, - p2pRoute: P2P.Route - ) { - self.txID = txID - self.dappMetadata = dappMetadata - self.p2pRoute = p2pRoute - } - } - - enum ViewAction: Sendable, Equatable { - case dismissTapped - } - - enum DelegateAction: Sendable, Equatable { - case dismiss - } - - func reduce(into state: inout State, viewAction: ViewAction) -> Effect { - switch viewAction { - case .dismissTapped: - .send(.delegate(.dismiss)) - } - } -} diff --git a/RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion+View.swift b/RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion+View.swift new file mode 100644 index 0000000000..48895e0001 --- /dev/null +++ b/RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion+View.swift @@ -0,0 +1,75 @@ +import ComposableArchitecture +import SwiftUI + +// MARK: - DappInteractionCompletion.View +extension DappInteractionCompletion { + @MainActor + struct View: SwiftUI.View { + let store: StoreOf + + @ScaledMetric private var height: CGFloat = 360 + + var body: some SwiftUI.View { + WithViewStore(store, observe: { $0 }) { viewStore in + WithNavigationBar { + store.send(.view(.dismissTapped)) + } content: { + VStack(spacing: .zero) { + Spacer() + + Image(asset: AssetResource.successCheckmark) + + Text(L10n.DAppRequest.Completion.title) + .foregroundColor(.app.gray1) + .textStyle(.sheetTitle) + .padding([.top, .horizontal], .medium3) + + Text(L10n.DAppRequest.Completion.subtitle(viewStore.dappMetadata.name)) + .foregroundColor(.app.gray1) + .textStyle(.body1Regular) + .multilineTextAlignment(.center) + .padding([.top, .horizontal], .medium3) + + if let intentHash = viewStore.intentHash { + AddressView(.transaction(intentHash), imageColor: .app.gray2) + .foregroundColor(.app.blue1) + .textStyle(.body1Header) + .padding(.top, .small2) + } + + Spacer() + + if viewStore.showSwitchBackToBrowserMessage { + Text(L10n.MobileConnect.interactionSuccess) + .foregroundColor(.app.gray1) + .textStyle(.body1Regular) + .multilineTextAlignment(.center) + .padding(.vertical, .medium1) + .frame(maxWidth: .infinity) + .background(.app.gray5) + } + } + .frame(maxWidth: .infinity) + } + } + .presentationDragIndicator(.visible) + .presentationDetents([.height(height)]) + .presentationBackground(.blur) + } + } +} + +private extension DappInteractionCompletion.State { + var intentHash: TransactionIntentHash? { + switch kind { + case .personaData: + nil + case let .transaction(intentHash): + intentHash + } + } + + var showSwitchBackToBrowserMessage: Bool { + p2pRoute.isDeepLink + } +} diff --git a/RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion.swift b/RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion.swift new file mode 100644 index 0000000000..ea5f0cdc99 --- /dev/null +++ b/RadixWallet/Features/DappInteractionFeature/Children/DappInteractionCompletion/DappInteractionCompletion.swift @@ -0,0 +1,39 @@ +import ComposableArchitecture +import SwiftUI + +// MARK: - DappInteractionCompletion +struct DappInteractionCompletion: Sendable, FeatureReducer { + struct State: Sendable, Hashable { + let kind: Kind + let dappMetadata: DappMetadata + let p2pRoute: P2P.Route + } + + enum ViewAction: Sendable, Equatable { + case dismissTapped + } + + enum DelegateAction: Sendable, Equatable { + case dismiss + } + + func reduce(into state: inout State, viewAction: ViewAction) -> Effect { + switch viewAction { + case .dismissTapped: + .send(.delegate(.dismiss)) + } + } +} + +typealias DappInteractionCompletionKind = DappInteractionCompletion.State.Kind + +// MARK: - DappInteractionCompletion.State.Kind +extension DappInteractionCompletion.State { + enum Kind: Sendable, Hashable { + /// Completion view shown after an authorized/unauthorized dApp interaction. + case personaData + + /// Completion view shown after a transaction dApp interaction. + case transaction(TransactionIntentHash) + } +} diff --git a/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionCoordinator.swift b/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionCoordinator.swift index 35302463a4..fa0a7dd974 100644 --- a/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionCoordinator.swift +++ b/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionCoordinator.swift @@ -40,8 +40,8 @@ struct DappInteractionCoordinator: Sendable, FeatureReducer { } enum DelegateAction: Sendable, Equatable { - case submit(WalletToDappInteractionResponse, DappMetadata) - case dismiss(DappMetadata, TransactionIntentHash) + case submit(WalletToDappInteractionResponse, DappMetadata, PreAuthorizationData? = nil) + case dismiss(DappMetadata, DappInteractionCompletionKind) case dismissSilently } @@ -96,14 +96,14 @@ struct DappInteractionCoordinator: Sendable, FeatureReducer { case let .flow(.delegate(.dismissWithFailure(error))): return .send(.delegate(.submit(.failure(error), .request(state.request.interaction.metadata)))) - case let .flow(.delegate(.dismissWithSuccess(dappMetadata, txID))): - return .send(.delegate(.dismiss(dappMetadata, txID))) + case let .flow(.delegate(.dismissWithSuccess(dappMetadata, kind))): + return .send(.delegate(.dismiss(dappMetadata, kind))) case .flow(.delegate(.dismiss)): return .send(.delegate(.dismissSilently)) - case let .flow(.delegate(.submit(response, dappMetadata))): - return .send(.delegate(.submit(.success(response), dappMetadata))) + case let .flow(.delegate(.submit(response, dappMetadata, preAuthData))): + return .send(.delegate(.submit(.success(response), dappMetadata, preAuthData))) default: return .none diff --git a/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionFlow.swift b/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionFlow.swift index 990f0a7a93..66f25213a4 100644 --- a/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionFlow.swift +++ b/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionFlow.swift @@ -124,8 +124,8 @@ struct DappInteractionFlow: Sendable, FeatureReducer { enum DelegateAction: Sendable, Equatable { case dismissWithFailure(WalletToDappInteractionFailureResponse) - case dismissWithSuccess(DappMetadata, TransactionIntentHash) - case submit(WalletToDappInteractionSuccessResponse, DappMetadata) + case dismissWithSuccess(DappMetadata, DappInteractionCompletionKind) + case submit(WalletToDappInteractionSuccessResponse, DappMetadata, PreAuthorizationData? = nil) case dismiss } @@ -512,11 +512,13 @@ extension DappInteractionFlow { func handlePreAuthorizationSignature( _ item: State.AnyInteractionItem, - _ signedSubintent: SignedSubintent + _ signedSubintent: SignedSubintent, + _ expiration: DappToWalletInteractionSubintentExpiration ) -> Effect { let preAuthResponse = newWalletToDappInteractionPreAuthorizationResponseItems(signedSubintent: signedSubintent) state.responseItems[item] = .remote(.preAuthorization(preAuthResponse)) - return continueEffect(for: &state) + + return continueEffect(for: &state, preAuthData: .init(subintentHash: signedSubintent.subintent.hash(), expiration: expiration)) } func handlePreAuthorizationFailure( @@ -564,7 +566,7 @@ extension DappInteractionFlow { return handleSignAndSubmitTX(item, txID) case let .reviewTransaction(.delegate(.transactionCompleted(txID))): - return .send(.delegate(.dismissWithSuccess(state.dappMetadata, txID))) + return .send(.delegate(.dismissWithSuccess(state.dappMetadata, .transaction(txID)))) case .reviewTransaction(.delegate(.dismiss)): return .send(.delegate(.dismiss)) @@ -586,8 +588,8 @@ extension DappInteractionFlow { .accountsProofOfOwnership(.delegate(.failedToSign)): return dismissEffect(for: state, errorKind: .failedToSignAuthChallenge, message: nil) - case let .preAuthorizationReview(.delegate(.signedPreAuthorization(encoded))): - return handlePreAuthorizationSignature(item, encoded) + case let .preAuthorizationReview(.delegate(.signedPreAuthorization(encoded, expiration))): + return handlePreAuthorizationSignature(item, encoded, expiration) case let .preAuthorizationReview(.delegate(.failed(error))): return handlePreAuthorizationFailure(error) @@ -744,7 +746,7 @@ extension DappInteractionFlow { } } - func continueEffect(for state: inout State) -> Effect { + func continueEffect(for state: inout State, preAuthData: PreAuthorizationData? = nil) -> Effect { if let nextRequest = state.interactionItems.first(where: { state.responseItems[$0] == nil }), let destination = Path.State( @@ -766,11 +768,11 @@ extension DappInteractionFlow { } return .none } else { - return finishInteractionFlow(state) + return finishInteractionFlow(state, preAuthData: preAuthData) } } - func finishInteractionFlow(_ state: State) -> Effect { + func finishInteractionFlow(_ state: State, preAuthData: PreAuthorizationData?) -> Effect { guard let response = WalletToDappInteractionSuccessResponse( for: state.remoteInteraction, with: state.responseItems.values.compactMap(/State.AnyInteractionResponseItem.remote) @@ -794,7 +796,7 @@ extension DappInteractionFlow { } } - await send(.delegate(.submit(response, state.dappMetadata))) + await send(.delegate(.submit(response, state.dappMetadata, preAuthData))) } } @@ -1053,7 +1055,7 @@ extension DappInteractionFlow.Path.State { unvalidatedManifest: item.unvalidatedManifest, expiration: item.expiration, nonce: .secureRandom(), - dAppMetadata: dappMetadata.onLedger, + dAppMetadata: dappMetadata, message: item.message )) } diff --git a/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionModels.swift b/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionModels.swift index b23cb8bd83..e8083d2b25 100644 --- a/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionModels.swift +++ b/RadixWallet/Features/DappInteractionFeature/Coordinator/DappInteractionModels.swift @@ -59,6 +59,17 @@ extension DappMetadata { } extension DappMetadata { + var name: String { + switch self { + case let .ledger(ledger): + ledger.name?.rawValue ?? L10n.DAppRequest.Metadata.unknownName + case .request: + L10n.DAppRequest.Metadata.unknownName + case .wallet: + L10n.DAppRequest.Metadata.wallet + } + } + var origin: DappOrigin { switch self { case let .ledger(metadata): metadata.origin diff --git a/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor+ViewModifier.swift b/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor+ViewModifier.swift index 78c0863f75..44ad9ae1d0 100644 --- a/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor+ViewModifier.swift +++ b/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor+ViewModifier.swift @@ -40,16 +40,12 @@ extension DappInteractor { } } - @MainActor private var dappInteraction: some SwiftUI.View { - WithViewStore(store, observe: { $0.destination }) { viewStore in - IfLetStore( - store.destination, - state: /DappInteractor.Destination.State.dappInteraction, - action: DappInteractor.Destination.Action.dappInteraction, - then: { DappInteractionCoordinator.View(store: $0) } - ) - .transition(.move(edge: .bottom)) + WithViewStore(store, observe: { $0 }) { viewStore in + IfLetStore(store.scope(state: \.dappInteraction, action: \.child.dappInteraction)) { viewStore in + DappInteractionCoordinator.View(store: viewStore) + .transition(.move(edge: .bottom)) + } .animation(.linear, value: viewStore.state) } } @@ -72,6 +68,7 @@ private extension View { return dappInteractionCompletion(with: destinationStore, store: store) .invalidRequestAlert(with: destinationStore) .responseFailureAlert(with: destinationStore) + .preAuthorizationPolling(with: destinationStore) } private func dappInteractionCompletion(with destinationStore: PresentationStoreOf, store: StoreOf) -> some View { @@ -79,7 +76,7 @@ private extension View { store: destinationStore.scope(state: \.dappInteractionCompletion, action: \.dappInteractionCompletion), onDismiss: { store.send(.view(.completionDismissed)) } ) { - Completion.View(store: $0) + DappInteractionCompletion.View(store: $0) } } @@ -90,6 +87,12 @@ private extension View { private func responseFailureAlert(with destinationStore: PresentationStoreOf) -> some View { alert(store: destinationStore.scope(state: \.responseFailure, action: \.responseFailure)) } + + private func preAuthorizationPolling(with destinationStore: PresentationStoreOf) -> some View { + sheet(store: destinationStore.scope(state: \.pollPreAuthorizationStatus, action: \.pollPreAuthorizationStatus)) { + PollPreAuthorizationStatus.View(store: $0) + } + } } #if DEBUG diff --git a/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor.swift b/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor.swift index df6561cd93..c00b8f8dbd 100644 --- a/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor.swift +++ b/RadixWallet/Features/DappInteractionFeature/Interactor/DappInteractor.swift @@ -4,6 +4,12 @@ import SwiftUI typealias RequestEnvelope = DappInteractionClient.RequestEnvelope +// MARK: - PreAuthorizationData +struct PreAuthorizationData: Sendable, Hashable { + let subintentHash: SubintentHash + let expiration: DappToWalletInteractionSubintentExpiration +} + // MARK: - RequestEnvelope + Identifiable extension RequestEnvelope: Identifiable { typealias ID = WalletInteractionId @@ -17,10 +23,12 @@ struct DappInteractor: Sendable, FeatureReducer { struct State: Sendable, Hashable { var requestQueue: IdentifiedArrayOf = [] + var dappInteraction: DappInteractionCoordinator.State? + @PresentationState var destination: Destination.State? - fileprivate var shouldIncrementOnCompletionDismiss = false + fileprivate var shouldIncrementNPSCounterOnCompletionDismiss = false } enum ViewAction: Sendable, Equatable { @@ -30,6 +38,11 @@ struct DappInteractor: Sendable, FeatureReducer { case completionDismissed } + @CasePathable + enum ChildAction: Sendable, Equatable { + case dappInteraction(DappInteractionCoordinator.Action) + } + enum InternalAction: Sendable, Equatable { case receivedRequestFromDapp(RequestEnvelope) case presentQueuedRequestIfNeeded @@ -37,20 +50,16 @@ struct DappInteractor: Sendable, FeatureReducer { WalletToDappInteractionResponse, for: RequestEnvelope, DappMetadata, - TransactionIntentHash? + PreAuthorizationData? ) case failedToSendResponseToDapp( WalletToDappInteractionResponse, for: RequestEnvelope, DappMetadata, - reason: String + reason: String, + preAuthData: PreAuthorizationData? ) - case presentResponseFailureAlert( - WalletToDappInteractionResponse, - for: RequestEnvelope, - DappMetadata, reason: String - ) - case presentResponseSuccessView(DappMetadata, TransactionIntentHash?, P2P.Route) + case presentResponseSuccessView(DappMetadata, DappInteractionCompletionKind, P2P.Route) case presentInvalidRequest( DappToWalletInteractionUnvalidated, reason: DappInteractionClient.ValidatedDappRequest.InvalidRequestReason, @@ -62,22 +71,22 @@ struct DappInteractor: Sendable, FeatureReducer { struct Destination: Sendable, DestinationReducer { @CasePathable enum State: Sendable, Hashable { - case dappInteraction(DappInteractionCoordinator.State) - case dappInteractionCompletion(Completion.State) + case dappInteractionCompletion(DappInteractionCompletion.State) + case pollPreAuthorizationStatus(PollPreAuthorizationStatus.State) case responseFailure(AlertState) case invalidRequest(AlertState) } @CasePathable enum Action: Sendable, Equatable { - case dappInteraction(DappInteractionCoordinator.Action) - case dappInteractionCompletion(Completion.Action) + case dappInteractionCompletion(DappInteractionCompletion.Action) + case pollPreAuthorizationStatus(PollPreAuthorizationStatus.Action) case responseFailure(ResponseFailure) case invalidRequest(InvalidRequest) enum ResponseFailure: Sendable, Hashable { case cancelButtonTapped(RequestEnvelope) - case retryButtonTapped(WalletToDappInteractionResponse, for: RequestEnvelope, DappMetadata) + case retryButtonTapped(WalletToDappInteractionResponse, for: RequestEnvelope, DappMetadata, PreAuthorizationData?) } enum InvalidRequest: Sendable, Hashable { @@ -86,11 +95,11 @@ struct DappInteractor: Sendable, FeatureReducer { } var body: some ReducerOf { - Scope(state: \.dappInteraction, action: \.dappInteraction) { - DappInteractionCoordinator() - } Scope(state: \.dappInteractionCompletion, action: \.dappInteractionCompletion) { - Completion() + DappInteractionCompletion() + } + Scope(state: \.pollPreAuthorizationStatus, action: \.pollPreAuthorizationStatus) { + PollPreAuthorizationStatus() } } } @@ -110,6 +119,9 @@ struct DappInteractor: Sendable, FeatureReducer { .ifLet(destinationPath, action: /Action.destination) { Destination() } + .ifLet(\.dappInteraction, action: \.child.dappInteraction) { + DappInteractionCoordinator() + } } private let destinationPath: WritableKeyPath> = \.$destination @@ -130,7 +142,7 @@ struct DappInteractor: Sendable, FeatureReducer { } case .completionDismissed: - if state.shouldIncrementOnCompletionDismiss { + if state.shouldIncrementNPSCounterOnCompletionDismiss { npsSurveyClient.incrementTransactionCompleteCounter() } return .none @@ -156,27 +168,30 @@ struct DappInteractor: Sendable, FeatureReducer { case .presentQueuedRequestIfNeeded: return presentQueuedRequestIfNeededEffect(for: &state) - case let .sentResponseToDapp(response, for: request, dappMetadata, txID): - dismissCurrentModalAndRequest(request, for: &state) + case let .sentResponseToDapp(response, for: request, dappMetadata, preAuthData): switch response { case .success: - return .send(.internal(.presentResponseSuccessView(dappMetadata, txID, request.route))) + if let preAuthData { + dismissCurrentModalAndRequest(request, for: &state, clearDappInteraction: false) + return pollPreAuthorizationEffect(for: &state, request: request, dappMetadata: dappMetadata, preAuthData: preAuthData) + } else { + dismissCurrentModalAndRequest(request, for: &state) + return .send(.internal(.presentResponseSuccessView(dappMetadata, .personaData, request.route))) + } case .failure: + dismissCurrentModalAndRequest(request, for: &state) return delayedMediumEffect(internal: .presentQueuedRequestIfNeeded) } - case let .failedToSendResponseToDapp(response, for: request, metadata, reason): + case let .failedToSendResponseToDapp(response, for: request, dappMetadata, reason, preAuthData): dismissCurrentModalAndRequest(request, for: &state) - return .send(.internal(.presentResponseFailureAlert(response, for: request, metadata, reason: reason))) - - case let .presentResponseFailureAlert(response, for: request, dappMetadata, reason): state.destination = .responseFailure(.init( title: { TextState(L10n.Common.errorAlertTitle) }, actions: { ButtonState(role: .cancel, action: .cancelButtonTapped(request)) { TextState(L10n.Common.cancel) } - ButtonState(action: .retryButtonTapped(response, for: request, dappMetadata)) { + ButtonState(action: .retryButtonTapped(response, for: request, dappMetadata, preAuthData)) { TextState(L10n.Common.retry) } }, @@ -212,26 +227,22 @@ struct DappInteractor: Sendable, FeatureReducer { )) return .none - case let .presentResponseSuccessView(dappMetadata, txID, p2pRoute): - state.shouldIncrementOnCompletionDismiss = txID != nil + case let .presentResponseSuccessView(dappMetadata, kind, p2pRoute): + state.shouldIncrementNPSCounterOnCompletionDismiss = kind.shouldIncrementNPSCounterOnCompletionDismiss if !state.requestQueue.isEmpty { return delayedMediumEffect(internal: .presentQueuedRequestIfNeeded) } state.destination = .dappInteractionCompletion( - .init( - txID: txID, - dappMetadata: dappMetadata, - p2pRoute: p2pRoute - ) + .init(kind: kind, dappMetadata: dappMetadata, p2pRoute: p2pRoute) ) return .none } } - func reduce(into state: inout State, presentedAction: Destination.Action) -> Effect { - switch presentedAction { + func reduce(into state: inout State, childAction: ChildAction) -> Effect { + switch childAction { case let .dappInteraction(.delegate(delegateAction)): - guard case let .dappInteraction(dappInteraction) = state.destination else { + guard let dappInteraction = state.dappInteraction else { let message = "We should only get actions from this modal if it is showing" assertionFailure(message) loggerGlobal.error(.init(stringLiteral: message)) @@ -240,8 +251,8 @@ struct DappInteractor: Sendable, FeatureReducer { let request = dappInteraction.request switch delegateAction { - case let .submit(responseToDapp, dappMetadata): - return sendResponseToDappEffect(responseToDapp, for: request, dappMetadata: dappMetadata) + case let .submit(responseToDapp, dappMetadata, preAuthData): + return sendResponseToDappEffect(responseToDapp, for: request, dappMetadata: dappMetadata, preAuthData: preAuthData) case let .dismiss(dappMetadata, txID): dismissCurrentModalAndRequest(request, for: &state) return delayedShortEffect(for: .internal(.presentResponseSuccessView(dappMetadata, txID, request.route))) @@ -250,6 +261,13 @@ struct DappInteractor: Sendable, FeatureReducer { return delayedMediumEffect(internal: .presentQueuedRequestIfNeeded) } + default: + return .none + } + } + + func reduce(into state: inout State, presentedAction: Destination.Action) -> Effect { + switch presentedAction { case .dappInteractionCompletion(.delegate(.dismiss)): return onCompletionScreenDismissed(&state) @@ -258,8 +276,8 @@ struct DappInteractor: Sendable, FeatureReducer { case let .cancelButtonTapped(request): dismissCurrentModalAndRequest(request, for: &state) return .send(.internal(.presentQueuedRequestIfNeeded)) - case let .retryButtonTapped(response, request, dappMetadata): - return sendResponseToDappEffect(response, for: request, dappMetadata: dappMetadata) + case let .retryButtonTapped(response, request, dappMetadata, preAuthData): + return sendResponseToDappEffect(response, for: request, dappMetadata: dappMetadata, preAuthData: preAuthData) } case let .invalidRequest(action): @@ -275,6 +293,10 @@ struct DappInteractor: Sendable, FeatureReducer { } } + case let .pollPreAuthorizationStatus(.delegate(.dismiss(request))): + dismissCurrentModalAndRequest(request, for: &state) + return delayedMediumEffect(internal: .presentQueuedRequestIfNeeded) + default: return .none } @@ -283,9 +305,12 @@ struct DappInteractor: Sendable, FeatureReducer { func reduceDismissedDestination(into state: inout State) -> Effect { switch state.destination { case .dappInteractionCompletion: - onCompletionScreenDismissed(&state) + return onCompletionScreenDismissed(&state) + case .pollPreAuthorizationStatus: + state.dappInteraction = nil + return delayedMediumEffect(internal: .presentQueuedRequestIfNeeded) default: - .none + return .none } } @@ -294,12 +319,13 @@ struct DappInteractor: Sendable, FeatureReducer { ) -> Effect { guard let next = state.requestQueue.first, + state.dappInteraction == nil, state.destination == nil else { return .none } - state.destination = .dappInteraction(.init(request: next)) + state.dappInteraction = .init(request: next) return .none } @@ -307,21 +333,24 @@ struct DappInteractor: Sendable, FeatureReducer { func sendResponseToDappEffect( _ responseToDapp: WalletToDappInteractionResponse, for request: RequestEnvelope, - dappMetadata: DappMetadata + dappMetadata: DappMetadata, + preAuthData: PreAuthorizationData? ) -> Effect { .run { send in // In case of transaction response, sending it to the peer client is a silent operation. // The success or failures is determined based on the transaction polling status. - let txID: TransactionIntentHash? = { - if case let .success(successResponse) = responseToDapp, - case let .transaction(txID) = successResponse.items - { - return txID.send.transactionIntentHash + let isTransactionResponse = { + guard case let .success(successResponse) = responseToDapp else { + return false + } + switch successResponse.items { + case .authorizedRequest, .unauthorizedRequest, .preAuthorization: + return false + case .transaction: + return true } - return nil }() - let isTransactionResponse = txID != nil do { _ = try await dappInteractionClient.completeInteraction(.response(.dapp(responseToDapp), origin: request.route)) @@ -331,7 +360,7 @@ struct DappInteractor: Sendable, FeatureReducer { responseToDapp, for: request, dappMetadata, - txID + preAuthData ) )) } else { @@ -344,7 +373,8 @@ struct DappInteractor: Sendable, FeatureReducer { responseToDapp, for: request, dappMetadata, - reason: error.localizedDescription + reason: error.localizedDescription, + preAuthData: preAuthData ) )) } else { @@ -354,8 +384,11 @@ struct DappInteractor: Sendable, FeatureReducer { } } - func dismissCurrentModalAndRequest(_ request: RequestEnvelope, for state: inout State) { + func dismissCurrentModalAndRequest(_ request: RequestEnvelope, for state: inout State, clearDappInteraction: Bool = true) { state.requestQueue.remove(id: request.id) + if clearDappInteraction { + state.dappInteraction = nil + } state.destination = nil } @@ -363,6 +396,24 @@ struct DappInteractor: Sendable, FeatureReducer { state.destination = nil return delayedMediumEffect(internal: .presentQueuedRequestIfNeeded) } + + func pollPreAuthorizationEffect( + for state: inout State, + request: RequestEnvelope, + dappMetadata: DappMetadata, + preAuthData: PreAuthorizationData + ) -> Effect { + state.destination = .pollPreAuthorizationStatus( + .init( + dAppMetadata: dappMetadata, + subintentHash: preAuthData.subintentHash, + expiration: preAuthData.expiration, + isDeepLink: request.route.isDeepLink, + request: request + ) + ) + return .none + } } extension DappInteractionClient.ValidatedDappRequest.InvalidRequestReason { @@ -510,3 +561,14 @@ extension DappInteractor { } } } + +private extension DappInteractionCompletionKind { + var shouldIncrementNPSCounterOnCompletionDismiss: Bool { + switch self { + case .transaction: + true + case .personaData: + false + } + } +} diff --git a/RadixWallet/Features/InteractionReview/Components/InteractionInProgressView.swift b/RadixWallet/Features/InteractionReview/Components/InteractionInProgressView.swift new file mode 100644 index 0000000000..39809faf80 --- /dev/null +++ b/RadixWallet/Features/InteractionReview/Components/InteractionInProgressView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +extension InteractionReview { + struct InteractionInProgressView: View { + @State private var opacity: Double = 1.0 + + var body: some View { + Image(asset: AssetResource.transactionInProgress) + .opacity(opacity) + .animation( + .easeInOut(duration: 0.3) + .delay(0.2) + .repeatForever(autoreverses: true), + value: opacity + ) + .onAppear { + withAnimation { + opacity = 0.5 + } + } + } + } +} diff --git a/RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus+View.swift b/RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus+View.swift new file mode 100644 index 0000000000..a80f546ece --- /dev/null +++ b/RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus+View.swift @@ -0,0 +1,149 @@ +// MARK: - PollPreAuthorizationStatus.View +extension PollPreAuthorizationStatus { + @MainActor + struct View: SwiftUI.View { + let store: StoreOf + + @ScaledMetric private var height: CGFloat = 485 + + var body: some SwiftUI.View { + WithViewStore(store, observe: { $0 }) { viewStore in + WithNavigationBar { + store.send(.view(.closeButtonTapped)) + } content: { + VStack(spacing: .zero) { + Spacer() + VStack(spacing: .medium1) { + topAsset(viewStore.status) + + Text(viewStore.title) + .textStyle(.sheetTitle) + .foregroundStyle(.app.gray1) + + VStack(spacing: .small1) { + Text(viewStore.subtitle) + .textStyle(.body1Regular) + .foregroundStyle(.app.gray1) + + if let ledgerIdentifiable = viewStore.ledgerIdentifiable { + AddressView(ledgerIdentifiable) + .foregroundColor(.app.blue1) + .textStyle(.body1Header) + } + } + } + .padding(.horizontal, .large2) + + Spacer() + + switch viewStore.status { + case .unknown: + unknownBottom(text: viewStore.expirationMessage) + case .expired: + expiredBottom(showBrowserMessage: viewStore.isDeepLink) + case .success: + successBottom(showBrowserMessage: viewStore.isDeepLink) + } + } + .multilineTextAlignment(.center) + } + .onFirstTask { @MainActor in + store.send(.view(.onFirstTask)) + } + .presentationDragIndicator(.visible) + .presentationDetents([.height(height), .large]) + .presentationBackground(.blur) + .animation(.default, value: viewStore.status) + } + } + + @ViewBuilder + private func topAsset(_ status: Status) -> some SwiftUI.View { + switch status { + case .unknown: + InteractionReview.InteractionInProgressView() + case .expired: + Image(.errorLarge) + case .success: + Image(.successCheckmark) + } + } + + private func unknownBottom(text: String) -> some SwiftUI.View { + Text(markdown: text, emphasizedColor: .app.account4pink, emphasizedFont: .app.button) + .textStyle(.body1Regular) + .foregroundStyle(.app.account4pink) + .padding(.medium1) + .frame(maxWidth: .infinity) + .background(.app.gray5) + } + + @ViewBuilder + private func expiredBottom(showBrowserMessage: Bool) -> some SwiftUI.View { + if showBrowserMessage { + Text(L10n.PreAuthorizationReview.ExpiredStatus.retryInBrowser) + .textStyle(.body1Regular) + .foregroundStyle(.app.gray1) + .padding(.medium1) + .frame(maxWidth: .infinity) + .background(.app.gray5) + } + } + + @ViewBuilder + private func successBottom(showBrowserMessage: Bool) -> some SwiftUI.View { + if showBrowserMessage { + Text(L10n.MobileConnect.interactionSuccess) + .textStyle(.body1Regular) + .foregroundStyle(.app.gray1) + .padding(.medium1) + .frame(maxWidth: .infinity) + .background(.app.gray5) + } + } + } +} + +private extension PollPreAuthorizationStatus.State { + var title: String { + switch status { + case .unknown: + L10n.PreAuthorizationReview.UnknownStatus.title + case .expired: + L10n.PreAuthorizationReview.ExpiredStatus.title + case .success: + L10n.DAppRequest.Completion.title + } + } + + var subtitle: String { + switch status { + case .unknown: + L10n.PreAuthorizationReview.UnknownStatus.subtitle(dAppMetadata.name) + case .expired: + L10n.PreAuthorizationReview.ExpiredStatus.subtitle + case .success: + L10n.DAppRequest.Completion.subtitlePreAuthorization + } + } + + var ledgerIdentifiable: LedgerIdentifiable? { + switch status { + case .unknown: + .preAuthorization(subintentHash) + case .expired: + nil + case let .success(intentHash): + .transaction(intentHash) + } + } + + var expirationMessage: String { + if secondsToExpiration > 0 { + let time = PreAuthorizationReview.TimeFormatter.format(seconds: secondsToExpiration) + return L10n.PreAuthorizationReview.UnknownStatus.expiration(dAppMetadata.name, time) + } else { + return L10n.PreAuthorizationReview.UnknownStatus.lastCheck + } + } +} diff --git a/RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus.swift b/RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus.swift new file mode 100644 index 0000000000..4dd69ce91c --- /dev/null +++ b/RadixWallet/Features/PreAuthorizationReview/PollPreAuthorizationStatus/PollPreAuthorizationStatus.swift @@ -0,0 +1,115 @@ +// MARK: - PollPreAuthorizationStatus +struct PollPreAuthorizationStatus: Sendable, FeatureReducer { + typealias Expiration = DappToWalletInteractionSubintentExpiration + + struct State: Sendable, Hashable { + let dAppMetadata: DappMetadata + let subintentHash: SubintentHash + let expiration: Expiration + let isDeepLink: Bool + let request: RequestEnvelope + var status = Status.unknown + var secondsToExpiration: Int + + init( + dAppMetadata: DappMetadata, + subintentHash: SubintentHash, + expiration: Expiration, + isDeepLink: Bool, + request: RequestEnvelope + ) { + self.dAppMetadata = dAppMetadata + self.subintentHash = subintentHash + self.expiration = expiration + self.isDeepLink = isDeepLink + self.request = request + switch expiration { + case let .afterDelay(afterDelay): + secondsToExpiration = Int(afterDelay.expireAfterSeconds) + case let .atTime(atTime): + secondsToExpiration = Int(atTime.date.timeIntervalSinceNow) + } + } + } + + enum ViewAction: Sendable, Equatable { + case onFirstTask + case closeButtonTapped + } + + enum InternalAction: Sendable, Equatable { + case setStatus(PreAuthorizationStatus) + case updateSecondsToExpiration + } + + enum DelegateAction: Sendable, Equatable { + case dismiss(RequestEnvelope) + } + + @Dependency(\.preAuthorizationClient) var preAuthorizationClient + @Dependency(\.continuousClock) var clock + + func reduce(into state: inout State, viewAction: ViewAction) -> Effect { + switch viewAction { + case .onFirstTask: + pollStatus(state: &state) + .merge(with: startTimer()) + case .closeButtonTapped: + .send(.delegate(.dismiss(state.request))) + } + } + + func reduce(into state: inout State, internalAction: InternalAction) -> Effect { + switch internalAction { + case let .setStatus(status): + switch status { + case .expired: + state.status = .expired + case let .success(intentHash): + state.status = .success(intentHash) + } + return .cancel(id: CancellableId.expirationTimer) + + case .updateSecondsToExpiration: + state.secondsToExpiration -= 1 + return .none + } + } + + private func pollStatus(state: inout State) -> Effect { + let request = PreAuthorizationClient.PollStatusRequest( + subintentHash: state.subintentHash, + expiration: state.expiration + ) + return .run { send in + let status = try await preAuthorizationClient.pollStatus(request) + await send(.internal(.setStatus(status))) + } + } + + private func startTimer() -> Effect { + .run { send in + for await _ in self.clock.timer(interval: .seconds(1)) { + await send(.internal(.updateSecondsToExpiration)) + } + } + .cancellable(id: CancellableId.expirationTimer, cancelInFlight: true) + } +} + +extension PollPreAuthorizationStatus { + enum Status: Sendable, Hashable { + /// The Pre-Authorization hasn't been submitted within a Transaction yet. We are still polling until we get a final status (success or expired). + case unknown + + /// The Pre-Authorization wasn't committed successfully within a Transaction and it has now expired. + case expired + + /// The Pre-Authorization was successfully commited within a Transaction. + case success(TransactionIntentHash) + } + + private enum CancellableId: Hashable { + case expirationTimer + } +} diff --git a/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview+View.swift b/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview+View.swift index 2f05fef77e..369ab70bc9 100644 --- a/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview+View.swift +++ b/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview+View.swift @@ -18,7 +18,7 @@ extension PreAuthorizationReview.State { // MARK: - PreAuthorizationReview.View extension PreAuthorizationReview { struct ViewState: Equatable { - let dAppMetadata: DappMetadata.Ledger? + let dAppMetadata: DappMetadata let displayMode: Common.DisplayMode let sliderResetDate: Date let expiration: Expiration @@ -26,10 +26,6 @@ extension PreAuthorizationReview { let globalControlState: ControlState let sliderControlState: ControlState let showRawManifestButton: Bool - - var dAppName: String? { - dAppMetadata?.name?.rawValue - } } @MainActor @@ -50,7 +46,7 @@ extension PreAuthorizationReview { .toolbar { ToolbarItem(placement: .principal) { if showNavigationTitle { - navigationTitle(dAppName: viewStore.dAppName) + navigationTitle(dAppName: viewStore.dAppMetadata.name) } } } @@ -91,7 +87,7 @@ extension PreAuthorizationReview { .clipShape(RoundedRectangle(cornerRadius: .small1)) .padding(.horizontal, .small2) - feesInformation(dAppName: viewStore.dAppName) + feesInformation(dAppName: viewStore.dAppMetadata.name) .padding(.top, .small2) .padding(.horizontal, .small2) @@ -123,11 +119,11 @@ extension PreAuthorizationReview { } } - private func header(dAppMetadata: DappMetadata.Ledger?) -> some SwiftUI.View { + private func header(dAppMetadata: DappMetadata) -> some SwiftUI.View { Common.HeaderView( kind: .preAuthorization, - name: dAppMetadata?.name?.rawValue, - thumbnail: dAppMetadata?.thumbnail + name: dAppMetadata.name, + thumbnail: dAppMetadata.thumbnail ) .measurePosition(navTitleID, coordSpace: coordSpace) .padding(.horizontal, .medium3) @@ -233,13 +229,13 @@ private extension View { .rawManifestAlert(with: destinationStore) } - private func rawManifestAlert(with destinationStore: PresentationStoreOf) -> some View { - alert(store: destinationStore.scope(state: \.rawManifestAlert, action: \.rawManifestAlert)) - } - private func signing(with destinationStore: PresentationStoreOf) -> some View { sheet(store: destinationStore.scope(state: \.signing, action: \.signing)) { Signing.View(store: $0) } } + + private func rawManifestAlert(with destinationStore: PresentationStoreOf) -> some View { + alert(store: destinationStore.scope(state: \.rawManifestAlert, action: \.rawManifestAlert)) + } } diff --git a/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview.swift b/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview.swift index 942d60d2cd..61b433d0b6 100644 --- a/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview.swift +++ b/RadixWallet/Features/PreAuthorizationReview/PreAuthorizationReview.swift @@ -8,7 +8,7 @@ struct PreAuthorizationReview: Sendable, FeatureReducer { let expiration: Expiration let nonce: Nonce let ephemeralNotaryPrivateKey: Curve25519.Signing.PrivateKey = .init() - let dAppMetadata: DappMetadata.Ledger? + let dAppMetadata: DappMetadata let message: String? var preview: PreAuthorizationPreview? @@ -44,7 +44,7 @@ struct PreAuthorizationReview: Sendable, FeatureReducer { } enum DelegateAction: Sendable, Equatable { - case signedPreAuthorization(SignedSubintent) + case signedPreAuthorization(SignedSubintent, DappToWalletInteractionSubintentExpiration) case failed(PreAuthorizationFailure) } @@ -156,7 +156,7 @@ struct PreAuthorizationReview: Sendable, FeatureReducer { } guard !preview.signingFactors.isEmpty else { - return .send(.delegate(.signedPreAuthorization(.init(subintent: subintent, subintentSignatures: .init(signatures: []))))) + return handleSignedSubinent(state: &state, signedSubintent: .init(subintent: subintent, subintentSignatures: .init(signatures: []))) } state.destination = .signing(.init( @@ -166,8 +166,9 @@ struct PreAuthorizationReview: Sendable, FeatureReducer { return .none case let .updateSecondsToExpiration(expiration): - state.secondsToExpiration = Int(expiration.timeIntervalSinceNow) - return .none + let secondsToExpiration = Int(expiration.timeIntervalSinceNow) + state.secondsToExpiration = secondsToExpiration + return secondsToExpiration > 0 ? .none : .cancel(id: CancellableId.expirationTimer) case .resetToApprovable: return resetToApprovable(&state) @@ -198,7 +199,7 @@ struct PreAuthorizationReview: Sendable, FeatureReducer { return resetToApprovable(&state) case let .finishedSigning(.signPreAuthorization(encoded)): - return .send(.delegate(.signedPreAuthorization(encoded))) + return handleSignedSubinent(state: &state, signedSubintent: encoded) case .finishedSigning: assertionFailure("Unexpected signature instead of .signPreAuthorization") @@ -209,6 +210,15 @@ struct PreAuthorizationReview: Sendable, FeatureReducer { return .none } } + + func reduceDismissedDestination(into state: inout State) -> Effect { + switch state.destination { + case .signing: + resetToApprovable(&state) + case .rawManifestAlert, .none: + .none + } + } } private extension PreAuthorizationReview { @@ -256,8 +266,14 @@ private extension PreAuthorizationReview { func resetToApprovable(_ state: inout State) -> Effect { state.isApprovalInProgress = false state.sliderResetDate = .now + state.destination = nil return .none } + + func handleSignedSubinent(state: inout State, signedSubintent: SignedSubintent) -> Effect { + state.destination = nil + return .send(.delegate(.signedPreAuthorization(signedSubintent, state.expiration))) + } } // MARK: PreAuthorizationReview.CancellableId diff --git a/RadixWallet/Features/PreAuthorizationReview/TImeFormatter/TimeFormatter.swift b/RadixWallet/Features/PreAuthorizationReview/TimeFormatter/TimeFormatter.swift similarity index 100% rename from RadixWallet/Features/PreAuthorizationReview/TImeFormatter/TimeFormatter.swift rename to RadixWallet/Features/PreAuthorizationReview/TimeFormatter/TimeFormatter.swift diff --git a/RadixWallet/Features/TransactionReviewFeature/SubmitTransaction/SubmitTransaction+View.swift b/RadixWallet/Features/TransactionReviewFeature/SubmitTransaction/SubmitTransaction+View.swift index c21eb10e98..6df653832b 100644 --- a/RadixWallet/Features/TransactionReviewFeature/SubmitTransaction/SubmitTransaction+View.swift +++ b/RadixWallet/Features/TransactionReviewFeature/SubmitTransaction/SubmitTransaction+View.swift @@ -55,8 +55,6 @@ extension SubmitTransaction { @MainActor struct View: SwiftUI.View { - @SwiftUI.State private var opacity: Double = 1.0 - private let store: StoreOf @ScaledMetric private var height: CGFloat = 360 @@ -81,19 +79,7 @@ extension SubmitTransaction { .padding(.horizontal, .medium2) .padding(.top, .medium3) } else { - Image(asset: AssetResource.transactionInProgress) - .opacity(opacity) - .animation( - .easeInOut(duration: 0.3) - .delay(0.2) - .repeatForever(autoreverses: true), - value: opacity - ) - .onAppear { - withAnimation { - opacity = 0.5 - } - } + InteractionReview.InteractionInProgressView() } Text(viewStore.status.display) @@ -103,15 +89,11 @@ extension SubmitTransaction { .padding(.horizontal, .medium2) .padding(.top, .medium3) - HStack { - Text(L10n.TransactionReview.SubmitTransaction.txID) - .foregroundColor(.app.gray1) - AddressView(.transaction(viewStore.txID), imageColor: .app.gray2) - .foregroundColor(.app.blue1) - } - .textStyle(.body1Header) - .padding(.horizontal, .medium2) - .padding(.top, .small2) + AddressView(.transaction(viewStore.txID), imageColor: .app.gray2) + .foregroundColor(.app.blue1) + .textStyle(.body1Header) + .padding(.horizontal, .medium2) + .padding(.top, .small2) Spacer() if viewStore.status.failed, viewStore.showSwitchBackToBrowserMessage {