From 7a294115cda287f1db64ebd34903f3cab18d39ee Mon Sep 17 00:00:00 2001 From: Robbie <304604+robbiehanson@users.noreply.github.com> Date: Tue, 24 Oct 2023 11:28:05 -0400 Subject: [PATCH] (ios) Display swap-in grace period & timeouts (#454) --- .../phoenix-ios.xcodeproj/project.pbxproj | 6 + phoenix-ios/phoenix-ios/Localizable.xcstrings | 49 ++ .../phoenix-ios/extensions/Sequence+Sum.swift | 2 +- .../kotlin/KotlinExtensions+Lightning.swift | 67 ++- .../configuration/ConfigurationView.swift | 1 + .../advanced/wallet/SwapInWalletDetails.swift | 303 +++++++----- .../advanced/wallet/WalletInfoView.swift | 202 +++++--- .../payment options/PaymentOptionsView.swift | 1 + .../views/environment/DeepLink.swift | 1 + .../phoenix-ios/views/main/HomeView.swift | 84 ++-- .../phoenix-ios/views/main/MainView_Big.swift | 1 + .../views/main/MainView_Small.swift | 1 + .../notifications/BizNotificationCell.swift | 385 +++++++++++++++ .../views/notifications/NoticeMonitor.swift | 41 +- .../notifications/NotificationCell.swift | 455 +----------------- .../notifications/NotificationsView.swift | 46 +- 16 files changed, 963 insertions(+), 682 deletions(-) create mode 100644 phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index a7aafd07e..4375dfed7 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -167,6 +167,7 @@ DC81B79F25BF2AA200F5A52C /* MVI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC81B79E25BF2AA200F5A52C /* MVI.swift */; }; DC82EED629789853007A5853 /* TxHistoryExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC82EED529789853007A5853 /* TxHistoryExporter.swift */; }; DC89857F25914747007B253F /* UIApplicationState+Phoenix.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89857E25914747007B253F /* UIApplicationState+Phoenix.swift */; }; + DC9130A02AE045FA00F9B8C6 /* Sequence+Sum.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC59377027516296003B4B53 /* Sequence+Sum.swift */; }; DC9473FA261270B4008D7242 /* MVI+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9473F9261270B4008D7242 /* MVI+Mock.swift */; }; DC9545C429490321008FCEF4 /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9545C329490321008FCEF4 /* NotificationContent.swift */; }; DC9545C5294905FB008FCEF4 /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9545C329490321008FCEF4 /* NotificationContent.swift */; }; @@ -275,6 +276,7 @@ DCFAEFC92A72F48700330088 /* SwapInWalletDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFAEFC82A72F48700330088 /* SwapInWalletDetails.swift */; }; DCFB8DF72A94066100947698 /* Task+Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFB8DF62A94066100947698 /* Task+Sleep.swift */; }; DCFB8DF92A94112A00947698 /* Dictionary+MapKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFB8DF82A94112A00947698 /* Dictionary+MapKeys.swift */; }; + DCFBC5592AE2CFEF00E3A418 /* BizNotificationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFBC5582AE2CFEF00E3A418 /* BizNotificationCell.swift */; }; DCFC72042862237400D6B293 /* Asserts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC72032862237400D6B293 /* Asserts.swift */; }; DCFD079126D84A380020DD8E /* HorizontalActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFD079026D84A380020DD8E /* HorizontalActivity.swift */; }; F4AED298257A50CD009485C1 /* LogsConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4AED296257A50CD009485C1 /* LogsConfigurationView.swift */; }; @@ -593,6 +595,7 @@ DCFAEFC82A72F48700330088 /* SwapInWalletDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapInWalletDetails.swift; sourceTree = ""; }; DCFB8DF62A94066100947698 /* Task+Sleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Sleep.swift"; sourceTree = ""; }; DCFB8DF82A94112A00947698 /* Dictionary+MapKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+MapKeys.swift"; sourceTree = ""; }; + DCFBC5582AE2CFEF00E3A418 /* BizNotificationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BizNotificationCell.swift; sourceTree = ""; }; DCFC72032862237400D6B293 /* Asserts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asserts.swift; sourceTree = ""; }; DCFD079026D84A380020DD8E /* HorizontalActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalActivity.swift; sourceTree = ""; }; F4AED296257A50CD009485C1 /* LogsConfigurationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogsConfigurationView.swift; sourceTree = ""; }; @@ -1015,6 +1018,7 @@ DC355E242A45FDD3008E8A8E /* NoticeMonitor.swift */, DC355E1E2A44A235008E8A8E /* NotificationCell.swift */, DC355E202A44D838008E8A8E /* NotificationsView.swift */, + DCFBC5582AE2CFEF00E3A418 /* BizNotificationCell.swift */, ); path = notifications; sourceTree = ""; @@ -1753,6 +1757,7 @@ DC27E4C42791C58C00C777CC /* PaymentsBackupView.swift in Sources */, DC59377127516297003B4B53 /* Sequence+Sum.swift in Sources */, DCA125752A27EDDB00DA2F7F /* MempoolRecommendedResponse.swift in Sources */, + DCFBC5592AE2CFEF00E3A418 /* BizNotificationCell.swift in Sources */, DC46CB1228D9AAB000C4EAC7 /* LockState.swift in Sources */, DC2F431427B6972C0006FCC4 /* SwapInView.swift in Sources */, DC71E7352728A5720063613D /* KotlinIdentifiable.swift in Sources */, @@ -1786,6 +1791,7 @@ DC641C7328208B7F00862DCD /* UserDefaults+Codable.swift in Sources */, DCA6DED1282ABA930073C658 /* KeychainConstants.swift in Sources */, DCEB2796282D7AAB0096B87E /* KotlinTypes.swift in Sources */, + DC9130A02AE045FA00F9B8C6 /* Sequence+Sum.swift in Sources */, DCB511CA281AED58001BC525 /* NotificationService.swift in Sources */, DCA6DEC82829C3150073C658 /* GenericPasswordStore.swift in Sources */, DCA6DECD282AB10C0073C658 /* SharedSecurity.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 999fc0d5b..942e3c1cc 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -1985,6 +1985,9 @@ } } } + }, + "A deposit will expire soon." : { + }, "A fee of %@ (≈ %@) may be needed to receive this payment." : { "localizations" : { @@ -2858,6 +2861,7 @@ } }, "An on-chain wallet derived from your seed.\n\nThe swap-in wallet is a bridge to Lightning. Funds on this wallet will automatically be moved to Lightning according to your liquidity policy setting." : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -3388,8 +3392,12 @@ } } } + }, + "Background payments disabled." : { + }, "Background payments disabled. " : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -4184,6 +4192,9 @@ } } } + }, + "Cancelled Funds" : { + }, "capacity" : { "localizations" : { @@ -4466,6 +4477,7 @@ } }, "Check it " : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -7730,6 +7742,7 @@ } }, "Error: invalid hash" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -8489,6 +8502,7 @@ } }, "Fix " : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -8839,6 +8853,9 @@ } } } + }, + "Funds not swapped **after 4 months** are recoverable on-chain" : { + }, "Funds sent" : { "localizations" : { @@ -10618,6 +10635,7 @@ } }, "Last Attempt" : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -10632,6 +10650,9 @@ } } } + }, + "Last attempt failed" : { + }, "layer 1 -> 2" : { "comment" : "Transaction Info: Explanation", @@ -10752,6 +10773,7 @@ } }, "Let's go " : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12045,6 +12067,7 @@ } }, "More info " : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -15720,6 +15743,7 @@ } }, "See how Phoenix is affected" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -18405,6 +18429,9 @@ } } } + }, + "The swap-in wallet is a bridge to Lightning. Funds on this wallet will automatically be moved to Lightning according to your liquidity policy setting." : { + }, "The transaction may take 30 minutes or more to confirm on the bitcoin blockchain" : { "localizations" : { @@ -18475,6 +18502,15 @@ } } } + }, + "These funds were not swapped in time. Use the wallet's descriptor to access them." : { + + }, + "These funds will be available from %lld days onwards." : { + + }, + "These funds will be available from 1 day onwards." : { + }, "This address is derived from your seed and belongs to you." : { "localizations" : { @@ -18823,6 +18859,12 @@ } } } + }, + "This swap will expire in %lld days" : { + + }, + "This swap will expire in 1 day" : { + }, "This will reset the app, as if you had just installed it." : { "localizations" : { @@ -18883,6 +18925,9 @@ } } } + }, + "Timed-Out Funds" : { + }, "timestamp" : { "comment" : "Label in DetailsView_IncomingPayment", @@ -20777,8 +20822,12 @@ } } } + }, + "Watchtower disabled." : { + }, "Watchtower disabled. " : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { diff --git a/phoenix-ios/phoenix-ios/extensions/Sequence+Sum.swift b/phoenix-ios/phoenix-ios/extensions/Sequence+Sum.swift index 39df0ed87..997f3b1d4 100644 --- a/phoenix-ios/phoenix-ios/extensions/Sequence+Sum.swift +++ b/phoenix-ios/phoenix-ios/extensions/Sequence+Sum.swift @@ -1,5 +1,5 @@ import Foundation extension Sequence where Element: AdditiveArithmetic { - func sum() -> Element { reduce(.zero, +) } + func sum() -> Element { reduce(.zero, +) } } diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift index 87eb3580a..2e100347a 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Lightning.swift @@ -26,32 +26,89 @@ extension Lightning_kmpConnection { extension Lightning_kmpWalletState.WalletWithConfirmations { var unconfirmedBalance: Bitcoin_kmpSatoshi { - let balance = unconfirmed.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + let balance = unconfirmed.map { $0.amount.toLong() }.sum() return Bitcoin_kmpSatoshi(sat: balance) } var weaklyConfirmedBalance: Bitcoin_kmpSatoshi { - let balance = weaklyConfirmed.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + let balance = weaklyConfirmed.map { $0.amount.toLong() }.sum() return Bitcoin_kmpSatoshi(sat: balance) } var deeplyConfirmedBalance: Bitcoin_kmpSatoshi { - let balance = deeplyConfirmed.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + let balance = deeplyConfirmed.map { $0.amount.toLong() }.sum() + return Bitcoin_kmpSatoshi(sat: balance) + } + + var lockedUntilRefundBalance: Bitcoin_kmpSatoshi { + let balance = lockedUntilRefund.map { $0.amount.toLong() }.sum() + return Bitcoin_kmpSatoshi(sat: balance) + } + + var readyForRefundBalance: Bitcoin_kmpSatoshi { + let balance = readyForRefund.map { $0.amount.toLong() }.sum() return Bitcoin_kmpSatoshi(sat: balance) } var anyConfirmedBalance: Bitcoin_kmpSatoshi { let anyConfirmedTx = weaklyConfirmed + deeplyConfirmed - let balance = anyConfirmedTx.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + let balance = anyConfirmedTx.map { $0.amount.toLong() }.sum() return Bitcoin_kmpSatoshi(sat: balance) } var totalBalance: Bitcoin_kmpSatoshi { let allTx = unconfirmed + weaklyConfirmed + deeplyConfirmed - let balance = allTx.map { $0.amount }.reduce(Int64(0)) { $0 + $1.toLong() } + let balance = allTx.map { $0.amount.toLong() }.sum() + return Bitcoin_kmpSatoshi(sat: balance) + } + + /// The `deeplyConfirmed` property contains UTXO's that are also represented in + /// `lockedUntilRefund` & `readyForRefund`. This property is a subset of + /// `deeplyConfirmed` that excludes those 2 categories. + /// + var readyForSwap: [Lightning_kmpWalletState.Utxo] { + let timedOut = Set(self.lockedUntilRefund + self.readyForRefund) + return deeplyConfirmed.filter { + !timedOut.contains($0) + } + } + + var readyForSwapBalance: Bitcoin_kmpSatoshi { + let balance = readyForSwap.map { $0.amount.toLong() }.sum() return Bitcoin_kmpSatoshi(sat: balance) } + /// Returns non-nil if any "ready for swap" UTXO's have an expiration date that + /// is less than 30 days away. + func expirationWarningInDays() -> Int? { + + let maxConfirmations = swapInParams.maxConfirmations + let remainingConfirmationsList = readyForSwap.map { + maxConfirmations - confirmations(utxo: $0) + } + + if let minRemainingConfirmations = remainingConfirmationsList.min() { + let days: Double = Double(minRemainingConfirmations) / 144.0 + let result = Int(days.rounded(.awayFromZero)) + if result < 30 { + return result + } + } + + return nil + } + + #if DEBUG + func fakeBlockHeight(plus diff: Int32) -> Lightning_kmpWalletState.WalletWithConfirmations { + + return Lightning_kmpWalletState.WalletWithConfirmations( + swapInParams: self.swapInParams, + currentBlockHeight: self.currentBlockHeight + diff, + all: self.all + ) + } + #endif + static func empty() -> Lightning_kmpWalletState.WalletWithConfirmations { return Lightning_kmpWalletState.WalletWithConfirmations( swapInParams: LightningExposureKt.defaultSwapInParams(), diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index 1fc2046cb..9ce3e1e0a 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -457,6 +457,7 @@ fileprivate struct ConfigurationList: View { case .backgroundPayments : newNavLinkTag = .PaymentOptions ; delay *= 2 case .liquiditySettings : newNavLinkTag = .ChannelManagement ; delay *= 1 case .forceCloseChannels : newNavLinkTag = .ForceCloseChannels ; delay *= 1 + case .swapInWallet : newNavLinkTag = .WalletInfo ; delay *= 2 } if let newNavLinkTag = newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift index f7dabb8be..4c9f1f2b3 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/SwapInWalletDetails.swift @@ -29,25 +29,8 @@ struct SwapInWalletDetails: View { let swapInRejectedPublisher = Biz.swapInRejectedPublisher @State var swapInRejected: Lightning_kmpLiquidityEventsRejected? = nil - let bizNotificationsPublisher = Biz.business.notificationsManager.notificationsPublisher() - @State var bizNotifications: [PhoenixShared.NotificationsManager.NotificationItem] = [] - @State var blockchainExplorerTxid: String? = nil - enum NavBarButtonWidth: Preference {} - let navBarButtonWidthReader = GeometryPreferenceReader( - key: AppendValue.self, - value: { [$0.size.width] } - ) - @State var navBarButtonWidth: CGFloat? = nil - - enum IconWidth: Preference {} - let iconWidthReader = GeometryPreferenceReader( - key: AppendValue.self, - value: { [$0.size.width] } - ) - @State var iconWidth: CGFloat? = nil - @Environment(\.presentationMode) var presentationMode: Binding @EnvironmentObject var popoverState: PopoverState @@ -67,6 +50,7 @@ struct SwapInWalletDetails: View { } .navigationTitle(NSLocalizedString("Swap-in wallet", comment: "Navigation Bar Title")) .navigationBarTitleDisplayMode(.inline) + .onAppear { onAppear() } } @ViewBuilder @@ -78,9 +62,8 @@ struct SwapInWalletDetails: View { Image(systemName: "xmark") .imageScale(.medium) .font(.title3) - .foregroundColor(.clear) + .foregroundColor(.clear) // invisible .accessibilityHidden(true) - .frame(width: navBarButtonWidth) Spacer(minLength: 0) Text("Swap-in wallet") @@ -92,16 +75,13 @@ struct SwapInWalletDetails: View { Button { closePopover() } label: { - Image(systemName: "xmark") // must match size of chevron.backward above + Image(systemName: "xmark") .imageScale(.medium) .font(.title3) } - .read(navBarButtonWidthReader) - .frame(width: navBarButtonWidth) } // .padding() - .assignMaxPreference(for: navBarButtonWidthReader.key, to: $navBarButtonWidth) } } @@ -110,9 +90,16 @@ struct SwapInWalletDetails: View { List { section_info() - section_lastAttempt() + if hasUnconfirmedUtxos() { + section_unconfirmed() + } section_confirmed() - section_unconfirmed() + if hasTimedOutUtxos() { + section_timedOut() + } + if hasCancelledUtxos() { + section_cancelled() + } } .listStyle(.insetGrouped) .listBackgroundColor(.primaryBackground) @@ -125,9 +112,6 @@ struct SwapInWalletDetails: View { .onReceive(swapInRejectedPublisher) { swapInRejectedStateChange($0) } - .onReceive(bizNotificationsPublisher) { - bizNotificationsChanged($0) - } } @ViewBuilder @@ -135,7 +119,7 @@ struct SwapInWalletDetails: View { Section { - VStack(alignment: HorizontalAlignment.leading, spacing: 20) { + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { if !liquidityPolicy.enabled { @@ -170,13 +154,16 @@ struct SwapInWalletDetails: View { } } + Text("Funds not swapped **after 4 months** are recoverable on-chain") + .font(.callout) + .foregroundColor(.secondary) + .padding(.bottom, 10) + Button { navigateToLiquiditySettings() } label: { HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 5) { Image(systemName: "gearshape.fill") - .frame(minWidth: iconWidth, alignment: Alignment.leadingFirstTextBaseline) - .read(iconWidthReader) Text("Configure fee settings") } } @@ -185,53 +172,6 @@ struct SwapInWalletDetails: View { .padding(.bottom, 5) } // - .assignMaxPreference(for: iconWidthReader.key, to: $iconWidth) - } - - @ViewBuilder - func section_lastAttempt() -> some View { - - if liquidityPolicy.enabled, let notification = paymentRejectedNotification() { - - Section { - - switch notification { - case .Left(let reason): - - let actualFee = Utils.formatBitcoin(currencyPrefs, msat: reason.fee) - let maxAllowedFee = Utils.formatBitcoin(currencyPrefs, sat: reason.maxAbsoluteFee) - - Text("The fee was **\(actualFee.string)** but your max fee was set to **\(maxAllowedFee.string)**.") - - case .Right(let reason): - - let actualFee = Utils.formatBitcoin(currencyPrefs, msat: reason.fee) - let percent = basisPointsAsPercent(reason.maxRelativeFeeBasisPoints) - - Text("The fee was **\(actualFee.string)** which is more than **\(percent)** of the amount.") - - } // - - } header: { - Text("Last Attempt") - - } // - } - } - - @ViewBuilder - func section_confirmed() -> some View { - - Section { - - let confirmed = confirmedBalance() - Text(verbatim: "\(confirmed.0.string)") + - Text(verbatim: " ≈ \(confirmed.1.string)").foregroundColor(.secondary) - - } header: { - Text("Ready For Swap") - - } // } @ViewBuilder @@ -303,6 +243,119 @@ struct SwapInWalletDetails: View { } // } + @ViewBuilder + func section_confirmed() -> some View { + + Section { + + let (btcAmt, fiatAmt) = confirmedBalance() + Text(verbatim: "\(btcAmt.string)") + + Text(verbatim: " ≈ \(fiatAmt.string)").foregroundColor(.secondary) + + subsection_confirmed_lastAttempt() + subsection_confirmed_expirationWarning() + + } header: { + Text("Ready For Swap") + + } // + } + + @ViewBuilder + func subsection_confirmed_lastAttempt() -> some View { + + if liquidityPolicy.enabled, let lastRejection = swapInRejected { + + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + + Text("Last attempt failed") + .foregroundColor(.appWarn) + + if let reason = lastRejection.reason as? + Lightning_kmpLiquidityEventsRejected.ReasonTooExpensiveOverAbsoluteFee + { + let actualFee = Utils.formatBitcoin(currencyPrefs, msat: lastRejection.fee) + let maxAllowedFee = Utils.formatBitcoin(currencyPrefs, sat: reason.maxAbsoluteFee) + + Text("The fee was **\(actualFee.string)** but your max fee was set to **\(maxAllowedFee.string)**.") + + } else if let reason = lastRejection.reason as? + Lightning_kmpLiquidityEventsRejected.ReasonTooExpensiveOverRelativeFee + { + let actualFee = Utils.formatBitcoin(currencyPrefs, msat: lastRejection.fee) + let percent = basisPointsAsPercent(reason.maxRelativeFeeBasisPoints) + + Text("The fee was **\(actualFee.string)** which is more than **\(percent)** of the amount.") + } + + } // + } + } + + @ViewBuilder + func subsection_confirmed_expirationWarning() -> some View { + + if let days = swapInWallet.expirationWarningInDays() { + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 5) { + Image(systemName: "exclamationmark.triangle") + if days == 1 { + Text("This swap will expire in 1 day") + } else { + Text("This swap will expire in \(days) days") + } + } // + .foregroundColor(.appNegative) + + } // + } + + @ViewBuilder + func section_timedOut() -> some View { + + Section { + + let (btcAmt, fiatAmt) = timedOutBalance() + Text(verbatim: "\(btcAmt.string)") + + Text(verbatim: " ≈ \(fiatAmt.string)").foregroundColor(.secondary) + + if let days = nextRefundInDays() { + Group { + if days <= 1 { + Text("These funds will be available from 1 day onwards.") + } else { + Text("These funds will be available from \(days) days onwards.") + } + } + .font(.callout) + .foregroundColor(.secondary) + } + + } header: { + Text("Timed-Out Funds") + + } // + } + + @ViewBuilder + func section_cancelled() -> some View { + + Section { + + let (btcAmt, fiatAmt) = cancelledBalance() + Text(verbatim: "\(btcAmt.string)") + + Text(verbatim: " ≈ \(fiatAmt.string)").foregroundColor(.secondary) + + Text("These funds were not swapped in time. Use the wallet's descriptor to access them.") + .font(.callout) + .foregroundColor(.secondary) + + } header: { + Text("Cancelled Funds") + + } // + } + // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- @@ -328,27 +381,6 @@ struct SwapInWalletDetails: View { return (formatted, false) } - func paymentRejectedNotification( - ) -> Either< - PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee, - PhoenixShared.Notification.PaymentRejected.OverRelativeFee - >? { - - let paymentRejected = bizNotifications - .compactMap { $0.notification as? PhoenixShared.Notification.PaymentRejected } - .filter { $0.source == Lightning_kmpLiquidityEventsSource.onchainwallet } - .first - - if let overAbsoluteFee = paymentRejected as? PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee { - return Either.Left(overAbsoluteFee) - } - if let overRelativeFee = paymentRejected as? PhoenixShared.Notification.PaymentRejected.OverRelativeFee { - return Either.Right(overRelativeFee) - } - - return nil - } - func basisPointsAsPercent(_ basisPoints: Int32) -> String { // Example: 30% == 3,000 basis points @@ -366,14 +398,19 @@ struct SwapInWalletDetails: View { return formatter.string(from: NSNumber(value: percent)) ?? "?%" } - func confirmedBalance() -> (FormattedAmount, FormattedAmount) { + func hasUnconfirmedUtxos() -> Bool { - let confirmed = swapInWallet.deeplyConfirmedBalance + return !swapInWallet.weaklyConfirmed.isEmpty || !swapInWallet.unconfirmed.isEmpty + } + + func hasTimedOutUtxos() -> Bool { - let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: confirmed) - let fiatAmt = Utils.formatFiat(currencyPrefs, sat: confirmed) + return !swapInWallet.lockedUntilRefund.isEmpty + } + + func hasCancelledUtxos() -> Bool { - return (btcAmt, fiatAmt) + return !swapInWallet.readyForRefund.isEmpty } func unconfirmedUtxos() -> [UtxoWrapper] { @@ -391,6 +428,24 @@ struct SwapInWalletDetails: View { return wrappedUtxos } + func confirmedBalance() -> (FormattedAmount, FormattedAmount) { + + let sats = swapInWallet.readyForSwapBalance + return formattedBalances(sats) + } + + func timedOutBalance() -> (FormattedAmount, FormattedAmount) { + + let sats = swapInWallet.lockedUntilRefundBalance + return formattedBalances(sats) + } + + func cancelledBalance() -> (FormattedAmount, FormattedAmount) { + + let sats = swapInWallet.readyForRefundBalance + return formattedBalances(sats) + } + func formattedBalances(_ sats: Bitcoin_kmpSatoshi) -> (FormattedAmount, FormattedAmount) { let btcAmt = Utils.formatBitcoin(currencyPrefs, sat: sats) @@ -399,6 +454,24 @@ struct SwapInWalletDetails: View { return (btcAmt, fiatAmt) } + /// Returns non-nil if there are any "timed-out funds" UTXO's. + /// The value represents the oldest UTXO in the category, which will be available to redeem next. + /// The value is in days (rounded up). + func nextRefundInDays() -> Int? { + + let confirmationsNeeded = swapInWallet.swapInParams.refundDelay + let pendingConfirmationsList = swapInWallet.lockedUntilRefund.map { + confirmationsNeeded - swapInWallet.confirmations(utxo: $0) + } + + if let minPendingConfirmations = pendingConfirmationsList.min() { + let days: Double = Double(minPendingConfirmations) / 144.0 + return Int(days.rounded(.awayFromZero)) + } + + return nil + } + func confirmationDialogBinding() -> Binding { return Binding( // SwiftUI only allows for 1 ".sheet" @@ -411,6 +484,12 @@ struct SwapInWalletDetails: View { // MARK: Notifications // -------------------------------------------------- + func onAppear() { + log.trace("onAppear()") + + // Reserved... + } + func liquidityPolicyChanged(_ newValue: LiquidityPolicy) { log.trace("liquidityPolicyChanged()") @@ -420,7 +499,14 @@ struct SwapInWalletDetails: View { func swapInWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { log.trace("swapInWalletChanged()") + #if DEBUG + // swapInWallet = newValue.fakeBlockHeight(plus: Int32(144 * 31 * 3)) // 3 months: test expirationWarning + // swapInWallet = newValue.fakeBlockHeight(plus: Int32(144 * 30 * 4)) // 4 months: test lockedUntilRefund + // swapInWallet = newValue.fakeBlockHeight(plus: Int32(144 * 30 * 6)) // 6 months: test readyForRefund + swapInWallet = newValue + #else swapInWallet = newValue + #endif } func swapInRejectedStateChange(_ state: Lightning_kmpLiquidityEventsRejected?) { @@ -429,15 +515,6 @@ struct SwapInWalletDetails: View { swapInRejected = state } - func bizNotificationsChanged(_ list: [PhoenixShared.NotificationsManager.NotificationItem]) { - log.trace("bizNotificationsChanges()") - - if !list.isEmpty { - log.debug("list = \(list)") - } - bizNotifications = list - } - // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- diff --git a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift index 15cbc512d..1785c2f47 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/advanced/wallet/WalletInfoView.swift @@ -12,11 +12,18 @@ fileprivate var log = Logger( fileprivate var log = Logger(OSLog.disabled) #endif +fileprivate enum NavLinkTag: String { + case SwapInWalletDetails + case FinalWalletDetails +} + struct WalletInfoView: View { let popTo: (PopToDestination) -> Void + @State private var navLinkTag: NavLinkTag? = nil + @State var didAppear = false @State var popToDestination: PopToDestination? = nil @@ -31,12 +38,16 @@ struct WalletInfoView: View { @State var finalWallet = Biz.business.peerManager.finalWalletValue() let finalWalletPublisher = Biz.business.peerManager.finalWalletPublisher() + @State private var swiftUiBugWorkaround: NavLinkTag? = nil + @State private var swiftUiBugWorkaroundIdx = 0 + @StateObject var toast = Toast() @Environment(\.colorScheme) var colorScheme: ColorScheme @Environment(\.presentationMode) var presentationMode: Binding @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var deepLinkManager: DeepLinkManager // -------------------------------------------------- // MARK: View Builders @@ -66,6 +77,12 @@ struct WalletInfoView: View { .onAppear() { onAppear() } + .onChange(of: deepLinkManager.deepLink) { + deepLinkChanged($0) + } + .onChange(of: navLinkTag) { + navLinkTagChanged($0) + } .onReceive(swapInWalletPublisher) { swapInWalletChanged($0) } @@ -115,7 +132,7 @@ struct WalletInfoView: View { Section { - NavigationLink(destination: SwapInWalletDetails(location: .embedded, popTo: popToWrapper)) { + navLink(.SwapInWalletDetails) { subsection_swapInWallet_balance() } subsection_swapInWallet_descriptor() @@ -142,8 +159,6 @@ struct WalletInfoView: View { InfoPopoverWindow { Text( """ - An on-chain wallet derived from your seed. - The swap-in wallet is a bridge to Lightning. \ Funds on this wallet will automatically be moved to Lightning \ according to your liquidity policy setting. @@ -199,7 +214,7 @@ struct WalletInfoView: View { Section { - NavigationLink(destination: FinalWalletDetails()) { + navLink(.FinalWalletDetails) { subsection_finalWallet_balance() } subsection_finalWallet_masterPublicKey() @@ -318,82 +333,11 @@ struct WalletInfoView: View { unconfirmed : Bitcoin_kmpSatoshi ) -> some View { - let hasPositiveConfirmed = confirmed.sat > 0 - let hasPositiveUnconfirmed = unconfirmed.sat > 0 + let total = confirmed.sat + unconfirmed.sat + let (btcAmt, fiatAmt) = formattedBalances(total) - if hasPositiveConfirmed && hasPositiveUnconfirmed { - - /* This looks a bit crowded... - I think it looks cleaner if we simply display the total in this scenario. - If the user wants more information, there's the SwapInWalletDetails screen. - - if #available(iOS 16, *) { - Grid(horizontalSpacing: 8, verticalSpacing: 12) { - GridRow(alignment: VerticalAlignment.firstTextBaseline) { - Text("confirmed") - .textCase(.lowercase) - .font(.subheadline) - .foregroundColor(.secondary) - .gridColumnAlignment(.trailing) - - Text(verbatim: confirmed.0.string) + - Text(verbatim: " ≈ \(confirmed.1.string)").foregroundColor(.secondary) - } - GridRow(alignment: VerticalAlignment.firstTextBaseline) { - Text("unconfirmed") - .textCase(.lowercase) - .font(.subheadline) - .foregroundColor(.secondary) - .gridColumnAlignment(.trailing) - - Text(verbatim: unconfirmed.0.string) + - Text(verbatim: " ≈ \(unconfirmed.1.string)").foregroundColor(.secondary) - } - } // - } else { - VStack(alignment: HorizontalAlignment.leading, spacing: 12) { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 8) { - Text("confirmed") - .textCase(.lowercase) - .font(.subheadline) - .foregroundColor(.secondary) - - Text(verbatim: confirmed.0.string) + - Text(verbatim: " ≈ \(confirmed.1.string)").foregroundColor(.secondary) - } - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 8) { - Text("unconfirmed") - .textCase(.lowercase) - .font(.subheadline) - .foregroundColor(.secondary) - - Text(verbatim: unconfirmed.0.string) + - Text(verbatim: " ≈ \(unconfirmed.1.string)").foregroundColor(.secondary) - } - } // - } - */ - - let total = confirmed.sat + unconfirmed.sat - let (btcAmt, fiatAmt) = formattedBalances(total) - - Text(verbatim: "\(btcAmt.string) ") + - Text(verbatim: " ≈ \(fiatAmt.string)").foregroundColor(.secondary) - - } else if hasPositiveUnconfirmed { - - let (btcAmt, fiatAmt) = formattedBalances(unconfirmed) - - Text(verbatim: "\(btcAmt.string) ") + - Text(verbatim: " ≈ \(fiatAmt.string)").foregroundColor(.secondary) - - } else { - - let (btcAmt, fiatAmt) = formattedBalances(confirmed) - - Text(verbatim: btcAmt.string) + - Text(verbatim: " ≈ \(fiatAmt.string)").foregroundColor(.secondary) - } + Text(verbatim: "\(btcAmt.string) ") + + Text(verbatim: " ≈ \(fiatAmt.string)").foregroundColor(.secondary) } @ViewBuilder @@ -415,6 +359,29 @@ struct WalletInfoView: View { .accessibilityHidden(true) } + @ViewBuilder + private func navLink( + _ tag: NavLinkTag, + label: () -> Content + ) -> some View where Content: View { + + NavigationLink( + destination: navLinkView(tag), + tag: tag, + selection: $navLinkTag, + label: label + ) + } + + @ViewBuilder + private func navLinkView(_ tag: NavLinkTag) -> some View { + + switch tag { + case .SwapInWalletDetails : SwapInWalletDetails(location: .embedded, popTo: popToWrapper) + case .FinalWalletDetails : FinalWalletDetails() + } + } + // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- @@ -473,6 +440,12 @@ struct WalletInfoView: View { if !didAppear { didAppear = true + if let deepLink = deepLinkManager.deepLink { + DispatchQueue.main.async { // iOS 14 issues workaround + deepLinkChanged(deepLink) + } + } + } else { if let destination = popToDestination { @@ -488,6 +461,60 @@ struct WalletInfoView: View { // MARK: Notifications // -------------------------------------------------- + func deepLinkChanged(_ value: DeepLink?) { + log.trace("deepLinkChanged() => \(value?.rawValue ?? "nil")") + + // This is a hack, courtesy of bugs in Apple's NavigationLink: + // https://developer.apple.com/forums/thread/677333 + // + // Summary: + // There's some quirky code in SwiftUI that is resetting our navLinkTag. + // Several bizarre workarounds have been proposed. + // I've tried every one of them, and none of them work (at least, without bad side-effects). + // + // The only clean solution I've found is to listen for SwiftUI's bad behaviour, + // and forcibly undo it. + + if let value = value { + + // Navigate towards deep link (if needed) + var newNavLinkTag: NavLinkTag? = nil + switch value { + case .paymentHistory : break + case .backup : break + case .drainWallet : break + case .electrum : break + case .backgroundPayments : break + case .liquiditySettings : break + case .forceCloseChannels : break + case .swapInWallet : newNavLinkTag = NavLinkTag.SwapInWalletDetails + } + + if let newNavLinkTag = newNavLinkTag { + + self.swiftUiBugWorkaround = newNavLinkTag + self.swiftUiBugWorkaroundIdx += 1 + clearSwiftUiBugWorkaround(delay: 1.5) + + self.navLinkTag = newNavLinkTag // Trigger/push the view + } + + } else { + // We reached the final destination of the deep link + clearSwiftUiBugWorkaround(delay: 0.0) + } + } + + fileprivate func navLinkTagChanged(_ tag: NavLinkTag?) { + log.trace("navLinkTagChanged() => \(tag?.rawValue ?? "nil")") + + if tag == nil, let forcedNavLinkTag = swiftUiBugWorkaround { + + log.debug("Blocking SwiftUI's attempt to reset our navLinkTag") + self.navLinkTag = forcedNavLinkTag + } + } + func swapInWalletChanged(_ newValue: Lightning_kmpWalletState.WalletWithConfirmations) { log.trace("swapInWalletChanged()") @@ -513,4 +540,21 @@ struct WalletInfoView: View { colorScheme: colorScheme.opposite ) } + + // -------------------------------------------------- + // MARK: Workarounds + // -------------------------------------------------- + + func clearSwiftUiBugWorkaround(delay: TimeInterval) { + + let idx = self.swiftUiBugWorkaroundIdx + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + + if self.swiftUiBugWorkaroundIdx == idx { + log.trace("swiftUiBugWorkaround = nil") + self.swiftUiBugWorkaround = nil + } + } + } } diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift index 787c4cd74..71eb71400 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift @@ -284,6 +284,7 @@ fileprivate struct PaymentOptionsList: View { case .backgroundPayments : newNavLinkTag = NavLinkTag.BackgroundPaymentsSelector case .liquiditySettings : break case .forceCloseChannels : break + case .swapInWallet : break } if let newNavLinkTag = newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift index feac77ee2..eac59200a 100644 --- a/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift +++ b/phoenix-ios/phoenix-ios/views/environment/DeepLink.swift @@ -19,6 +19,7 @@ enum DeepLink: String, Equatable { case backgroundPayments case liquiditySettings case forceCloseChannels + case swapInWallet } class DeepLinkManager: ObservableObject { diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index 45ed6ca10..2dc95c653 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -390,45 +390,74 @@ struct HomeView : MVIView { .onTapGesture { openNotificationsSheet() } - } @ViewBuilder func notice_other_single() -> some View { - NoticeBox { - notice_other_content(isSingle: true) + if noticeMonitor.hasNotice { + NoticeBox { + notice_other_content(isSingle: true) + } + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { + if noticeMonitor.hasNotice_backupSeed { + navigateToBackup() + } else if noticeMonitor.hasNotice_electrumServer { + navigationToElecrumServer() + } else if noticeMonitor.hasNotice_swapInExpiration { + showSwapInWallet() + } else if noticeMonitor.hasNotice_backgroundPayments { + navigationToBackgroundPayments() + } else if noticeMonitor.hasNotice_watchTower { + fixBackgroundAppRefreshDisabled() + } else if noticeMonitor.hasNotice_mempoolFull { + openMempoolFullURL() + } + } + + } else { + NoticeBox { + notice_other_content(isSingle: true) + } } } @ViewBuilder func notice_other_content(isSingle: Bool) -> some View { - Group { - if noticeMonitor.hasNotice_backupSeed { - NotificationCell.backupSeed(action: isSingle ? navigateToBackup : nil) - - } else if noticeMonitor.hasNotice_electrumServer { - NotificationCell.electrumServer(action: isSingle ? navigationToElecrumServer : nil) - - } else if noticeMonitor.hasNotice_backgroundPayments { - NotificationCell.backgroundPayments(action: isSingle ? navigationToBackgroundPayments : nil) - - } else if noticeMonitor.hasNotice_watchTower { - NotificationCell.watchTower(action: isSingle ? fixBackgroundAppRefreshDisabled : nil) - - } else if noticeMonitor.hasNotice_mempoolFull { - NotificationCell.mempoolFull(action: isSingle ? openMempoolFullURL : nil) - - } else if let item = bizNotifications_watchtower.first { - let location = isSingle ? - BizNotificationCell.Location.HomeView_Single(preAction: {}) - : BizNotificationCell.Location.HomeView_Multiple - - BizNotificationCell(item: item, location: location) - } + if noticeMonitor.hasNotice_backupSeed { + NotificationCell.backupSeed() + .font(.footnote) + + } else if noticeMonitor.hasNotice_electrumServer { + NotificationCell.electrumServer() + .font(.footnote) + + } else if noticeMonitor.hasNotice_swapInExpiration { + NotificationCell.swapInExpiration() + .font(.footnote) + + } else if noticeMonitor.hasNotice_backgroundPayments { + NotificationCell.backgroundPayments() + .font(.footnote) + + } else if noticeMonitor.hasNotice_watchTower { + NotificationCell.watchTower() + .font(.footnote) + + } else if noticeMonitor.hasNotice_mempoolFull { + NotificationCell.mempoolFull() + .font(.footnote) + + } else if let item = bizNotifications_watchtower.first { + let location = isSingle ? + BizNotificationCell.Location.HomeView_Single(preAction: {}) + : BizNotificationCell.Location.HomeView_Multiple + + BizNotificationCell(item: item, location: location) + .font(.caption) } - .font(.caption) } @ViewBuilder @@ -616,6 +645,7 @@ struct HomeView : MVIView { var count = 0 if noticeMonitor.hasNotice_backupSeed { count += 1 } if noticeMonitor.hasNotice_electrumServer { count += 1 } + if noticeMonitor.hasNotice_swapInExpiration { count += 1 } if noticeMonitor.hasNotice_mempoolFull { count += 1 } if noticeMonitor.hasNotice_backgroundPayments { count += 1 } if noticeMonitor.hasNotice_watchTower { count += 1 } diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift index e868b9c9d..66b83283b 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Big.swift @@ -616,6 +616,7 @@ struct MainView_Big: View { case .backgroundPayments : showSettings() case .liquiditySettings : showSettings() case .forceCloseChannels : showSettings() + case .swapInWallet : showSettings() } } } diff --git a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift index 26d0fd477..230c764fd 100644 --- a/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift +++ b/phoenix-ios/phoenix-ios/views/main/MainView_Small.swift @@ -540,6 +540,7 @@ struct MainView_Small: View { case .backgroundPayments : newNavLinkTag = .ConfigurationView ; delay *= 3 case .liquiditySettings : newNavLinkTag = .ConfigurationView ; delay *= 3 case .forceCloseChannels : newNavLinkTag = .ConfigurationView ; delay *= 2 + case .swapInWallet : newNavLinkTag = .ConfigurationView ; delay *= 2 } if let newNavLinkTag = newNavLinkTag { diff --git a/phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift b/phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift new file mode 100644 index 000000000..5f9d42f07 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/notifications/BizNotificationCell.swift @@ -0,0 +1,385 @@ +import SwiftUI +import PhoenixShared +import os.log + +#if DEBUG && true +fileprivate var log = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "BizNotificationCell" +) +#else +fileprivate var log = Logger(OSLog.disabled) +#endif + + +struct BizNotificationCell: View { + + enum Location { + case HomeView_Single(preAction: ()->Void) + case HomeView_Multiple + case NotificationsView(preAction: ()->Void) + } + + let item: PhoenixShared.NotificationsManager.NotificationItem + let location: Location + + @EnvironmentObject var currencyPrefs: CurrencyPrefs + @EnvironmentObject var deepLinkManager: DeepLinkManager + + @ViewBuilder + var body: some View { + if let reason = item.notification as? PhoenixShared.Notification.PaymentRejected { + + if let reason = reason as? PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee { + body_paymentRejected_overFee(Either.Left(reason)) + + } else if let reason = reason as? PhoenixShared.Notification.PaymentRejected.OverRelativeFee { + body_paymentRejected_overFee(Either.Right(reason)) + + } else { + body_paymentRejected(reason) + } + + } else if let reason = item.notification as? PhoenixShared.WatchTowerOutcome { + + if let reason = reason as? PhoenixShared.WatchTowerOutcome.Nominal { + body_watchTowerSuccess(reason) + + } else if let reason = reason as? PhoenixShared.WatchTowerOutcome.RevokedFound { + body_watchTowerRevoked(reason) + + } else if let reason = reason as? PhoenixShared.WatchTowerOutcome.Unknown { + body_watchTowerUnknown(reason) + } + + } else { + EmptyView() + } + } + + @ViewBuilder + func body_paymentRejected_overFee( + _ either: Either< + PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee, + PhoenixShared.Notification.PaymentRejected.OverRelativeFee + > + ) -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + // Title + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Group { + let amt = Utils.formatBitcoin(currencyPrefs, msat: amount(either)) + if isOnChain(either) { + Text("On-chain funds pending (+\(amt.string))") + } else { + Text("Payment rejected (+\(amt.string))") + } + } + .font(.headline) + + if isDismissable() { + Spacer(minLength: 0) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } // + + switch either { + case .Left(let reason): + + let actualFee = Utils.formatBitcoin(currencyPrefs, msat: reason.fee) + let maxAllowedFee = Utils.formatBitcoin(currencyPrefs, sat: reason.maxAbsoluteFee) + + Text("The fee was \(actualFee.string) but your max fee was set to \(maxAllowedFee.string).") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) + + case .Right(let reason): + + let actualFee = Utils.formatBitcoin(currencyPrefs, msat: reason.fee) + let percent = basisPointsAsPercent(reason.maxRelativeFeeBasisPoints) + + Text("The fee was \(actualFee.string) which is more than \(percent) of the amount.") + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) + + } // + + body_paymentRejected_footer() + + } // + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilitySortPriority(47) + } + + @ViewBuilder + func body_paymentRejected( + _ reason: PhoenixShared.Notification.PaymentRejected + ) -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + // Title + HStack(alignment: VerticalAlignment.center, spacing: 0) { + let amt = Utils.formatBitcoin(currencyPrefs, msat: reason.amount) + Text("Payment rejected (+\(amt.string))") + .font(.headline) + + if isDismissable() { + Spacer(minLength: 0) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } // + + Group { + if reason is PhoenixShared.Notification.PaymentRejected.FeePolicyDisabled { + Text("Automated incoming liquidity is disabled in your incoming fee settings.") + + } else if reason is PhoenixShared.Notification.PaymentRejected.ChannelsInitializing { + Text("Channels initializing...") + + } else { + Text("Unknown reason.") + } + } + .padding(.top, 10) + + body_paymentRejected_footer() + + } // + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilitySortPriority(47) + } + + @ViewBuilder + func body_paymentRejected_footer() -> some View { + + let showAction = shouldShowAction() + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { + if showAction { + Button { + navigateToLiquiditySettings() + } label: { + Text("Check fee settings") + .font(.callout) + } + } + Spacer(minLength: 0) + Text(timestamp()) + .font(.caption) + .foregroundColor(.secondary) + } // + .padding(.top, showAction ? 15 : 5) + } + + @ViewBuilder + func body_watchTowerSuccess( + _ reason: PhoenixShared.WatchTowerOutcome.Nominal + ) -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + // Title + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Watchtower report") + .font(.headline) + + if isDismissable() { + Spacer(minLength: 0) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + + } // + + if reason.channelsWatchedCount == 1 { + Text("1 channel was successfully checked. No issues were found.") + .font(.callout) + } else { + Text("\(reason.channelsWatchedCount) channels were successfully checked. No issues were found.") + .font(.callout) + } + + } // + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilitySortPriority(47) + } + + @ViewBuilder + func body_watchTowerRevoked( + _ reason: PhoenixShared.WatchTowerOutcome.RevokedFound + ) -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + // Title + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Watchtower alert") + .font(.headline) + + if isDismissable() { + Spacer(minLength: 0) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } // + + Text("Revoked commits found on \(reason.channels.count) channel(s)!") + .font(.callout) + + Text("Contact support if needed.") + .font(.callout) + + } // + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilitySortPriority(47) + } + + @ViewBuilder + func body_watchTowerUnknown( + _ reason: PhoenixShared.WatchTowerOutcome.Unknown + ) -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + + // Title + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Watchtower unable to complete") + .font(.headline) + + if isDismissable() { + Spacer(minLength: 0) + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } // + + Text("A new attempt is scheduled in a few hours.") + .font(.callout) + + } // + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilitySortPriority(47) + } + + func isDismissable() -> Bool { + + switch location { + case .HomeView_Single : return true + case .HomeView_Multiple : return false + case .NotificationsView : return false + } + } + + func shouldShowAction() -> Bool { + + switch location { + case .HomeView_Single : return true + case .HomeView_Multiple : return false + case .NotificationsView : return true + } + } + + func timestamp() -> String { + + let date = item.notification.createdAt.toDate(from: .milliseconds) + let now = Date.now + + if now.timeIntervalSince(date) < 1.0 { + // The RelativeDateTimeFormatter will say something like "in 0 seconds", + // which is wrong in this context. + + return NSLocalizedString("just now", comment: "Timestamp for notification") + + } else { + + let formatter = RelativeDateTimeFormatter() + return formatter.localizedString(for: date, relativeTo: now) + } + } + + func amount(_ either: Either< + PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee, + PhoenixShared.Notification.PaymentRejected.OverRelativeFee + >) -> Lightning_kmpMilliSatoshi { + + switch either { + case .Left(let reason) : return reason.amount + case .Right(let reason): return reason.amount + } + } + + func isOnChain(_ either: Either< + PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee, + PhoenixShared.Notification.PaymentRejected.OverRelativeFee + >) -> Bool { + + let source: Lightning_kmpLiquidityEventsSource + switch either { + case .Left(let reason) : source = reason.source + case .Right(let reason): source = reason.source + } + + return source == Lightning_kmpLiquidityEventsSource.onchainwallet + } + + func basisPointsAsPercent(_ basisPoints: Int32) -> String { + + // Example: 30% == 3,000 basis points + // + // 3,000 / 100 => 30.0 => 3000% + // 3,000 / 100 / 100 => 0.3 => 30% + + let percent = Double(basisPoints) / Double(10_000) + + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 2 + + return formatter.string(from: NSNumber(value: percent)) ?? "?%" + } + + func navigateToLiquiditySettings() { + log.trace("navigateToLiquiditySettings()") + + switch location { + case .HomeView_Single(let preAction) : preAction() + case .HomeView_Multiple : break + case .NotificationsView(let preAction) : preAction() + } + deepLinkManager.broadcast(.liquiditySettings) + } + + func dismiss() { + log.trace("dismiss()") + + Biz.business.notificationsManager.dismissNotifications(ids: item.ids) + } +} + diff --git a/phoenix-ios/phoenix-ios/views/notifications/NoticeMonitor.swift b/phoenix-ios/phoenix-ios/views/notifications/NoticeMonitor.swift index b5e42fea3..3e1fc0abc 100644 --- a/phoenix-ios/phoenix-ios/views/notifications/NoticeMonitor.swift +++ b/phoenix-ios/phoenix-ios/views/notifications/NoticeMonitor.swift @@ -15,20 +15,20 @@ fileprivate var log = Logger(OSLog.disabled) class NoticeMonitor: ObservableObject { - @Published var isNewWallet = Prefs.shared.isNewWallet - @Published var backupSeed_enabled = Prefs.shared.backupSeed.isEnabled - @Published var manualBackup_taskDone = Prefs.shared.backupSeed.manualBackup_taskDone( + @Published private var isNewWallet = Prefs.shared.isNewWallet + @Published private var backupSeed_enabled = Prefs.shared.backupSeed.isEnabled + @Published private var manualBackup_taskDone = Prefs.shared.backupSeed.manualBackup_taskDone( encryptedNodeId: Biz.encryptedNodeId! ) - // Raw publisher for all WalletContext settings fetched from the cloud. - // See specific functions below for simple getters. E.g.: `hasNotice_mempoolFull` - @Published var walletContext: WalletContext? = nil + @Published private var walletContext: WalletContext? = nil - @Published var notificationPermissions = NotificationsManager.shared.permissions.value - @Published var bgRefreshStatus = NotificationsManager.shared.backgroundRefreshStatus.value + @Published private var swapInWallet = Biz.business.balanceManager.swapInWalletValue() - @NestedObservableObject var customElectrumServerObserver = CustomElectrumServerObserver() + @Published private var notificationPermissions = NotificationsManager.shared.permissions.value + @Published private var bgRefreshStatus = NotificationsManager.shared.backgroundRefreshStatus.value + + @NestedObservableObject private var customElectrumServerObserver = CustomElectrumServerObserver() private var cancellables = Set() @@ -60,6 +60,12 @@ class NoticeMonitor: ObservableObject { } .store(in: &cancellables) + Biz.business.balanceManager.swapInWalletPublisher() + .sink {[weak self](wallet: Lightning_kmpWalletState.WalletWithConfirmations) in + self?.swapInWallet = wallet + } + .store(in: &cancellables) + NotificationsManager.shared.permissions .sink {[weak self](permissions: NotificationPermissions) in self?.notificationPermissions = permissions @@ -73,11 +79,22 @@ class NoticeMonitor: ObservableObject { .store(in: &cancellables) } + var hasNotice: Bool { + + if hasNotice_backupSeed { return true } + if hasNotice_electrumServer { return true } + if hasNotice_swapInExpiration { return true } + if hasNotice_mempoolFull { return true } + if hasNotice_backgroundPayments { return true } + if hasNotice_watchTower { return true } + + return false + } var hasNotice_backupSeed: Bool { if isNewWallet { // It's a new wallet. We don't bug them about backing up their seed until - // they've actually done something with thie wallet. + // they've actually done something with this wallet. return false } else if !backupSeed_enabled && !manualBackup_taskDone { return true @@ -90,6 +107,10 @@ class NoticeMonitor: ObservableObject { return customElectrumServerObserver.problem == .badCertificate } + var hasNotice_swapInExpiration: Bool { + return swapInWallet.expirationWarningInDays() != nil + } + var hasNotice_mempoolFull: Bool { return walletContext?.isMempoolFull ?? false } diff --git a/phoenix-ios/phoenix-ios/views/notifications/NotificationCell.swift b/phoenix-ios/phoenix-ios/views/notifications/NotificationCell.swift index 6caa048f0..e8881a5e8 100644 --- a/phoenix-ios/phoenix-ios/views/notifications/NotificationCell.swift +++ b/phoenix-ios/phoenix-ios/views/notifications/NotificationCell.swift @@ -16,7 +16,7 @@ fileprivate var log = Logger(OSLog.disabled) class NotificationCell { @ViewBuilder - static func backupSeed(action: (() -> Void)?) -> some View { + static func backupSeed() -> some View { HStack(alignment: VerticalAlignment.top, spacing: 0) { Image(systemName: "exclamationmark.triangle") @@ -24,20 +24,7 @@ class NotificationCell { .padding(.trailing, 10) .accessibilityLabel("Warning") - VStack(alignment: HorizontalAlignment.leading, spacing: 5) { - Text("Backup your recovery phrase to prevent losing your funds.") - if let action { - Button(action: action) { - Group { - Text("Let's go ").bold() + - Text(Image(systemName: "arrowtriangle.forward")).bold() - } - .multilineTextAlignment(.leading) - .allowsTightening(true) - } - .foregroundColor(.appAccent) - } - } // + Text("Backup your recovery phrase to prevent losing your funds.") } // .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) @@ -45,7 +32,7 @@ class NotificationCell { } @ViewBuilder - static func electrumServer(action: (() -> Void)?) -> some View { + static func electrumServer() -> some View { HStack(alignment: VerticalAlignment.top, spacing: 0) { Image(systemName: "exclamationmark.shield") @@ -54,20 +41,7 @@ class NotificationCell { .accessibilityHidden(true) .accessibilityLabel("Warning") - VStack(alignment: HorizontalAlignment.leading, spacing: 5) { - Text("Custom electrum server: bad certificate !") - if let action { - Button(action: action) { - Group { - Text("Check it ").bold() + - Text(Image(systemName: "arrowtriangle.forward")).bold() - } - .multilineTextAlignment(.leading) - .allowsTightening(true) - } - .foregroundColor(.appAccent) - } - } // + Text("Custom electrum server: bad certificate !") } // .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) @@ -75,28 +49,15 @@ class NotificationCell { } @ViewBuilder - static func backgroundPayments(action: (() -> Void)?) -> some View { - + static func swapInExpiration() -> some View { HStack(alignment: VerticalAlignment.top, spacing: 0) { Image(systemName: "exclamationmark.triangle") .imageScale(.large) .padding(.trailing, 10) + .accessibilityHidden(true) .accessibilityLabel("Warning") - VStack(alignment: HorizontalAlignment.leading, spacing: 5) { - Text("Background payments disabled. ") - if let action { - Button(action: action) { - Group { - Text("Fix ").bold() + - Text(Image(systemName: "arrowtriangle.forward")).bold() - } - .multilineTextAlignment(.leading) - .allowsTightening(true) - } - .foregroundColor(.appAccent) - } - } // + Text("A deposit will expire soon.") } // .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) @@ -104,7 +65,7 @@ class NotificationCell { } @ViewBuilder - static func watchTower(action: (() -> Void)?) -> some View { + static func backgroundPayments() -> some View { HStack(alignment: VerticalAlignment.top, spacing: 0) { Image(systemName: "exclamationmark.triangle") @@ -112,20 +73,7 @@ class NotificationCell { .padding(.trailing, 10) .accessibilityLabel("Warning") - VStack(alignment: HorizontalAlignment.leading, spacing: 5) { - Text("Watchtower disabled. ") - if let action { - Button(action: action) { - Group { - Text("More info ").bold() + - Text(Image(systemName: "arrowtriangle.forward")).bold() - } - .multilineTextAlignment(.leading) - .allowsTightening(true) - } - .foregroundColor(.appAccent) - } - } // + Text("Background payments disabled.") } // .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) @@ -133,398 +81,35 @@ class NotificationCell { } @ViewBuilder - static func mempoolFull(action: (() -> Void)?) -> some View { + static func watchTower() -> some View { HStack(alignment: VerticalAlignment.top, spacing: 0) { - Image(systemName: "tray.full") + Image(systemName: "exclamationmark.triangle") .imageScale(.large) .padding(.trailing, 10) - .accessibilityHidden(true) .accessibilityLabel("Warning") - VStack(alignment: HorizontalAlignment.leading, spacing: 5) { - Text("Bitcoin mempool is full and fees are high.") - if let action { - Button(action: action) { - Text("See how Phoenix is affected").bold() - } - .foregroundColor(.appAccent) - } - } // + Text("Watchtower disabled.") } // .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) .accessibilitySortPriority(47) } -} - -struct BizNotificationCell: View { - - enum Location { - case HomeView_Single(preAction: ()->Void) - case HomeView_Multiple - case NotificationsView(preAction: ()->Void) - } - - let item: PhoenixShared.NotificationsManager.NotificationItem - let location: Location - - @EnvironmentObject var currencyPrefs: CurrencyPrefs - @EnvironmentObject var deepLinkManager: DeepLinkManager - - @ViewBuilder - var body: some View { - if let reason = item.notification as? PhoenixShared.Notification.PaymentRejected { - - if let reason = reason as? PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee { - body_paymentRejected_overFee(Either.Left(reason)) - - } else if let reason = reason as? PhoenixShared.Notification.PaymentRejected.OverRelativeFee { - body_paymentRejected_overFee(Either.Right(reason)) - - } else { - body_paymentRejected(reason) - } - - } else if let reason = item.notification as? PhoenixShared.WatchTowerOutcome { - - if let reason = reason as? PhoenixShared.WatchTowerOutcome.Nominal { - body_watchTowerSuccess(reason) - - } else if let reason = reason as? PhoenixShared.WatchTowerOutcome.RevokedFound { - body_watchTowerRevoked(reason) - - } else if let reason = reason as? PhoenixShared.WatchTowerOutcome.Unknown { - body_watchTowerUnknown(reason) - } - - } else { - EmptyView() - } - } - - @ViewBuilder - func body_paymentRejected_overFee( - _ either: Either< - PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee, - PhoenixShared.Notification.PaymentRejected.OverRelativeFee - > - ) -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - - // Title - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Group { - let amt = Utils.formatBitcoin(currencyPrefs, msat: amount(either)) - if isOnChain(either) { - Text("On-chain funds pending (+\(amt.string))") - } else { - Text("Payment rejected (+\(amt.string))") - } - } - .font(.headline) - - if isDismissable() { - Spacer(minLength: 0) - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - } - } // - - switch either { - case .Left(let reason): - - let actualFee = Utils.formatBitcoin(currencyPrefs, msat: reason.fee) - let maxAllowedFee = Utils.formatBitcoin(currencyPrefs, sat: reason.maxAbsoluteFee) - - Text("The fee was \(actualFee.string) but your max fee was set to \(maxAllowedFee.string).") - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 10) - - case .Right(let reason): - - let actualFee = Utils.formatBitcoin(currencyPrefs, msat: reason.fee) - let percent = basisPointsAsPercent(reason.maxRelativeFeeBasisPoints) - - Text("The fee was \(actualFee.string) which is more than \(percent) of the amount.") - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 10) - - } // - - body_paymentRejected_footer() - - } // - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilitySortPriority(47) - } @ViewBuilder - func body_paymentRejected( - _ reason: PhoenixShared.Notification.PaymentRejected - ) -> some View { + static func mempoolFull() -> some View { - VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - - // Title - HStack(alignment: VerticalAlignment.center, spacing: 0) { - let amt = Utils.formatBitcoin(currencyPrefs, msat: reason.amount) - Text("Payment rejected (+\(amt.string))") - .font(.headline) - - if isDismissable() { - Spacer(minLength: 0) - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - } - } // - - Group { - if reason is PhoenixShared.Notification.PaymentRejected.FeePolicyDisabled { - Text("Automated incoming liquidity is disabled in your incoming fee settings.") - - } else if reason is PhoenixShared.Notification.PaymentRejected.ChannelsInitializing { - Text("Channels initializing...") - - } else { - Text("Unknown reason.") - } - } - .padding(.top, 10) - - body_paymentRejected_footer() + HStack(alignment: VerticalAlignment.top, spacing: 0) { + Image(systemName: "tray.full") + .imageScale(.large) + .padding(.trailing, 10) + .accessibilityHidden(true) + .accessibilityLabel("Warning") - } // - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilitySortPriority(47) - } - - @ViewBuilder - func body_paymentRejected_footer() -> some View { - - let showAction = shouldShowAction() - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 4) { - if showAction { - Button { - navigateToLiquiditySettings() - } label: { - Text("Check fee settings") - .font(.callout) - } - } - Spacer(minLength: 0) - Text(timestamp()) - .font(.caption) - .foregroundColor(.secondary) + Text("Bitcoin mempool is full and fees are high.") } // - .padding(.top, showAction ? 15 : 5) - } - - @ViewBuilder - func body_watchTowerSuccess( - _ reason: PhoenixShared.WatchTowerOutcome.Nominal - ) -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - - // Title - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Watchtower report") - .font(.headline) - - if isDismissable() { - Spacer(minLength: 0) - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - } - - } // - - if reason.channelsWatchedCount == 1 { - Text("1 channel was successfully checked. No issues were found.") - .font(.callout) - } else { - Text("\(reason.channelsWatchedCount) channels were successfully checked. No issues were found.") - .font(.callout) - } - - } // .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) .accessibilitySortPriority(47) } - - @ViewBuilder - func body_watchTowerRevoked( - _ reason: PhoenixShared.WatchTowerOutcome.RevokedFound - ) -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - - // Title - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Watchtower alert") - .font(.headline) - - if isDismissable() { - Spacer(minLength: 0) - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - } - } // - - Text("Revoked commits found on \(reason.channels.count) channel(s)!") - .font(.callout) - - Text("Contact support if needed.") - .font(.callout) - - } // - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilitySortPriority(47) - } - - @ViewBuilder - func body_watchTowerUnknown( - _ reason: PhoenixShared.WatchTowerOutcome.Unknown - ) -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 10) { - - // Title - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Text("Watchtower unable to complete") - .font(.headline) - - if isDismissable() { - Spacer(minLength: 0) - Button { - dismiss() - } label: { - Image(systemName: "xmark") - } - } - } // - - Text("A new attempt is scheduled in a few hours.") - .font(.callout) - - } // - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilitySortPriority(47) - } - - func isDismissable() -> Bool { - - switch location { - case .HomeView_Single : return true - case .HomeView_Multiple : return false - case .NotificationsView : return false - } - } - - func shouldShowAction() -> Bool { - - switch location { - case .HomeView_Single : return true - case .HomeView_Multiple : return false - case .NotificationsView : return true - } - } - - func timestamp() -> String { - - let date = item.notification.createdAt.toDate(from: .milliseconds) - let now = Date.now - - if now.timeIntervalSince(date) < 1.0 { - // The RelativeDateTimeFormatter will say something like "in 0 seconds", - // which is wrong in this context. - - return NSLocalizedString("just now", comment: "Timestamp for notification") - - } else { - - let formatter = RelativeDateTimeFormatter() - return formatter.localizedString(for: date, relativeTo: now) - } - } - - func amount(_ either: Either< - PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee, - PhoenixShared.Notification.PaymentRejected.OverRelativeFee - >) -> Lightning_kmpMilliSatoshi { - - switch either { - case .Left(let reason) : return reason.amount - case .Right(let reason): return reason.amount - } - } - - func isOnChain(_ either: Either< - PhoenixShared.Notification.PaymentRejected.OverAbsoluteFee, - PhoenixShared.Notification.PaymentRejected.OverRelativeFee - >) -> Bool { - - let source: Lightning_kmpLiquidityEventsSource - switch either { - case .Left(let reason) : source = reason.source - case .Right(let reason): source = reason.source - } - - return source == Lightning_kmpLiquidityEventsSource.onchainwallet - } - - func basisPointsAsPercent(_ basisPoints: Int32) -> String { - - // Example: 30% == 3,000 basis points - // - // 3,000 / 100 => 30.0 => 3000% - // 3,000 / 100 / 100 => 0.3 => 30% - - let percent = Double(basisPoints) / Double(10_000) - - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 - - return formatter.string(from: NSNumber(value: percent)) ?? "?%" - } - - func navigateToLiquiditySettings() { - log.trace("navigateToLiquiditySettings()") - - switch location { - case .HomeView_Single(let preAction) : preAction() - case .HomeView_Multiple : break - case .NotificationsView(let preAction) : preAction() - } - deepLinkManager.broadcast(.liquiditySettings) - } - - func dismiss() { - log.trace("dismiss()") - - Biz.business.notificationsManager.dismissNotifications(ids: item.ids) - } } diff --git a/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift b/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift index 0c188b11d..7458bcd0b 100644 --- a/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift +++ b/phoenix-ios/phoenix-ios/views/notifications/NotificationsView.swift @@ -132,37 +132,56 @@ struct NotificationsView : View { if noticeMonitor.hasNotice_backupSeed { NoticeBox(backgroundColor: .mutedBackground) { - NotificationCell.backupSeed(action: navigateToBackup) + NotificationCell.backupSeed() } .font(.callout) + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { navigateToBackup() } } if noticeMonitor.hasNotice_electrumServer { NoticeBox(backgroundColor: .mutedBackground) { - NotificationCell.electrumServer(action: navigationToElecrumServer) + NotificationCell.electrumServer() } .font(.callout) + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { navigateToElectrumServer() } + } + + if noticeMonitor.hasNotice_swapInExpiration { + NoticeBox(backgroundColor: .mutedBackground) { + NotificationCell.swapInExpiration() + } + .font(.callout) + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { navigateToSwapInWallet() } } if noticeMonitor.hasNotice_backgroundPayments { NoticeBox(backgroundColor: .mutedBackground) { - NotificationCell.backgroundPayments(action: navigationToBackgroundPayments) + NotificationCell.backgroundPayments() } .font(.callout) + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { navigateToBackgroundPayments() } } if noticeMonitor.hasNotice_watchTower { NoticeBox(backgroundColor: .mutedBackground) { - NotificationCell.watchTower(action: fixBackgroundAppRefreshDisabled) + NotificationCell.watchTower() } .font(.callout) + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { fixBackgroundAppRefreshDisabled() } } if noticeMonitor.hasNotice_mempoolFull { NoticeBox(backgroundColor: .mutedBackground) { - NotificationCell.mempoolFull(action: openMempoolFullURL) + NotificationCell.mempoolFull() } .font(.callout) + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { openMempoolFullURL() } } } header: { @@ -246,11 +265,7 @@ struct NotificationsView : View { func hasImportantNotifications() -> Bool { - return noticeMonitor.hasNotice_backupSeed - || noticeMonitor.hasNotice_electrumServer - || noticeMonitor.hasNotice_backgroundPayments - || noticeMonitor.hasNotice_watchTower - || noticeMonitor.hasNotice_mempoolFull + return noticeMonitor.hasNotice } // -------------------------------------------------- @@ -290,14 +305,21 @@ struct NotificationsView : View { deepLinkManager.broadcast(DeepLink.backup) } - func navigationToElecrumServer() { + func navigateToElectrumServer() { log.trace("navigateToElectrumServer()") presentationMode.wrappedValue.dismiss() deepLinkManager.broadcast(DeepLink.electrum) } - func navigationToBackgroundPayments() { + func navigateToSwapInWallet() { + log.trace("navigateToSwapInWallet()") + + presentationMode.wrappedValue.dismiss() + deepLinkManager.broadcast(DeepLink.swapInWallet) + } + + func navigateToBackgroundPayments() { log.trace("navigateToBackgroundPayments()") presentationMode.wrappedValue.dismiss()