Skip to content
Merged
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
5 changes: 5 additions & 0 deletions Bitkit/Extensions/Activity+Contact.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ extension Activity {
return contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, contactPublicKey) })
}

func isReplacedSentTransaction(txIdsInBoostTxIds: Set<String>) -> Bool {
guard case let .onchain(onchain) = self else { return false }
return !onchain.doesExist && onchain.txType == .sent && txIdsInBoostTxIds.contains(onchain.txId)
}

private var contactPublicKey: String? {
switch self {
case let .lightning(lightning):
Expand Down
16 changes: 13 additions & 3 deletions Bitkit/Managers/ScannerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class ScannerManager: ObservableObject {
}

func handleScan(_ uri: String, context: ScannerContext) async {
let uri = uri.trimmingCharacters(in: .whitespacesAndNewlines)
guard !uri.isEmpty else { return }

Haptics.play(.scanSuccess)

switch context {
Expand Down Expand Up @@ -90,7 +93,7 @@ class ScannerManager: ObservableObject {
}
}

private func handlePubkyRouteIfNeeded(_ input: String) -> Bool {
private func handlePubkyRouteIfNeeded(_ input: String, hiding sheetId: SheetID? = .scanner, reason: String = "Scanner routed pubky key") -> Bool {
guard let navigation,
let route = resolvePastedPubkyRoute(
input: input,
Expand All @@ -101,7 +104,9 @@ class ScannerManager: ObservableObject {
return false
}

sheets?.hideSheetIfActive(.scanner, reason: "Scanner routed pubky key")
if let sheetId {
sheets?.hideSheetIfActive(sheetId, reason: reason)
}
navigation.navigate(route)
return true
}
Expand All @@ -115,6 +120,11 @@ class ScannerManager: ObservableObject {
Haptics.play(.scanSuccess)

do {
if handlePubkyRouteIfNeeded(uri, hiding: .send, reason: "Send scanner routed pubky key") {
completion(nil)
return
}

try await app.handleScannedData(uri)

let route = PaymentNavigationHelper.appropriateSendRoute(
Expand Down Expand Up @@ -177,7 +187,7 @@ class ScannerManager: ObservableObject {
return
}

await handleScan(uri, context: context)
await handleScan(uri.trimmingCharacters(in: .whitespacesAndNewlines), context: context)
}

func handleImageSelection(_ item: PhotosPickerItem?, context: ScannerContext, completion: @escaping (SendRoute?) -> Void = { _ in }) async {
Expand Down
67 changes: 53 additions & 14 deletions Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ class ActivityService {
{
activity.boostTxIds.append(txid)
activity.isBoosted = true
activity.contact = activity.contact ?? replacedActivity?.contact
activity.updatedAt = UInt64(Date().timeIntervalSince1970)
try await self.update(id: activity.id, activity: .onchain(activity))

Expand Down Expand Up @@ -965,17 +966,25 @@ class ActivityService {

func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] {
let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
let txIdsInBoostTxIds = await getTxIdsInBoostTxIds()
// TODO: push contact filtering into BitkitCore once the activity store exposes it.
let activities = try await get(filter: .all, sortDirection: sortDirection)

return activities.filter { activity in
switch activity {
case let .lightning(lightning):
return PubkyPublicKeyFormat.matches(lightning.contact, normalizedKey)
case let .onchain(onchain):
return PubkyPublicKeyFormat.matches(onchain.contact, normalizedKey)
return activities
.filter { !isReplacedSentTransaction($0, txIdsInBoostTxIds: txIdsInBoostTxIds) }
.filter { activity in
switch activity {
case let .lightning(lightning):
return PubkyPublicKeyFormat.matches(lightning.contact, normalizedKey)
case let .onchain(onchain):
return PubkyPublicKeyFormat.matches(onchain.contact, normalizedKey)
}
}
}
}

private func isReplacedSentTransaction(_ activity: Activity, txIdsInBoostTxIds: Set<String>) -> Bool {
guard case let .onchain(onchain) = activity else { return false }
return !onchain.doesExist && onchain.txType == .sent && txIdsInBoostTxIds.contains(onchain.txId)
}

func update(id: String, activity: Activity) async throws {
Expand Down Expand Up @@ -1046,7 +1055,7 @@ class ActivityService {
let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }

try await ServiceQueue.background(.core) {
guard let activity = try getActivityById(activityId: id) else {
guard let activity = try getActivityById(activityId: id) ?? (try? BitkitCore.getActivityByTxId(txId: id)).map(Activity.onchain) else {
throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found")
}

Expand All @@ -1055,19 +1064,49 @@ class ActivityService {
guard lightning.contact != normalizedContact else { return }
lightning.contact = normalizedContact
lightning.updatedAt = UInt64(Date().timeIntervalSince1970)
try updateActivity(activityId: id, activity: .lightning(lightning))
try updateActivity(activityId: lightning.id, activity: .lightning(lightning))
self.activitiesChangedSubject.send()

case var .onchain(onchain):
guard onchain.contact != normalizedContact else { return }
onchain.contact = normalizedContact
onchain.updatedAt = UInt64(Date().timeIntervalSince1970)
try updateActivity(activityId: id, activity: .onchain(onchain))
self.activitiesChangedSubject.send()
let contactChanged = onchain.contact != normalizedContact
if contactChanged {
onchain.contact = normalizedContact
onchain.updatedAt = UInt64(Date().timeIntervalSince1970)
try updateActivity(activityId: onchain.id, activity: .onchain(onchain))
}

let replacementContactChanged = try self.updateReplacementContactIfNeeded(for: onchain, normalizedContact: normalizedContact)
if contactChanged || replacementContactChanged {
self.activitiesChangedSubject.send()
}
}
}
}

private func updateReplacementContactIfNeeded(for activity: OnchainActivity, normalizedContact: String?) throws -> Bool {
guard !activity.doesExist, activity.txType == .sent else { return false }

let activities = try getActivities(
filter: .onchain,
txType: nil,
tags: nil,
search: nil,
minDate: nil,
maxDate: nil,
limit: nil,
sortDirection: nil
)
var didUpdate = false
for case var .onchain(replacement) in activities where replacement.boostTxIds.contains(activity.txId) {
guard replacement.contact != normalizedContact else { continue }
replacement.contact = normalizedContact
replacement.updatedAt = UInt64(Date().timeIntervalSince1970)
try updateActivity(activityId: replacement.id, activity: .onchain(replacement))
didUpdate = true
}
return didUpdate
}

func delete(id: String) async throws -> Bool {
try await ServiceQueue.background(.core) {
// Rebuild cache if deleting an onchain activity with boostTxIds
Expand Down
5 changes: 4 additions & 1 deletion Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,10 @@ extension LightningService {
Logger.error(error, context: "node.eventHandled()")
}

if case .channelReady = event {
await refreshChannelCache()
}

onEvent?(event)

switch event {
Expand Down Expand Up @@ -1204,7 +1208,6 @@ extension LightningService {
Logger.info(
"👐 Channel ready: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") fundingTxo: \(fundingTxo != nil ? "\(fundingTxo!.txid):\(fundingTxo!.vout)" : "nil")"
)
await refreshChannelCache()
case let .channelClosed(channelId, userChannelId, counterpartyNodeId, reason):
let reasonString = reason.map { String(describing: $0) } ?? ""
Logger.info(
Expand Down
17 changes: 2 additions & 15 deletions Bitkit/ViewModels/ActivityListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,7 @@ class ActivityListViewModel: ObservableObject {
}

func contactActivities(publicKey: String) async throws -> [Activity] {
let activities = try await coreService.activity.get(contact: publicKey, sortDirection: .desc)
return await filterOutReplacedSentTransactions(activities)
try await coreService.activity.get(contact: publicKey, sortDirection: .desc)
}

func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws {
Expand Down Expand Up @@ -470,19 +469,7 @@ extension ActivityListViewModel {
// Get cached set of txIds that appear in boostTxIds
let txIdsInBoostTxIds = await coreService.activity.getTxIdsInBoostTxIds()

// Filter out activities that:
// 1. Are onchain
// 2. Have doesExist = false
// 3. Are sent transactions
// 4. Appear in another transaction's boostTxIds
return activities.filter { activity in
if case let .onchain(onchain) = activity {
if !onchain.doesExist && onchain.txType == .sent && txIdsInBoostTxIds.contains(onchain.txId) {
return false
}
}
return true
}
return activities.filter { !$0.isReplacedSentTransaction(txIdsInBoostTxIds: txIdsInBoostTxIds) }
}

/// Filter activities based on the selected tab
Expand Down
7 changes: 7 additions & 0 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,13 @@ extension AppViewModel {
return
}

if PubkyPublicKeyFormat.normalized(normalized) != nil {
guard currentSequence == manualEntryValidationSequence else { return }
manualEntryValidationResult = .valid
isManualEntryInputValid = true
return
}

// Try to decode the invoice
guard let decodedData = try? await decode(invoice: normalized) else {
guard currentSequence == manualEntryValidationSequence else { return }
Expand Down
6 changes: 5 additions & 1 deletion Bitkit/ViewModels/NavigationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func fallbackRouteForMissingPendingImport(hasPendingImport: Bool) -> Route? {
hasPendingImport ? nil : .payContacts
}

func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? {
func resolvePubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? {
guard let normalizedKey = PubkyPublicKeyFormat.normalized(input) else {
return nil
}
Expand All @@ -142,6 +142,10 @@ func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [Pu
return .addContact(publicKey: normalizedKey)
}

func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? {
resolvePubkyRoute(input: input, ownPublicKey: ownPublicKey, contacts: contacts)
}

@MainActor
class NavigationViewModel: ObservableObject {
@Published var path: [Route] = []
Expand Down
19 changes: 18 additions & 1 deletion Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ class WalletViewModel: ObservableObject {
case .channelReady:
self.bolt11 = ""
Task {
await self.reconnectTrustedPeers()
await self.refreshAndSyncState()
try? await self.refreshBip21()
}
Expand Down Expand Up @@ -861,12 +862,20 @@ class WalletViewModel: ObservableObject {

/// Sync channels and peers only
private func syncChannelsAndPeers() {
let hadUsableChannels = channels?.contains(where: \.isUsable) ?? false
peers = lightningService.peers
channels = lightningService.channels
let hasUsableChannels = channels?.contains(where: \.isUsable) ?? false

if let channels {
channelCount = channels.count
}

if sharesPublicPaykitEndpoints, hasUsableChannels, !hadUsableChannels {
Task { [weak self] in
await self?.syncPublicPaykitEndpointsAfterChannelBecameUsable()
}
}
}

/// Sync balance details only
Expand Down Expand Up @@ -961,7 +970,7 @@ class WalletViewModel: ObservableObject {
func refreshPublicPaykitEndpoints(forceRefreshBolt11: Bool = false) async throws -> (onchainAddress: String, bolt11: String) {
let publicOnchainAddress = try await refreshReusableOnchainAddress()

if hasReadyChannels {
if hasUsableChannels {
let hasReusableInvoice = await hasReusablePublicPaykitInvoice()
let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice
if shouldRefreshBolt11 {
Expand All @@ -984,6 +993,14 @@ class WalletViewModel: ObservableObject {
}
}

private func syncPublicPaykitEndpointsAfterChannelBecameUsable() async {
do {
try await PublicPaykitService.syncPublishedEndpoints(wallet: self, publish: true)
} catch {
Logger.warn("Failed to refresh public Paykit endpoints after channel became usable: \(error)", context: "WalletViewModel")
}
}

private func hasReusablePublicPaykitInvoice() async -> Bool {
guard !publicPaykitBolt11.isEmpty else { return false }
guard publicPaykitBolt11ExpiresAt > Date().timeIntervalSince1970 + Self.publicPaykitInvoiceRefreshBufferSeconds else { return false }
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Views/Contacts/AddContactView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ struct AddContactView: View {
return false
}

navigation.navigateBack()
app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey)
sheets.showSheet(.send, data: SendConfig(view: route))
return true
Expand Down
20 changes: 20 additions & 0 deletions Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ struct SendEnterManuallyView: View {
@EnvironmentObject var wallet: WalletViewModel
@EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var settings: SettingsViewModel
@EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var pubkyProfile: PubkyProfileManager
@EnvironmentObject var sheets: SheetViewModel

@Binding var navigationPath: [SendRoute]
@FocusState private var isTextEditorFocused: Bool
Expand Down Expand Up @@ -77,6 +81,16 @@ struct SendEnterManuallyView: View {

guard !uri.isEmpty, app.isManualEntryInputValid else { return }

if let route = resolvePubkyRoute(
input: uri,
ownPublicKey: pubkyProfile.publicKey,
contacts: contactsManager.contacts
) {
sheets.hideSheetIfActive(.send, reason: "Manual pubky entry routed to contacts")
navigation.navigate(route)
return
}

do {
wallet.resetSendState(speed: settings.defaultTransactionSpeed)

Expand Down Expand Up @@ -106,5 +120,11 @@ struct SendEnterManuallyView: View {
SendEnterManuallyView(navigationPath: .constant([]))
.environmentObject(AppViewModel())
.environmentObject(WalletViewModel())
.environmentObject(CurrencyViewModel())
.environmentObject(SettingsViewModel.shared)
.environmentObject(ContactsManager())
.environmentObject(NavigationViewModel())
.environmentObject(PubkyProfileManager())
.environmentObject(SheetViewModel())
.preferredColorScheme(.dark)
}
17 changes: 16 additions & 1 deletion Bitkit/Views/Wallets/Send/SendOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import SwiftUI

struct SendOptionsView: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var contactsManager: ContactsManager
@EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var pubkyProfile: PubkyProfileManager
@EnvironmentObject var scanner: ScannerManager
@EnvironmentObject var settings: SettingsViewModel
@EnvironmentObject var sheets: SheetViewModel
@EnvironmentObject var wallet: WalletViewModel

@Binding var navigationPath: [SendRoute]
Expand Down Expand Up @@ -71,8 +75,12 @@ struct SendOptionsView: View {
wallet.syncState()
scanner.configure(
app: app,
contactsManager: contactsManager,
currency: currency,
settings: settings
settings: settings,
navigation: navigation,
pubkyProfile: pubkyProfile,
sheets: sheets
)
}
}
Expand Down Expand Up @@ -110,6 +118,13 @@ struct SendOptionsView: View {
NavigationStack {
SendOptionsView(navigationPath: .constant([]))
.environmentObject(AppViewModel())
.environmentObject(ContactsManager())
.environmentObject(CurrencyViewModel())
.environmentObject(NavigationViewModel())
.environmentObject(PubkyProfileManager())
.environmentObject(ScannerManager())
.environmentObject(SettingsViewModel.shared)
.environmentObject(SheetViewModel())
.environmentObject(WalletViewModel())
}
.presentationDetents([.height(UIScreen.screenHeight - 120)])
Expand Down
Loading
Loading