Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions damus.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,9 @@
5C14C29F2BBBA5C600079FD2 /* RelayNipList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */; };
5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; };
5C4D9EA72C042FA5005EA0F7 /* HighlightDraftContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4D9EA62C042FA5005EA0F7 /* HighlightDraftContentView.swift */; };
5C4FA7C52DBA9D5C00CE658C /* CoinbaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7C42DBA9D5800CE658C /* CoinbaseModel.swift */; };
5C4FA7C62DBA9D5C00CE658C /* CoinbaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7C42DBA9D5800CE658C /* CoinbaseModel.swift */; };
5C4FA7C72DBA9D5C00CE658C /* CoinbaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7C42DBA9D5800CE658C /* CoinbaseModel.swift */; };
5C513FBA297F72980072348F /* CustomPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FB9297F72980072348F /* CustomPicker.swift */; };
5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FCB2984ACA60072348F /* QRCodeView.swift */; };
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; };
Expand Down Expand Up @@ -2409,6 +2412,7 @@
5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayNipList.swift; sourceTree = "<group>"; };
5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = "<group>"; };
5C4D9EA62C042FA5005EA0F7 /* HighlightDraftContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDraftContentView.swift; sourceTree = "<group>"; };
5C4FA7C42DBA9D5800CE658C /* CoinbaseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinbaseModel.swift; sourceTree = "<group>"; };
5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = "<group>"; };
5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = "<group>"; };
5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2770,6 +2774,7 @@
4C0A3F8D280F63FF000448DE /* Models */ = {
isa = PBXGroup;
children = (
5C4FA7C42DBA9D5800CE658C /* CoinbaseModel.swift */,
D73BDB122D71212600D69970 /* NostrNetworkManager */,
D74F43082B23F09300425B75 /* Purple */,
BA3759882ABCCDE30018D73B /* Camera */,
Expand Down Expand Up @@ -4512,6 +4517,7 @@
4C32B9572A9AD44700DC3548 /* Root.swift in Sources */,
4C3EA64428FF558100C48A62 /* sha256.c in Sources */,
5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */,
5C4FA7C72DBA9D5C00CE658C /* CoinbaseModel.swift in Sources */,
504323A72A34915F006AE6DC /* RelayModel.swift in Sources */,
4CF0ABF62985CD5500D66079 /* UserSearch.swift in Sources */,
4C32B9542A9AD44700DC3548 /* FlatBuffersUtils.swift in Sources */,
Expand Down Expand Up @@ -5283,6 +5289,7 @@
82D6FB832CD99F7900C925F4 /* SearchModel.swift in Sources */,
82D6FB842CD99F7900C925F4 /* NostrFilter+Hashable.swift in Sources */,
82D6FB852CD99F7900C925F4 /* Contacts.swift in Sources */,
5C4FA7C62DBA9D5C00CE658C /* CoinbaseModel.swift in Sources */,
82D6FB862CD99F7900C925F4 /* CreateAccountModel.swift in Sources */,
82D6FB872CD99F7900C925F4 /* HomeModel.swift in Sources */,
82D6FB882CD99F7900C925F4 /* SignalModel.swift in Sources */,
Expand Down Expand Up @@ -5777,6 +5784,7 @@
D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */,
D73E5EFE2C6A97F4007EB227 /* (null) in Sources */,
D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */,
5C4FA7C52DBA9D5C00CE658C /* CoinbaseModel.swift in Sources */,
D73E5EFF2C6A97F4007EB227 /* ZapsView.swift in Sources */,
D73E5F002C6A97F4007EB227 /* CustomizeZapView.swift in Sources */,
D73E5F012C6A97F4007EB227 /* ZapTypePicker.swift in Sources */,
Expand Down
84 changes: 84 additions & 0 deletions damus/Models/CoinbaseModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// CoinbaseModel.swift
// damus
//
// Created by eric on 4/24/25.
//

import Foundation
import SwiftUI

class CoinbaseModel: ObservableObject {

@Published var btcPrice: Double? = nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Published var btcPrice: Double? = nil {
@Published var btcPrice: Decimal? = nil {

didSet {
if let btcPrice = btcPrice {
cachePrice(btcPrice)
}
}
}

var cacheKey: String {
"btc_price_\(currency.lowercased())"
}

var currency: String = Locale.current.currency?.identifier ?? "USD"

let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = Locale.current
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
return formatter
}()

func satsToFiat(input: Int64) -> String {
guard let btcPrice = btcPrice, input > 0 else {
return ""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use more expressive semantics of the result, and let the view choose how to handle failures.

Instead of defaulting to an empty string or value when something fails, we should:

  • throw errors if something unexpected happened, and then let the view choose whether to show or suppress those errors.
  • Return nil if there is no value available.

}
let fiat = (Double(input) / 100_000_000) * btcPrice
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let fiat = (Double(input) / 100_000_000) * btcPrice
let fiat = (Decimal(input) / 100_000_000) * btcPrice

let amount = numberFormatter.string(from: NSNumber(value: fiat)) ?? ""
let symbol: String = numberFormatter.currencySymbol
if symbol.isEmpty || symbol == currency {
return amount + " " + currency
}
return symbol + amount + " " + currency
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be making some assumptions about currency value formatting that might not be true for some locales.

I believe Apple provides a way to format currencies in a simpler and localization-friendly way, by using numberStyle = .currency instead of numberStyle = .decimal:

}

func fetchFromCoinbase() {
let urlString = "https://api.coinbase.com/v2/prices/BTC-\(currency)/spot"
guard let url = URL(string: urlString) else { return }

Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
let decoded = try JSONDecoder().decode(CoinbaseResponse.self, from: data)
DispatchQueue.main.async {
self.btcPrice = decoded.data.amount
}
} catch {
print("Failed to fetch BTC price from Coinbase: \(error)")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit worried about how this error is being handled. If something is broken or the API stops working, the interface will just keep displaying the last known value forever, which might be misleading and hard to notice.

}
}
}

private func cachePrice(_ price: Double) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private func cachePrice(_ price: Double) {
private func cachePrice(_ price: Decimal) {

UserDefaults.standard.set(price, forKey: cacheKey)
}

func loadCachedPrice() {
let cached = UserDefaults.standard.double(forKey: cacheKey)
if cached > 0 {
btcPrice = cached
}
}
}

struct CoinbaseResponse: Codable {
let data: CoinbasePrice
}

struct CoinbasePrice: Codable {
let amount: Double
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let amount: Double
let amount: Decimal

}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick tip: The structs CoinbasePrice and CoinbaseResponse exist in the context of CoinbaseModel, so we can move them inside CoinbaseModel to make namespace semantics more explicit and avoid having too many names in the global namespace:

struct CoinbaseModel {
    ...
    struct Response: Codable {
        let data: Price
        
        struct Price: Codable {
            let amount: Double
        }
    }
}


12 changes: 11 additions & 1 deletion damus/Views/Wallet/BalanceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import SwiftUI

struct BalanceView: View {
var balance: Int64?
@StateObject private var coinbase = CoinbaseModel()

var body: some View {
VStack(spacing: 5) {
Text("Current balance", comment: "Label for displaying current wallet balance")
.foregroundStyle(DamusColors.neutral6)
if let balance {
self.numericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal))

Text(coinbase.satsToFiat(input: balance))
.fontWeight(.medium)
.foregroundStyle(DamusColors.neutral6)
Comment on lines +21 to +23
Copy link

Copilot AI May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If satsToFiat returns an empty string (e.g., zero balance or missing price), this Text will render blank space. Consider conditionally showing the view only when the string is non-empty.

Suggested change
Text(coinbase.satsToFiat(input: balance))
.fontWeight(.medium)
.foregroundStyle(DamusColors.neutral6)
let fiatValue = coinbase.satsToFiat(input: balance)
if !fiatValue.isEmpty {
Text(fiatValue)
.fontWeight(.medium)
.foregroundStyle(DamusColors.neutral6)
}

Copilot uses AI. Check for mistakes.

}
else {
// Make sure we do not show any numeric value to the user when still loading (or when failed to load)
Expand All @@ -25,7 +30,12 @@ struct BalanceView: View {
.shimmer(true)
}
}
.onAppear {
coinbase.loadCachedPrice()
coinbase.fetchFromCoinbase()
}
}


func numericalBalanceView(text: String) -> some View {
HStack {
Expand All @@ -43,7 +53,7 @@ struct BalanceView: View {
.foregroundStyle(PinkGradient)
}
}
.padding(.bottom)
.padding(.bottom, 5)
}
}

Expand Down
2 changes: 1 addition & 1 deletion damus/Views/Wallet/WalletView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct WalletView: View {
)
}

VStack(spacing: 5) {
VStack(spacing: 10) {

BalanceView(balance: model.balance)

Expand Down