diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 09df5e0d..43c2c04f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -87,6 +87,7 @@ Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, Models/LnPeer.swift, + Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, Models/Transfer.swift, Models/TransferType.swift, @@ -117,6 +118,7 @@ Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, Models/LnPeer.swift, + Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, Services/CoreService.swift, Services/GeoService.swift, @@ -904,8 +906,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { - kind = revision; - revision = cd1253291b1582759d569372d5942b8871527ea1; + kind = exactVersion; + version = 0.1.0-rc5; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { @@ -936,8 +938,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { - kind = revision; - revision = 99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69; + kind = exactVersion; + version = 0.1.58; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 06a83812..7283543a 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69" + "revision" : "47bd506bb46ae885191a265f76245ab357a93f28", + "version" : "0.1.58" } }, { @@ -40,7 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "cd1253291b1582759d569372d5942b8871527ea1" + "revision" : "04759b7b9bca7a0dd8a29e724f11e984db361241", + "version" : "0.1.0-rc5" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index a6cc9f34..efe05536 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -560,6 +560,7 @@ struct AppScene: View { Task { await clearDeliveredNotifications() await LightningService.shared.reconnectPeers() + await wallet.refreshPublicPaykitEndpointsOnForeground() } } } diff --git a/Bitkit/Components/Activity/ActivityList.swift b/Bitkit/Components/Activity/ActivityList.swift index a3a570fb..33814d07 100644 --- a/Bitkit/Components/Activity/ActivityList.swift +++ b/Bitkit/Components/Activity/ActivityList.swift @@ -3,6 +3,7 @@ import SwiftUI struct ActivityList: View { @EnvironmentObject var activity: ActivityListViewModel + @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @State private var isHorizontalSwipe = false @@ -28,7 +29,11 @@ struct ActivityList: View { case let .activity(item): NavigationLink(value: Route.activityDetail(item)) { - ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates) + ActivityRow( + item: item, + feeEstimates: feeEstimatesManager.estimates, + contact: item.contact(in: contactsManager.contacts) + ) } .accessibilityIdentifier("Activity-\(index)") .disabled(isHorizontalSwipe) diff --git a/Bitkit/Components/ContactAvatarLetter.swift b/Bitkit/Components/ContactAvatarLetter.swift new file mode 100644 index 00000000..d29430a9 --- /dev/null +++ b/Bitkit/Components/ContactAvatarLetter.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ContactAvatarLetter: View { + let source: String + let size: CGFloat + var backgroundColor: Color = .white.opacity(0.1) + var strokeColor: Color? + var strokeWidth: CGFloat = 0 + + private var letter: String { + String(source.prefix(1)).uppercased() + } + + var body: some View { + Circle() + .fill(backgroundColor) + .frame(width: size, height: size) + .overlay { + avatarText + } + .overlay { + if let strokeColor, strokeWidth > 0 { + Circle() + .stroke(strokeColor, lineWidth: strokeWidth) + } + } + .accessibilityHidden(true) + } + + @ViewBuilder + private var avatarText: some View { + if size >= 72 { + HeadlineText(letter) + } else if size >= 56 { + TitleText(letter) + } else if size >= 44 { + BodyMSBText(letter) + } else { + CaptionBText(letter, textColor: .textPrimary) + } + } +} diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4da84ebc..845b69e0 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -272,7 +272,7 @@ enum Env { case .bitcoin: return "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw" default: - return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/staging.paykit/v0/:rw" + return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/paykit/v0/:rw" } } diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift new file mode 100644 index 00000000..a2b9cb80 --- /dev/null +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -0,0 +1,18 @@ +import BitkitCore + +extension Activity { + func contact(in contacts: [PubkyContact]) -> PubkyContact? { + guard let contactPublicKey else { return nil } + return contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, contactPublicKey) }) + } + + private var contactPublicKey: String? { + switch self { + case let .lightning(lightning): + return lightning.contact + + case let .onchain(onchain): + return onchain.contact + } + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 1eae66d3..a787d4e9 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -385,6 +385,7 @@ struct MainNavView: View { } case .contactsIntro: ContactsIntroView() case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey) + case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey) case .contactImportOverview: if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { missingPendingImportView(fallbackRoute: fallbackRoute) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 067f6420..a8c47860 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -11,44 +11,9 @@ private func stripPubkyPrefix(_ key: String) -> String { key.hasPrefix(pubkyPrefix) ? String(key.dropFirst(pubkyPrefix.count)) : key } -enum PubkyPublicKeyFormat { - private static let rawKeyLength = 52 - private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") - - static let maximumInputLength = pubkyPrefix.count + rawKeyLength - - static func bounded(_ input: String) -> String { - String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(maximumInputLength)) - } - - static func normalized(_ input: String) -> String? { - let boundedInput = bounded(input) - let rawKey = stripPubkyPrefix(boundedInput) - - guard rawKey.count == rawKeyLength else { - return nil - } - - guard rawKey.allSatisfy({ allowedCharacters.contains($0) }) else { - return nil - } - - return ensurePubkyPrefix(rawKey) - } - - static func matches(_ lhs: String?, _ rhs: String?) -> Bool { - guard let lhs = lhs.flatMap(normalized), - let rhs = rhs.flatMap(normalized) - else { - return false - } - - return lhs == rhs - } -} - enum AddContactValidationResult: Equatable { case empty + case existingContact case invalidKey case ownKey case valid(normalizedKey: String) @@ -57,6 +22,8 @@ enum AddContactValidationResult: Equatable { switch self { case .empty, .valid: nil + case .existingContact: + t("contacts__add_error_existing") case .invalidKey: t("contacts__add_error_invalid_key") case .ownKey: @@ -65,7 +32,11 @@ enum AddContactValidationResult: Equatable { } } -func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddContactValidationResult { +func resolveAddContactValidation( + input: String, + ownPublicKey: String?, + existingContacts: [PubkyContact] = [] +) -> AddContactValidationResult { let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedInput.isEmpty else { @@ -80,12 +51,17 @@ func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddCon return .invalidKey } + if existingContacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, normalizedKey) }) { + return .existingContact + } + return .valid(normalizedKey: normalizedKey) } enum ContactsManagerError: LocalizedError { case invalidPublicKey case cannotAddYourself + case alreadyExists var errorDescription: String? { switch self { @@ -93,6 +69,8 @@ enum ContactsManagerError: LocalizedError { return t("contacts__add_error_invalid_key") case .cannotAddYourself: return t("contacts__add_error_self") + case .alreadyExists: + return t("contacts__add_error_existing") } } } @@ -142,6 +120,7 @@ class ContactsManager: ObservableObject { @Published var isLoading = false @Published var hasLoaded = false @Published var loadErrorMessage: String? + @Published var shouldOpenAddContactSheet = false /// Temporarily holds contacts discovered during import (e.g., from pubky.app after Ring auth). /// Cleared after import is completed or discarded. @@ -164,6 +143,7 @@ class ContactsManager: ObservableObject { isLoading = false hasLoaded = false loadErrorMessage = nil + shouldOpenAddContactSheet = false clearPendingImport() } @@ -185,7 +165,7 @@ class ContactsManager: ObservableObject { defer { isLoading = false } let basePath = contactsBasePath - Logger.info("Loading contacts from \(basePath) for \(publicKey)", context: "ContactsManager") + Logger.info("Loading contacts from \(basePath) for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "ContactsManager") do { let sessionSecret = try getSessionSecret() @@ -214,7 +194,10 @@ class ContactsManager: ObservableObject { let profile = profileData.toProfile(publicKey: prefixedKey) return .success(PubkyContact(publicKey: prefixedKey, profile: profile)) } catch { - Logger.warn("Failed to load contact data for '\(prefixedKey)': \(error)", context: "ContactsManager") + Logger.warn( + "Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", + context: "ContactsManager" + ) return .failure(error) } } @@ -290,9 +273,8 @@ class ContactsManager: ObservableObject { throw ContactsManagerError.cannotAddYourself } - guard !contacts.contains(where: { $0.publicKey == prefixedKey }) else { - Logger.debug("Contact \(prefixedKey) already exists, skipping add", context: "ContactsManager") - return + guard !contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) else { + throw ContactsManagerError.alreadyExists } // Use existing profile if provided (e.g., already fetched during preview), @@ -315,7 +297,7 @@ class ContactsManager: ObservableObject { let contactData = PubkyProfileData.from(profile: profile) try await savePubkyProfileData(publicKey: prefixedKey, data: contactData) - Logger.info("Added contact \(prefixedKey)", context: "ContactsManager") + Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") let contact = PubkyContact(publicKey: prefixedKey, profile: profile) contacts.append(contact) @@ -338,7 +320,7 @@ class ContactsManager: ObservableObject { try await savePubkyProfileData(publicKey: key, data: contactData) return .success(PubkyContact(publicKey: key, profile: profile)) } catch { - Logger.warn("Failed to save imported contact '\(key)': \(error)", context: "ContactsManager") + Logger.warn("Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(key))': \(error)", context: "ContactsManager") return .failure(error) } } @@ -400,7 +382,7 @@ class ContactsManager: ObservableObject { contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } - Logger.info("Updated contact \(prefixedKey)", context: "ContactsManager") + Logger.info("Updated contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") } // MARK: - Delete Contact @@ -418,7 +400,7 @@ class ContactsManager: ObservableObject { ) }.value - Logger.info("Removed contact \(prefixedKey)", context: "ContactsManager") + Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") contacts.removeAll { $0.publicKey == prefixedKey } } @@ -459,18 +441,18 @@ class ContactsManager: ObservableObject { deletedKeys.insert(ensurePubkyPrefix(contactKey)) } catch { firstError = firstError ?? error - Logger.warn("Failed to delete contact '\(contactKey)': \(error)", context: "ContactsManager") + Logger.warn("Failed to delete contact '\(PubkyPublicKeyFormat.redacted(contactKey))': \(error)", context: "ContactsManager") } } - if !deletedKeys.isEmpty { - contacts.removeAll { deletedKeys.contains($0.publicKey) } - } - if let firstError { + if !deletedKeys.isEmpty { + contacts.removeAll { deletedKeys.contains($0.publicKey) } + } throw firstError } + // All remote deletes succeeded, so clear any local-only contacts too. contacts.removeAll() Logger.info("Deleted all contacts", context: "ContactsManager") } @@ -602,11 +584,14 @@ class ContactsManager: ObservableObject { return try await PubkyProfileManager.resolveRemoteProfile(publicKey: prefixedKey) } catch { if includePlaceholder, Self.isMissingContactsDataError(error) { - Logger.info("No remote profile found for '\(prefixedKey)', using placeholder", context: "ContactsManager") + Logger.info( + "No remote profile found for '\(PubkyPublicKeyFormat.redacted(prefixedKey))', using placeholder", + context: "ContactsManager" + ) return PubkyProfile.placeholder(publicKey: prefixedKey) } - Logger.warn("Failed to resolve contact profile for '\(prefixedKey)': \(error)", context: "ContactsManager") + Logger.warn("Failed to resolve contact profile for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", context: "ContactsManager") throw error } } @@ -620,7 +605,7 @@ class ContactsManager: ObservableObject { } catch { if attempt == 0, !(error is CancellationError) { Logger.warn( - "Retrying imported contact profile resolution for '\(prefixedKey)' after transient error: \(error)", + "Retrying imported contact profile resolution for '\(PubkyPublicKeyFormat.redacted(prefixedKey))' after transient error: \(error)", context: "ContactsManager" ) try? await Task.sleep(nanoseconds: 250_000_000) @@ -628,7 +613,7 @@ class ContactsManager: ObservableObject { } Logger.warn( - "Falling back to placeholder while importing contact '\(prefixedKey)': \(error)", + "Falling back to placeholder while importing contact '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", context: "ContactsManager" ) return PubkyProfile.placeholder(publicKey: prefixedKey) @@ -697,13 +682,11 @@ class ContactsManager: ObservableObject { } let normalized = message.lowercased() - let indicatesMissingResource = normalized.contains("404") + return normalized.contains("404") || normalized.contains("no such file") || normalized.contains("does not exist") || normalized.contains("profile not found") || normalized.contains("profilenotfound") || (normalized.contains("fetch failed") && normalized.contains("not found")) - - return indicatesMissingResource } } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 5e8eca3b..d896cd9b 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -759,11 +759,36 @@ class PubkyProfileManager: ObservableObject { await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + clearPublicPaykitSharingState() notifyAppStateBackupChanged() } + private static func clearPublicPaykitSharingState() { + UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints") + UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt") + } + + static func removePublicPaykitEndpoints(context: String) async throws { + do { + try await PublicPaykitService.removePublishedEndpoints() + } catch PubkyServiceError.sessionNotActive { + Logger.debug("Skipping public Paykit endpoint cleanup because no session is active", context: context) + } catch { + Logger.warn("Failed to remove public Paykit endpoints before clearing session: \(error)", context: context) + throw error + } + } + + static func removePublicPaykitEndpointsBestEffort(context: String) async { + try? await removePublicPaykitEndpoints(context: context) + } + func signOut() async { await Task.detached { + await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") do { try await PubkyService.signOut() } catch { diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 67d8f14e..5b057e4c 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -3,6 +3,7 @@ import SwiftUI import Vision enum ScannerContext { + case addContact case main case send case electrum @@ -40,6 +41,8 @@ class ScannerManager: ObservableObject { Haptics.play(.scanSuccess) switch context { + case .addContact: + handleAddContactScan(uri) case .main: await handleMainScan(uri) case .send: @@ -49,6 +52,16 @@ class ScannerManager: ObservableObject { } } + private func handleAddContactScan(_ input: String) { + navigation?.navigateBack() + + guard !handlePubkyRouteIfNeeded(input) else { + return + } + + navigation?.navigate(.addContact(publicKey: input)) + } + private func handleMainScan(_ uri: String) async { guard let app else { return } diff --git a/Bitkit/Models/ActivityDisplayConstants.swift b/Bitkit/Models/ActivityDisplayConstants.swift new file mode 100644 index 00000000..d9b610ea --- /dev/null +++ b/Bitkit/Models/ActivityDisplayConstants.swift @@ -0,0 +1,3 @@ +enum ActivityDisplayConstants { + static let maxHomeActivityItems = 4 +} diff --git a/Bitkit/Models/PubkyPublicKeyFormat.swift b/Bitkit/Models/PubkyPublicKeyFormat.swift new file mode 100644 index 00000000..64e0c86e --- /dev/null +++ b/Bitkit/Models/PubkyPublicKeyFormat.swift @@ -0,0 +1,47 @@ +import Foundation + +enum PubkyPublicKeyFormat { + private static let prefix = "pubky" + private static let rawKeyLength = 52 + private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") + + static let maximumInputLength = prefix.count + rawKeyLength + + static func bounded(_ input: String) -> String { + String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(maximumInputLength)) + } + + static func normalized(_ input: String) -> String? { + let boundedInput = bounded(input) + let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput + + guard rawKey.count == rawKeyLength else { + return nil + } + + guard rawKey.allSatisfy({ allowedCharacters.contains($0) }) else { + return nil + } + + return "\(prefix)\(rawKey)" + } + + static func matches(_ lhs: String?, _ rhs: String?) -> Bool { + guard let lhs = lhs.flatMap(normalized), + let rhs = rhs.flatMap(normalized) + else { + return false + } + + return lhs == rhs + } + + static func redacted(_ input: String) -> String { + let value = normalized(input) ?? input.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.count > 12 else { + return value + } + + return "\(value.prefix(12))..." + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 766d3dfd..82914c7b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -958,6 +958,8 @@ "slashtags__onboarding_text" = "Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles."; "slashtags__onboarding_button" = "Add First Contact"; "contacts__detail_title" = "Contact"; +"contacts__activity_sent_to" = "Sent to {name}"; +"contacts__activity_received_from" = "Received from {name}"; "contacts__detail_empty_state" = "Unable to load contact."; "contacts__empty_state" = "You don't have any contacts yet."; "contacts__intro_add_contact" = "Add Contact"; @@ -973,6 +975,7 @@ "contacts__add_pubky_placeholder" = "Paste a pubky"; "contacts__add_scan_qr" = "Scan QR"; "contacts__add_button" = "Add"; +"contacts__add_error_existing" = "This pubky is already in your contacts."; "contacts__add_error_invalid_key" = "Invalid pubky key format. Please check and try again."; "contacts__add_error_self" = "You can't add your own pubky as a contact."; "contacts__add_retrieving" = "Retrieving\ncontact info"; @@ -1037,6 +1040,7 @@ "slashtags__error_deleting_profile" = "Unable To Delete Profile"; "slashtags__error_pay_title" = "Unable To Pay Contact"; "slashtags__error_pay_empty_msg" = "The contact you're trying to send to hasn't enabled payments."; +"slashtags__error_pay_not_opened_msg" = "No compatible payment endpoint is available."; "slashtags__auth_depricated_title" = "Deprecated"; "slashtags__auth_depricated_msg" = "Slashauth is deprecated. Please use Bitkit Beta."; "profile__nav_title" = "Profile"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index d871d109..5874326d 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -395,6 +395,7 @@ class ActivityService { var isTransfer = existingOnchain?.isTransfer ?? false var channelId = existingOnchain?.channelId let transferTxId = existingOnchain?.transferTxId + let contact = existingOnchain?.contact let feeRate = existingOnchain?.feeRate ?? 1 let preservedAddress = existingOnchain?.address ?? "Loading..." let doesExist = existingOnchain?.doesExist ?? true @@ -466,6 +467,7 @@ class ActivityService { confirmTimestamp: blockTimestamp, channelId: channelId, transferTxId: transferTxId, + contact: contact, createdAt: UInt64(payment.creationTime.timeIntervalSince1970), updatedAt: paymentTimestamp, seenAt: seenAt @@ -691,6 +693,7 @@ class ActivityService { message: description ?? "", timestamp: paymentTimestamp, preimage: preimage, + contact: existingLightning?.contact, createdAt: paymentTimestamp, updatedAt: paymentTimestamp, seenAt: existingLightning?.seenAt @@ -960,6 +963,21 @@ class ActivityService { } } + func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] { + let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + // 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) + } + } + } + func update(id: String, activity: Activity) async throws { try await ServiceQueue.background(.core) { try updateActivity(activityId: id, activity: activity) @@ -982,7 +1000,8 @@ class ActivityService { address: String, amount: UInt64, fee: UInt64, - feeRate: UInt32 + feeRate: UInt32, + contact: String? = nil ) async { do { try await ServiceQueue.background(.core) { @@ -1008,6 +1027,7 @@ class ActivityService { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }, createdAt: now, updatedAt: now, seenAt: now @@ -1022,6 +1042,32 @@ class ActivityService { } } + func setContact(_ publicKey: String?, forActivity id: String) async throws { + let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } + + try await ServiceQueue.background(.core) { + guard let activity = try getActivityById(activityId: id) else { + throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found") + } + + switch activity { + case var .lightning(lightning): + guard lightning.contact != normalizedContact else { return } + lightning.contact = normalizedContact + lightning.updatedAt = UInt64(Date().timeIntervalSince1970) + try updateActivity(activityId: 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() + } + } + } + func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { // Rebuild cache if deleting an onchain activity with boostTxIds @@ -1217,6 +1263,7 @@ class ActivityService { message: template.message, timestamp: timestamp, preimage: template.status == .succeeded ? "preimage\(activityId)" : nil, + contact: nil, createdAt: timestamp, updatedAt: timestamp, seenAt: nil @@ -1241,6 +1288,7 @@ class ActivityService { confirmTimestamp: template.confirmed == true ? timestamp + 3600 : nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: timestamp, updatedAt: timestamp, seenAt: nil diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 13d84719..b3d3c5f3 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1404,6 +1404,7 @@ extension MigrationsService { message: item.message ?? "", timestamp: timestampSecs, preimage: item.preimage, + contact: nil, createdAt: timestampSecs, updatedAt: timestampSecs, seenAt: now @@ -1853,6 +1854,7 @@ extension MigrationsService { confirmTimestamp: item.confirmTimestamp.map { UInt64($0 / 1000) }, channelId: item.channelId, transferTxId: item.transferTxId, + contact: nil, createdAt: activityTimestamp, updatedAt: activityTimestamp, seenAt: now diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift new file mode 100644 index 00000000..dd1b54d5 --- /dev/null +++ b/Bitkit/Services/PublicPaykitService.swift @@ -0,0 +1,488 @@ +import BitkitCore +import Foundation +import LDKNode + +enum PublicPaykitError: LocalizedError { + case noSupportedEndpoint + case walletNotReady + case invalidPayload + + var errorDescription: String? { + switch self { + case .noSupportedEndpoint: + return "No supported public payment endpoint is available." + case .walletNotReady: + return "Bitkit could not prepare a public payment endpoint because the wallet is not ready." + case .invalidPayload: + return "The public payment endpoint payload is invalid." + } + } +} + +enum PublicPaykitPaymentLaunchResult { + case opened(paymentRequest: String) + case noEndpoint + case notOpened + + var contactPaymentFailureMessageKey: String? { + switch self { + case .opened: + nil + case .noEndpoint: + "slashtags__error_pay_empty_msg" + case .notOpened: + "slashtags__error_pay_not_opened_msg" + } + } +} + +private actor PublicPaykitEndpointLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func withLock(_ operation: () async throws -> T) async throws -> T { + await lock() + defer { unlock() } + return try await operation() + } + + private func lock() async { + if !isLocked { + isLocked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func unlock() { + guard !waiters.isEmpty else { + isLocked = false + return + } + + waiters.removeFirst().resume() + } +} + +enum PublicPaykitService { + private static let endpointLock = PublicPaykitEndpointLock() + enum MethodId: String, Hashable, CaseIterable { + case bitcoinLightningBolt11 = "btc-lightning-bolt11" + case bitcoinLightningLnurl = "btc-lightning-lnurl" + case bitcoinOnchainP2tr = "btc-bitcoin-p2tr" + case bitcoinOnchainP2wpkh = "btc-bitcoin-p2wpkh" + case bitcoinOnchainP2sh = "btc-bitcoin-p2sh" + case bitcoinOnchainP2pkh = "btc-bitcoin-p2pkh" + case testnetOnchainP2tr = "btc-testnet-p2tr" + case testnetOnchainP2wpkh = "btc-testnet-p2wpkh" + case testnetOnchainP2sh = "btc-testnet-p2sh" + case testnetOnchainP2pkh = "btc-testnet-p2pkh" + case signetOnchainP2tr = "btc-signet-p2tr" + case signetOnchainP2wpkh = "btc-signet-p2wpkh" + case signetOnchainP2sh = "btc-signet-p2sh" + case signetOnchainP2pkh = "btc-signet-p2pkh" + case regtestOnchainP2tr = "btc-regtest-p2tr" + case regtestOnchainP2wpkh = "btc-regtest-p2wpkh" + case regtestOnchainP2sh = "btc-regtest-p2sh" + case regtestOnchainP2pkh = "btc-regtest-p2pkh" + + static let payablePreferenceOrder: [MethodId] = [ + .bitcoinLightningBolt11, + .bitcoinLightningLnurl, + ] + onchainPreferenceOrder + + static let publishableMethodIds: [MethodId] = [ + .bitcoinLightningBolt11, + ] + onchainPreferenceOrder + + static let onchainPreferenceOrder: [MethodId] = [ + .bitcoinOnchainP2tr, + .testnetOnchainP2tr, + .signetOnchainP2tr, + .regtestOnchainP2tr, + .bitcoinOnchainP2wpkh, + .testnetOnchainP2wpkh, + .signetOnchainP2wpkh, + .regtestOnchainP2wpkh, + .bitcoinOnchainP2sh, + .testnetOnchainP2sh, + .signetOnchainP2sh, + .regtestOnchainP2sh, + .bitcoinOnchainP2pkh, + .testnetOnchainP2pkh, + .signetOnchainP2pkh, + .regtestOnchainP2pkh, + ] + + var onchainNetwork: LDKNode.Network? { + switch self { + case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh: + .bitcoin + case .testnetOnchainP2tr, .testnetOnchainP2wpkh, .testnetOnchainP2sh, .testnetOnchainP2pkh: + .testnet + case .signetOnchainP2tr, .signetOnchainP2wpkh, .signetOnchainP2sh, .signetOnchainP2pkh: + .signet + case .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh: + .regtest + case .bitcoinLightningBolt11, .bitcoinLightningLnurl: + nil + } + } + + static func onchainMethodId(network: LDKNode.Network, scriptType: OnchainScriptType) -> MethodId { + switch (network, scriptType) { + case (.bitcoin, .p2tr): .bitcoinOnchainP2tr + case (.bitcoin, .p2wpkh): .bitcoinOnchainP2wpkh + case (.bitcoin, .p2sh): .bitcoinOnchainP2sh + case (.bitcoin, .p2pkh): .bitcoinOnchainP2pkh + case (.testnet, .p2tr): .testnetOnchainP2tr + case (.testnet, .p2wpkh): .testnetOnchainP2wpkh + case (.testnet, .p2sh): .testnetOnchainP2sh + case (.testnet, .p2pkh): .testnetOnchainP2pkh + case (.signet, .p2tr): .signetOnchainP2tr + case (.signet, .p2wpkh): .signetOnchainP2wpkh + case (.signet, .p2sh): .signetOnchainP2sh + case (.signet, .p2pkh): .signetOnchainP2pkh + case (.regtest, .p2tr): .regtestOnchainP2tr + case (.regtest, .p2wpkh): .regtestOnchainP2wpkh + case (.regtest, .p2sh): .regtestOnchainP2sh + case (.regtest, .p2pkh): .regtestOnchainP2pkh + } + } + } + + enum OnchainScriptType { + case p2tr + case p2wpkh + case p2sh + case p2pkh + } + + struct Endpoint: Equatable, Hashable { + let methodId: MethodId + let value: String + let min: String? + let max: String? + let rawPayload: String + + var paymentRequest: String { + value + } + } + + struct EndpointSyncPlan: Equatable { + let endpointsToSet: [Endpoint] + let methodIdsToRemove: [MethodId] + } + + static func fetchPublicEndpoints(publicKey: String) async throws -> [Endpoint] { + let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + let paymentEntries = try await PubkyService.getPaymentList(publicKey: normalizedKey) + var endpointsByMethodId: [MethodId: Endpoint] = [:] + + for entry in paymentEntries { + guard let endpoint = parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData) else { + continue + } + + endpointsByMethodId[endpoint.methodId] = endpoint + } + + return MethodId.payablePreferenceOrder.compactMap { endpointsByMethodId[$0] } + } + + static func parseEndpoint(methodId rawMethodId: String, endpointData: String) -> Endpoint? { + guard let methodId = MethodId(rawValue: rawMethodId) else { + return nil + } + + guard let payload = parsePayload(endpointData) else { + return nil + } + + return Endpoint( + methodId: methodId, + value: payload.value, + min: payload.min, + max: payload.max, + rawPayload: endpointData + ) + } + + static func serializePayload(value: String) throws -> String { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty else { + throw PublicPaykitError.invalidPayload + } + + let payload = ["value": trimmedValue] + let data = try JSONSerialization.data(withJSONObject: payload) + guard let json = String(data: data, encoding: .utf8) else { + throw PublicPaykitError.invalidPayload + } + return json + } + + @MainActor + static func syncPublishedEndpoints(wallet: WalletViewModel, publish: Bool) async throws { + guard publish else { + try await removePublishedEndpoints() + return + } + + let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true) + try await applyPublishedEndpoints(desiredEndpoints) + } + + @MainActor + static func syncCurrentPublishedEndpoints(wallet: WalletViewModel) async throws { + let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false) + try await applyPublishedEndpoints(desiredEndpoints) + } + + static func removePublishedEndpoints() async throws { + try await endpointLock.withLock { + let existingMethodIds = try await currentPublishedMethodIds() + + for methodId in methodIdsToRemoveWhenUnpublishing(existingMethodIds: existingMethodIds) { + try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + } + } + } + + static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool { + let endpoints = try await payablePublicEndpoints(publicKey: publicKey) + return !endpoints.isEmpty + } + + static func payablePublicEndpoints(publicKey: String) async throws -> [Endpoint] { + let endpoints = try await fetchPublicEndpoints(publicKey: publicKey) + return await payableEndpoints(from: endpoints) + } + + static func payableEndpoints(from endpoints: [Endpoint]) async -> [Endpoint] { + var payableEndpoints: [Endpoint] = [] + + for endpoint in endpoints { + if await isPayableEndpoint(endpoint) { + payableEndpoints.append(endpoint) + } + } + + return payableEndpoints + } + + static func beginPayment( + to publicKey: String + ) async throws -> PublicPaykitPaymentLaunchResult { + let endpoints = try await fetchPublicEndpoints(publicKey: publicKey) + let payableEndpoints = await payableEndpoints(from: endpoints) + + guard !payableEndpoints.isEmpty else { + return endpoints.isEmpty ? .noEndpoint : .notOpened + } + + return .opened(paymentRequest: paymentRequest(from: payableEndpoints)) + } + + static func paymentRequest(from endpoints: [Endpoint]) -> String { + guard let onchainEndpoint = MethodId.onchainPreferenceOrder.compactMap({ methodId in endpoints.first { $0.methodId == methodId } }).first, + let bolt11Endpoint = endpoints.first(where: { $0.methodId == .bitcoinLightningBolt11 }) + else { + return endpoints.first?.paymentRequest ?? "" + } + + var allowedCharacters = CharacterSet.urlQueryAllowed + allowedCharacters.remove(charactersIn: "?&=") + let lightningPaymentRequest = bolt11Endpoint.paymentRequest + let encodedLightning = lightningPaymentRequest.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? lightningPaymentRequest + return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(encodedLightning)" + } + + static func onchainMethodId(for address: String, network: LDKNode.Network = Env.network) -> MethodId { + let normalizedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let scriptType: OnchainScriptType = if normalizedAddress.hasPrefix("bc1p") || normalizedAddress.hasPrefix("tb1p") || normalizedAddress + .hasPrefix("bcrt1p") + { + .p2tr + } else if normalizedAddress.hasPrefix("bc1q") || normalizedAddress.hasPrefix("tb1q") || normalizedAddress.hasPrefix("bcrt1q") { + .p2wpkh + } else if normalizedAddress.hasPrefix("3") || normalizedAddress.hasPrefix("2") { + .p2sh + } else { + .p2pkh + } + + return MethodId.onchainMethodId(network: network, scriptType: scriptType) + } + + static func methodIdsToRemoveWhenUnpublishing(existingMethodIds: Set) -> [MethodId] { + MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) } + } + + static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { + let desiredMethodIds = Set(desiredEndpoints.map(\.methodId)) + return EndpointSyncPlan( + endpointsToSet: desiredEndpoints.filter { existingEndpoints[$0.methodId] != $0.rawPayload }, + methodIdsToRemove: MethodId.publishableMethodIds.filter { existingEndpoints[$0] != nil && !desiredMethodIds.contains($0) } + ) + } + + private struct ParsedPayload { + let value: String + let min: String? + let max: String? + } + + private static func parsePayload(_ endpointData: String) -> ParsedPayload? { + let trimmedPayload = endpointData.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPayload.isEmpty else { + return nil + } + + if let data = trimmedPayload.data(using: .utf8), + let payloadObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = (payloadObject["value"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return ParsedPayload( + value: value, + min: payloadObject["min"] as? String, + max: payloadObject["max"] as? String + ) + } + + return nil + } + + private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { + try await endpointLock.withLock { + let existingEndpoints = try await currentPublishedEndpoints() + let plan = publishedEndpointSyncPlan(existingEndpoints: existingEndpoints, desiredEndpoints: desiredEndpoints) + + for endpoint in plan.endpointsToSet { + try await PubkyService.setPaymentEndpoint( + methodId: endpoint.methodId.rawValue, + endpointData: endpoint.rawPayload + ) + } + + for methodId in plan.methodIdsToRemove { + try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + } + } + } + + private static func currentPublishedMethodIds() async throws -> Set { + let endpoints = try await currentPublishedEndpoints() + return Set(endpoints.keys) + } + + private static func currentPublishedEndpoints() async throws -> [MethodId: String] { + guard let publicKey = await PubkyService.currentPublicKey() else { + throw PubkyServiceError.sessionNotActive + } + + let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey) + var endpoints: [MethodId: String] = [:] + for entry in paymentEntries { + guard let methodId = MethodId(rawValue: entry.methodId) else { + continue + } + + endpoints[methodId] = entry.endpointData + } + + return endpoints + } + + @MainActor + private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool) async throws -> [Endpoint] { + if refreshIfNeeded { + let isNodeReady = await wallet.waitForNodeToRun() + let lifecycleState = wallet.nodeLifecycleState + guard isNodeReady || lifecycleState == .running else { + throw PublicPaykitError.walletNotReady + } + + _ = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: true) + } + + let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: false) + var endpoints: [Endpoint] = [] + + let onchainAddress = publicEndpoints.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines) + if !onchainAddress.isEmpty { + try endpoints.append( + Endpoint( + methodId: onchainMethodId(for: onchainAddress), + value: onchainAddress, + min: nil, + max: nil, + rawPayload: serializePayload(value: onchainAddress) + ) + ) + } + + let bolt11 = publicEndpoints.bolt11.trimmingCharacters(in: .whitespacesAndNewlines) + if !bolt11.isEmpty { + try endpoints.append( + Endpoint( + methodId: .bitcoinLightningBolt11, + value: bolt11, + min: nil, + max: nil, + rawPayload: serializePayload(value: bolt11) + ) + ) + } + + guard !endpoints.isEmpty else { + throw PublicPaykitError.noSupportedEndpoint + } + + return endpoints + } + + private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool { + switch endpoint.methodId { + case .bitcoinLightningBolt11: + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.paymentRequest) else { + return false + } + + guard !invoice.isExpired else { + return false + } + + let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType) + return !NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) + + case .bitcoinLightningLnurl: + guard case .lnurlPay = try? await decode(invoice: endpoint.paymentRequest) else { + return false + } + + return true + + case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh, + .testnetOnchainP2tr, .testnetOnchainP2wpkh, .testnetOnchainP2sh, .testnetOnchainP2pkh, + .signetOnchainP2tr, .signetOnchainP2wpkh, .signetOnchainP2sh, .signetOnchainP2pkh, + .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh: + guard endpoint.methodId.onchainNetwork == Env.network else { + return false + } + + guard case let .onChain(invoice) = try? await decode(invoice: endpoint.paymentRequest) else { + return false + } + + let addressValidation = try? validateBitcoinAddress(address: invoice.address) + let addressNetwork = addressValidation.map { NetworkValidationHelper.convertNetworkType($0.network) } + return !NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) + } + } +} diff --git a/Bitkit/Services/ZipService.swift b/Bitkit/Services/ZipService.swift index 1a812bc4..29e84184 100644 --- a/Bitkit/Services/ZipService.swift +++ b/Bitkit/Services/ZipService.swift @@ -64,12 +64,14 @@ extension FileToZip { } private static func sanitizedFilename(_ filename: String) -> String { - filename + let sanitized = filename .replacingOccurrences(of: "\\", with: "/") .split(separator: "/") .last .map(String.init)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + return sanitized == "." || sanitized == ".." ? "" : sanitized } } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index ac5de593..a93fc784 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -9,6 +9,8 @@ enum AppReset { session: SessionManager, toastType: Toast.ToastType = .success ) async throws { + await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") + // Set wiping flag to prevent backups during wipe operations BackupService.shared.setWiping(true) defer { diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 84b351a5..a9c30fd1 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -146,4 +146,38 @@ struct PaymentNavigationHelper { // No valid invoice data return nil } + + static func contactPaymentRoute( + app: AppViewModel, + currency: CurrencyViewModel, + settings: SettingsViewModel + ) -> SendRoute? { + guard let route = appropriateSendRoute(app: app, currency: currency, settings: settings) else { + return nil + } + + switch route { + case .quickpay: + if let lnurlPayData = app.lnurlPayData { + return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount + } + + if let invoice = app.scannedLightningInvoice { + return invoice.amountSatoshis == 0 ? .amount : .confirm + } + + if app.scannedOnchainInvoice != nil { + return .amount + } + + return route + case .confirm: + if let invoice = app.scannedLightningInvoice { + return invoice.amountSatoshis == 0 ? .amount : .confirm + } + return route + default: + return route + } + } } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index de18a427..944c4473 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -47,6 +47,10 @@ class ActivityListViewModel: ObservableObject { @Published private(set) var availableTags: [String] = [] + var activitiesChangedPublisher: AnyPublisher { + coreService.activity.activitiesChangedPublisher + } + private func updateAvailableTags() async { do { availableTags = try await coreService.activity.allPossibleTags() @@ -133,7 +137,7 @@ class ActivityListViewModel: ObservableObject { func syncState() async { do { // Get latest activities first as that's displayed on the home view - let limitLatest: UInt32 = 4 + let limitLatest = UInt32(ActivityDisplayConstants.maxHomeActivityItems) // Fetch extra to account for potential filtering of replaced transactions let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3) let filtered = await filterOutReplacedSentTransactions(latest) @@ -268,6 +272,20 @@ class ActivityListViewModel: ObservableObject { return activity } + func contactActivities(publicKey: String) async throws -> [Activity] { + let activities = try await coreService.activity.get(contact: publicKey, sortDirection: .desc) + return await filterOutReplacedSentTransactions(activities) + } + + func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws { + if syncLdkPayments { + try? await syncLdkNodePayments() + } + + try await coreService.activity.setContact(contactPublicKey, forActivity: paymentId) + await syncState() + } + func getAllPossibleTags() async throws -> [String] { try await coreService.activity.allPossibleTags() } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 07dc3be4..75252920 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -10,6 +10,10 @@ struct SendSheetPendingResolution: Equatable { let success: Bool } +struct ContactPaymentContext: Equatable { + let publicKey: String +} + enum ManualEntryValidationResult: Equatable { case valid case empty @@ -28,6 +32,7 @@ class AppViewModel: ObservableObject { @Published var manualEntryInput: String = "" @Published var isManualEntryInputValid: Bool = false @Published var manualEntryValidationResult: ManualEntryValidationResult = .empty + @Published var contactPaymentContext: ContactPaymentContext? // LNURL @Published var lnurlPayData: LnurlPayData? @@ -62,6 +67,7 @@ class AppViewModel: ObservableObject { /// Payment hashes for which we navigated to the pending screen. /// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate. private var pendingPaymentHashes: Set = [] + private var pendingContactPaymentContexts: [String: ContactPaymentContext] = [:] /// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate. /// Consumed by SendPendingScreen via consumeSendSheetPendingResolution. @@ -288,8 +294,25 @@ extension AppViewModel { // MARK: Pending payment tracking extension AppViewModel { - func addPendingPaymentHash(_ hash: String) { + func addPendingPaymentHash(_ hash: String, contactPublicKey: String? = nil) { pendingPaymentHashes.insert(hash) + + if let contactPublicKey { + pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey) + } + } + + func addPendingContactPaymentContext(_ hash: String, contactPublicKey: String?) { + guard let contactPublicKey else { return } + pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey) + } + + func contactPaymentContext(forPendingPaymentHash hash: String) -> ContactPaymentContext? { + pendingContactPaymentContexts[hash] + } + + func consumeContactPaymentContext(forPendingPaymentHash hash: String) { + pendingContactPaymentContexts.removeValue(forKey: hash) } /// Called by SendPendingScreen when it consumes a resolution. Clears the published value. @@ -586,6 +609,7 @@ extension AppViewModel { selectedWalletToPayFrom = .onchain // Reset to default lnurlPayData = nil lnurlWithdrawData = nil + contactPaymentContext = nil } } @@ -774,6 +798,7 @@ extension AppViewModel { message: "", timestamp: now, preimage: nil, + contact: nil, createdAt: now, updatedAt: nil, seenAt: nil diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index c26ea2e4..a2995bda 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -12,6 +12,7 @@ enum Route: Hashable { case contacts case contactsIntro case contactDetail(publicKey: String) + case contactActivity(publicKey: String) case contactImportOverview case contactImportSelect case addContact(publicKey: String) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 782b82a1..ee91fee6 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -17,6 +17,9 @@ class WalletViewModel: ObservableObject { @AppStorage("onchainAddress") var onchainAddress = "" @AppStorage("bolt11") var bolt11 = "" @AppStorage("bip21") var bip21 = "" + @AppStorage("publicPaykitBolt11") var publicPaykitBolt11 = "" + @AppStorage("publicPaykitBolt11PaymentHash") var publicPaykitBolt11PaymentHash = "" + @AppStorage("publicPaykitBolt11ExpiresAt") var publicPaykitBolt11ExpiresAt = 0.0 @AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better anticipate the receive flow UI // Send flow @@ -56,6 +59,9 @@ class WalletViewModel: ObservableObject { private var probeOutcomes: [PaymentId: ProbeOutcome] = [:] @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false + @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false + + private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 30 * 60 private let lightningService: LightningService private let coreService: CoreService @@ -200,7 +206,14 @@ class WalletViewModel: ObservableObject { paymentHash: paymentHash, shortChannelId: shortChannelId ) - case .paymentReceived, .channelReady: + case let .paymentReceived(_, paymentHash, _, _): + self.bolt11 = "" + self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) + Task { + await self.refreshAndSyncState() + try? await self.refreshBip21() + } + case .channelReady: self.bolt11 = "" Task { await self.refreshAndSyncState() @@ -928,16 +941,8 @@ class WalletViewModel: ObservableObject { return channels?.contains(where: \.isUsable) ?? false } - func refreshBip21(forceRefreshBolt11: Bool = false) async throws { - // Get old payment ID and tags before refreshing (which may change payment ID) - let oldPaymentId = await paymentId() - var tagsToMigrate: [String] = [] - if let oldPaymentId, !oldPaymentId.isEmpty { - if let oldMetadata = try? await coreService.activity.getPreActivityMetadata(searchKey: oldPaymentId, searchByAddress: false) { - tagsToMigrate = oldMetadata.tags - } - } - + @discardableResult + private func refreshReusableOnchainAddress() async throws -> String { if onchainAddress.isEmpty { onchainAddress = try await lightningService.newAddress() } else { @@ -950,6 +955,81 @@ class WalletViewModel: ObservableObject { } } + return onchainAddress + } + + func refreshPublicPaykitEndpoints(forceRefreshBolt11: Bool = false) async throws -> (onchainAddress: String, bolt11: String) { + let publicOnchainAddress = try await refreshReusableOnchainAddress() + + if hasReadyChannels { + let hasReusableInvoice = await hasReusablePublicPaykitInvoice() + let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice + if shouldRefreshBolt11 { + try await refreshPublicPaykitBolt11() + } + } else { + clearPublicPaykitBolt11() + } + + return (publicOnchainAddress, publicPaykitBolt11) + } + + func refreshPublicPaykitEndpointsOnForeground() async { + guard sharesPublicPaykitEndpoints else { return } + + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) + } catch { + Logger.warn("Failed to refresh public Paykit endpoints on foreground: \(error)", context: "WalletViewModel") + } + } + + private func hasReusablePublicPaykitInvoice() async -> Bool { + guard !publicPaykitBolt11.isEmpty else { return false } + guard publicPaykitBolt11ExpiresAt > Date().timeIntervalSince1970 + Self.publicPaykitInvoiceRefreshBufferSeconds else { return false } + + guard case let .lightning(lightningInvoice) = try? await decode(invoice: publicPaykitBolt11) else { return false } + return !lightningInvoice.isExpired && lightningInvoice.amountSatoshis == 0 && (lightningInvoice.description ?? "").isEmpty + } + + private func refreshPublicPaykitBolt11() async throws { + let invoice = try await createInvoice(amountSats: nil, note: "") + guard case let .lightning(lightningInvoice) = try await decode(invoice: invoice) else { + clearPublicPaykitBolt11() + throw PublicPaykitError.invalidPayload + } + + publicPaykitBolt11 = invoice + publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex + publicPaykitBolt11ExpiresAt = Double(lightningInvoice.timestampSeconds + lightningInvoice.expirySeconds) + } + + private func clearPublicPaykitBolt11() { + publicPaykitBolt11 = "" + publicPaykitBolt11PaymentHash = "" + publicPaykitBolt11ExpiresAt = 0 + } + + private func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) { + guard !publicPaykitBolt11PaymentHash.isEmpty, + publicPaykitBolt11PaymentHash == paymentHash + else { return } + + clearPublicPaykitBolt11() + } + + func refreshBip21(forceRefreshBolt11: Bool = false) async throws { + // Get old payment ID and tags before refreshing (which may change payment ID) + let oldPaymentId = await paymentId() + var tagsToMigrate: [String] = [] + if let oldPaymentId, !oldPaymentId.isEmpty { + if let oldMetadata = try? await coreService.activity.getPreActivityMetadata(searchKey: oldPaymentId, searchByAddress: false) { + tagsToMigrate = oldMetadata.tags + } + } + + try await refreshReusableOnchainAddress() + var newBip21 = "bitcoin:\(onchainAddress)" let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil @@ -992,6 +1072,14 @@ class WalletViewModel: ObservableObject { // Persist metadata with migrated tags await persistPreActivityMetadata(tags: tagsToMigrate) + + if sharesPublicPaykitEndpoints { + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) + } catch { + Logger.warn("Failed to refresh public paykit endpoints after receive refresh: \(error)", context: "WalletViewModel") + } + } } /// Payment hash from the current bolt11 invoice, if available @@ -1154,6 +1242,7 @@ class WalletViewModel: ObservableObject { onchainAddress = "" bolt11 = "" bip21 = "" + clearPublicPaykitBolt11() try? await coreService.activity.removeAll() diff --git a/Bitkit/Views/Contacts/AddContactSheet.swift b/Bitkit/Views/Contacts/AddContactSheet.swift index e89bd8fc..b33d6992 100644 --- a/Bitkit/Views/Contacts/AddContactSheet.swift +++ b/Bitkit/Views/Contacts/AddContactSheet.swift @@ -4,13 +4,14 @@ struct AddContactSheet: View { @Environment(\.dismiss) private var dismiss let currentPublicKey: String? + let contacts: [PubkyContact] let onAdd: (String) -> Void let onScanQR: () -> Void @State private var pubkyInput: String = "" private var validationResult: AddContactValidationResult { - resolveAddContactValidation(input: pubkyInput, ownPublicKey: currentPublicKey) + resolveAddContactValidation(input: pubkyInput, ownPublicKey: currentPublicKey, existingContacts: contacts) } private var validationMessage: String? { @@ -116,7 +117,7 @@ struct AddContactSheet: View { #Preview { Color.clear .sheet(isPresented: .constant(true)) { - AddContactSheet(currentPublicKey: nil, onAdd: { _ in }, onScanQR: {}) + AddContactSheet(currentPublicKey: nil, contacts: [], onAdd: { _ in }, onScanQR: {}) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 8a8ba0a3..4004ad38 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -2,17 +2,21 @@ import SwiftUI struct AddContactView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var sheets: SheetViewModel let publicKey: String @State private var fetchedProfile: PubkyProfile? + @State private var hasPublicPaymentEndpoint = false @State private var isLoading = true @State private var isSaving = false @State private var errorMessage: String? - @State private var canRetry = true + @State private var canRetryError = true private var truncatedPublicKey: String { let displayKey = normalizedPublicKey ?? publicKey @@ -21,7 +25,11 @@ struct AddContactView: View { } private var normalizedPublicKey: String? { - if case let .valid(normalizedKey) = resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) { + if case let .valid(normalizedKey) = resolveAddContactValidation( + input: publicKey, + ownPublicKey: pubkyProfile.publicKey, + existingContacts: contactsManager.contacts + ) { return normalizedKey } @@ -33,7 +41,7 @@ struct AddContactView: View { NavigationBar(title: t("contacts__add_title")) .padding(.horizontal, 16) - if isLoading && fetchedProfile == nil { + if isLoading { loadingContent } else if let profile = fetchedProfile { resultContent(profile) @@ -53,22 +61,13 @@ struct AddContactView: View { @State private var dashedCircleRotation: Double = 0 - @ViewBuilder private var loadingContent: some View { VStack(spacing: 0) { CaptionMText(truncatedPublicKey, textColor: .white64) .padding(.top, 24) .padding(.bottom, 16) - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 80, height: 80) - .overlay { - Text(String(publicKey.prefix(1)).uppercased()) - .font(Fonts.bold(size: 28)) - .foregroundColor(.textPrimary) - } - .accessibilityHidden(true) + ContactAvatarLetter(source: publicKey, size: 80) .padding(.bottom, 24) DisplayText(t("contacts__add_retrieving"), accentColor: .pubkyGreen) @@ -92,7 +91,6 @@ struct AddContactView: View { } } - @ViewBuilder private var retrievingAnimation: some View { ZStack { Image("ellipse-outer-green") @@ -116,7 +114,6 @@ struct AddContactView: View { // MARK: - Result State - @ViewBuilder private func resultContent(_ profile: PubkyProfile) -> some View { VStack(spacing: 0) { ScrollView { @@ -144,6 +141,15 @@ struct AddContactView: View { .padding(.horizontal, 32) .padding(.bottom, 16) + if hasPublicPaymentEndpoint { + CustomButton(title: t("wallet__send"), variant: .secondary) { + await payContact() + } + .accessibilityIdentifier("AddContactPay") + .padding(.horizontal, 32) + .padding(.bottom, 16) + } + HStack(spacing: 16) { CustomButton(title: t("common__discard"), variant: .secondary) { navigation.navigateBack() @@ -163,7 +169,6 @@ struct AddContactView: View { // MARK: - Error State - @ViewBuilder private var errorContent: some View { VStack(spacing: 16) { Spacer() @@ -173,17 +178,15 @@ struct AddContactView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) - if canRetry { - CustomButton(title: t("profile__retry_load"), variant: .secondary) { + CustomButton(title: canRetryError ? t("common__retry") : t("common__discard"), variant: .secondary) { + if canRetryError { await loadProfile() - } - .accessibilityIdentifier("AddContactRetry") - } else { - CustomButton(title: t("common__discard"), variant: .secondary) { + } else { navigation.navigateBack() } - .accessibilityIdentifier("AddContactDiscardInvalid") } + .accessibilityIdentifier(canRetryError ? "AddContactRetry" : "AddContactDiscard") + Spacer() } .padding(.horizontal, 32) @@ -196,17 +199,26 @@ struct AddContactView: View { isLoading = true fetchedProfile = nil errorMessage = nil - canRetry = true + canRetryError = true - switch resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) { + switch resolveAddContactValidation( + input: publicKey, + ownPublicKey: pubkyProfile.publicKey, + existingContacts: contactsManager.contacts + ) { case .empty, .invalidKey: errorMessage = t("contacts__add_error_invalid_key") - canRetry = false + canRetryError = false isLoading = false return case .ownKey: errorMessage = t("contacts__add_error_self") - canRetry = false + canRetryError = false + isLoading = false + return + case .existingContact: + errorMessage = t("contacts__add_error_existing") + canRetryError = false isLoading = false return case let .valid(normalizedKey): @@ -215,6 +227,7 @@ struct AddContactView: View { } else { errorMessage = t("contacts__add_error") } + await loadPaymentEndpoints(publicKey: normalizedKey) } isLoading = false @@ -244,15 +257,86 @@ struct AddContactView: View { app.toast(type: .error, title: t("contacts__add_error"), description: error.localizedDescription) } } + + private func loadPaymentEndpoints(publicKey: String) async { + do { + hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) + } catch { + Logger.warn( + "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "AddContactView" + ) + hasPublicPaymentEndpoint = false + } + } + + private func payContact() async { + guard let normalizedPublicKey else { + app.toast(type: .warning, title: t("slashtags__error_pay_title"), description: t("slashtags__error_pay_empty_msg")) + return + } + + do { + let result = try await PublicPaykitService.beginPayment(to: normalizedPublicKey) + + switch result { + case let .opened(paymentRequest): + guard await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey) else { + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) + return + } + case .noEndpoint, .notOpened: + if let messageKey = result.contactPaymentFailureMessageKey { + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t(messageKey) + ) + } + } + } catch { + Logger.error("Failed to pay public pubky \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "AddContactView") + app.toast( + type: .error, + title: t("slashtags__error_pay_title"), + description: error.localizedDescription + ) + } + } + + @MainActor + private func openContactPayment(paymentRequest: String, publicKey: String) async -> Bool { + do { + try await app.handleScannedData(paymentRequest) + } catch { + Logger.warn("Failed to decode contact payment request: \(error)", context: "AddContactView") + return false + } + + guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { + return false + } + + app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) + sheets.showSheet(.send, data: SendConfig(view: route)) + return true + } } #Preview { NavigationStack { AddContactView(publicKey: "pubkyz6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) .environmentObject(PubkyProfileManager()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift new file mode 100644 index 00000000..78f47995 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -0,0 +1,158 @@ +import BitkitCore +import SwiftUI + +struct ContactActivityView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var activityList: ActivityListViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager + + let publicKey: String + + @State private var activities: [Activity] = [] + @State private var isLoading = true + @State private var hasError = false + @State private var contactName = "" + + private var groupedActivities: [ActivityGroupItem] { + activityList.groupActivities(activities) + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: contactName.isEmpty ? t("wallet__activity") : contactName) + .padding(.horizontal, 16) + + if isLoading { + loadingContent + } else if hasError { + errorContent + } else if groupedActivities.isEmpty { + emptyContent + } else { + activityContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + resolveContactName() + await loadActivities(showLoading: true) + } + .onReceive(activityList.activitiesChangedPublisher) { _ in + Task { + await loadActivities(showLoading: activities.isEmpty) + } + } + .onReceive(contactsManager.$contacts) { _ in + resolveContactName() + } + } + + private var activityContent: some View { + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(Array(zip(groupedActivities.indices, groupedActivities)), id: \.1) { index, groupItem in + switch groupItem { + case let .header(title): + CaptionMText(title) + .frame(height: 34, alignment: .bottom) + + case let .activity(activity): + NavigationLink(value: Route.activityDetail(activity)) { + ActivityRow( + item: activity, + feeEstimates: feeEstimatesManager.estimates, + contact: activityContact + ) + } + .accessibilityIdentifier("ContactActivity-\(index)") + } + } + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + } + + private var loadingContent: some View { + VStack { + Spacer() + ActivityIndicator(size: 32) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyContent: some View { + VStack(spacing: 16) { + Spacer() + BodyMText(t("wallet__activity_no")) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var errorContent: some View { + VStack(spacing: 16) { + Spacer() + BodyMText(t("contacts__error_loading")) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var contactDisplayName: String { + if !contactName.isEmpty { + return contactName + } + + return publicKey.ellipsis(maxLength: 18) + } + + private var activityContact: PubkyContact { + PubkyContact( + publicKey: publicKey, + profile: PubkyProfile( + publicKey: publicKey, + name: contactDisplayName, + bio: "", + imageUrl: nil, + links: [], + status: nil + ) + ) + } + + private func resolveContactName() { + contactName = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) })?.displayName ?? "" + } + + private func loadActivities(showLoading: Bool) async { + if showLoading { + isLoading = true + } + defer { + if showLoading { + isLoading = false + } + } + + do { + activities = try await activityList.contactActivities(publicKey: publicKey) + hasError = false + } catch { + Logger.error(error, context: "ContactActivityView") + if showLoading || activities.isEmpty { + activities = [] + hasError = true + } else { + hasError = false + } + app.toast(type: .error, title: t("contacts__error_loading"), description: error.localizedDescription) + } + } +} diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index b021dca3..d96b8a28 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -2,12 +2,16 @@ import SwiftUI struct ContactDetailView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var sheets: SheetViewModel let publicKey: String @State private var profile: PubkyProfile? + @State private var hasPublicPaymentEndpoint = false @State private var isLoading = true @State private var showAddTagSheet = false @State private var hasResolvedContactFromContacts = false @@ -17,7 +21,7 @@ struct ContactDetailView: View { NavigationBar(title: t("contacts__detail_title")) .padding(.horizontal, 16) - if isLoading && profile == nil { + if isLoading { loadingContent } else if let profile { contactBody(profile) @@ -33,6 +37,7 @@ struct ContactDetailView: View { profile = cached.profile hasResolvedContactFromContacts = true } + await loadPaymentEndpoints() isLoading = false } .onReceive(contactsManager.$contacts) { updatedContacts in @@ -49,7 +54,6 @@ struct ContactDetailView: View { // MARK: - Contact Body - @ViewBuilder private func contactBody(_ profile: PubkyProfile) -> some View { ScrollView { VStack(spacing: 0) { @@ -88,9 +92,22 @@ struct ContactDetailView: View { // MARK: - Action Buttons - @ViewBuilder private var contactActions: some View { HStack(spacing: 16) { + if hasPublicPaymentEndpoint { + GradientCircleButton(icon: "coins", accessibilityLabel: t("wallet__send")) { + Task { + await payContact() + } + } + .accessibilityIdentifier("ContactPay") + } + + GradientCircleButton(icon: "activity", accessibilityLabel: t("wallet__activity")) { + navigation.navigate(.contactActivity(publicKey: publicKey)) + } + .accessibilityIdentifier("ContactActivity") + GradientCircleButton(icon: "copy", accessibilityLabel: t("common__copy")) { UIPasteboard.general.string = publicKey app.toast(type: .success, title: t("common__copied")) @@ -111,7 +128,6 @@ struct ContactDetailView: View { // MARK: - Links / Metadata - @ViewBuilder private func linksSection(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(profile.links.enumerated()), id: \.element.id) { index, link in @@ -122,7 +138,6 @@ struct ContactDetailView: View { // MARK: - Tags - @ViewBuilder private func tagsSection(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("profile__create_tags_label"), textColor: .white64) @@ -140,7 +155,6 @@ struct ContactDetailView: View { } } - @ViewBuilder private var addTagButton: some View { IconActionButton( icon: "tag", @@ -203,7 +217,6 @@ struct ContactDetailView: View { // MARK: - Loading & Empty States - @ViewBuilder private var loadingContent: some View { VStack { Spacer() @@ -213,17 +226,20 @@ struct ContactDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private var emptyContent: some View { VStack(spacing: 16) { Spacer() BodyMText(t("contacts__detail_empty_state")) CustomButton(title: t("profile__retry_load"), variant: .secondary) { + isLoading = true + defer { isLoading = false } + if let contact = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) { profile = contact.profile } else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey) { profile = fetched } + await loadPaymentEndpoints() } .accessibilityIdentifier("ContactRetry") Spacer() @@ -251,14 +267,80 @@ struct ContactDetailView: View { presentingVC.present(activityVC, animated: true) } } + + private func loadPaymentEndpoints() async { + do { + hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) + } catch { + Logger.warn( + "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "ContactDetailView" + ) + hasPublicPaymentEndpoint = false + } + } + + private func payContact() async { + do { + let result = try await PublicPaykitService.beginPayment(to: publicKey) + + switch result { + case let .opened(paymentRequest): + guard await openContactPayment(paymentRequest: paymentRequest) else { + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) + return + } + case .noEndpoint, .notOpened: + if let messageKey = result.contactPaymentFailureMessageKey { + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t(messageKey) + ) + } + } + } catch { + Logger.error("Failed to pay contact \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "ContactDetailView") + app.toast( + type: .error, + title: t("slashtags__error_pay_title"), + description: error.localizedDescription + ) + } + } + + @MainActor + private func openContactPayment(paymentRequest: String) async -> Bool { + do { + try await app.handleScannedData(paymentRequest) + } catch { + Logger.warn("Failed to decode contact payment request: \(error)", context: "ContactDetailView") + return false + } + + guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { + return false + } + + app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) + sheets.showSheet(.send, data: SendConfig(view: route)) + return true + } } #Preview { NavigationStack { ContactDetailView(publicKey: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index bf1a1479..3121e9a7 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -56,7 +56,6 @@ struct ContactImportOverviewView: View { // MARK: - Profile Row - @ViewBuilder private var profileRow: some View { HStack(alignment: .top, spacing: 16) { HeadlineText(profile.name) @@ -67,14 +66,7 @@ struct ContactImportOverviewView: View { if let imageUrl = profile.imageUrl { PubkyImage(uri: imageUrl, size: 64) } else { - Circle() - .fill(Color.pubkyGreen) - .frame(width: 64, height: 64) - .overlay { - Text(String(profile.name.prefix(1)).uppercased()) - .font(Fonts.bold(size: 22)) - .foregroundColor(.textPrimary) - } + ContactAvatarLetter(source: profile.name, size: 64, backgroundColor: .pubkyGreen) } } .accessibilityHidden(true) @@ -84,7 +76,6 @@ struct ContactImportOverviewView: View { // MARK: - Contacts Summary - @ViewBuilder private var contactsSummary: some View { HStack(spacing: 16) { BodyMSBText(t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"])) @@ -112,9 +103,7 @@ struct ContactImportOverviewView: View { .fill(Color.gray4) .frame(width: 36, height: 36) .overlay { - Text("+\(overflow)") - .font(Fonts.bold(size: 12)) - .foregroundColor(.textPrimary) + CaptionBText("+\(overflow)", textColor: .textPrimary) } .overlay( Circle() @@ -136,24 +125,12 @@ struct ContactImportOverviewView: View { .stroke(Color.customBlack, lineWidth: 2) ) } else { - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 36, height: 36) - .overlay { - Text(String(contact.displayName.prefix(1)).uppercased()) - .font(Fonts.bold(size: 13)) - .foregroundColor(.textPrimary) - } - .overlay( - Circle() - .stroke(Color.customBlack, lineWidth: 2) - ) + ContactAvatarLetter(source: contact.displayName, size: 36, strokeColor: .customBlack, strokeWidth: 2) } } // MARK: - Button Bar - @ViewBuilder private var buttonBar: some View { HStack(spacing: 16) { CustomButton(title: t("contacts__import_select"), variant: .secondary) { diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift index d9c34f55..4051d44e 100644 --- a/Bitkit/Views/Contacts/ContactImportSelectView.swift +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -86,7 +86,6 @@ struct ContactImportSelectView: View { // MARK: - Checkmark - @ViewBuilder private func checkmark(isSelected: Bool) -> some View { ZStack { if isSelected { @@ -109,20 +108,12 @@ struct ContactImportSelectView: View { // MARK: - Contact Avatar - @ViewBuilder private func contactAvatar(name: String, imageUrl: String?) -> some View { Group { if let imageUrl { PubkyImage(uri: imageUrl, size: 48) } else { - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 48, height: 48) - .overlay { - Text(String(name.prefix(1)).uppercased()) - .font(Fonts.bold(size: 17)) - .foregroundColor(.textPrimary) - } + ContactAvatarLetter(source: name, size: 48) } } .accessibilityHidden(true) @@ -130,7 +121,6 @@ struct ContactImportSelectView: View { // MARK: - Footer Bar - @ViewBuilder private var footerBar: some View { VStack(spacing: 0) { CustomDivider() @@ -167,12 +157,9 @@ struct ContactImportSelectView: View { // MARK: - Pill Button - @ViewBuilder private func pillButton(title: String, isActive: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { - Text(title) - .font(Fonts.medium(size: 13)) - .foregroundColor(isActive ? .white64 : .textPrimary) + CaptionText(title, textColor: isActive ? .white64 : .textPrimary) .padding(.horizontal, 14) .padding(.vertical, 6) .background( diff --git a/Bitkit/Views/Contacts/ContactsIntroView.swift b/Bitkit/Views/Contacts/ContactsIntroView.swift index 31bb769a..37b811cd 100644 --- a/Bitkit/Views/Contacts/ContactsIntroView.swift +++ b/Bitkit/Views/Contacts/ContactsIntroView.swift @@ -4,6 +4,7 @@ struct ContactsIntroView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var contactsManager: ContactsManager var body: some View { OnboardingView( @@ -11,10 +12,11 @@ struct ContactsIntroView: View { title: t("contacts__intro_title"), description: t("contacts__intro_description"), imageName: "group", - buttonText: t("common__continue"), + buttonText: t("contacts__intro_add_contact"), onButtonPress: { app.hasSeenContactsIntro = true if pubkyProfile.isAuthenticated { + contactsManager.shouldOpenAddContactSheet = true navigation.navigate(.contacts) } else if app.hasSeenProfileIntro { navigation.navigate(.pubkyChoice) @@ -36,6 +38,7 @@ struct ContactsIntroView: View { .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(PubkyProfileManager()) + .environmentObject(ContactsManager()) .preferredColorScheme(.dark) } } diff --git a/Bitkit/Views/Contacts/ContactsListView.swift b/Bitkit/Views/Contacts/ContactsListView.swift index 8a703f31..b28a4911 100644 --- a/Bitkit/Views/Contacts/ContactsListView.swift +++ b/Bitkit/Views/Contacts/ContactsListView.swift @@ -53,6 +53,7 @@ struct ContactsListView: View { .sheet(isPresented: $showAddContactSheet) { AddContactSheet( currentPublicKey: pubkyProfile.publicKey, + contacts: contactsManager.contacts, onAdd: { pubky in navigation.navigate(.addContact(publicKey: pubky)) }, @@ -61,11 +62,15 @@ struct ContactsListView: View { } ) } + .onChange(of: contactsManager.shouldOpenAddContactSheet, initial: true) { _, shouldOpen in + guard shouldOpen else { return } + showAddContactSheet = true + contactsManager.shouldOpenAddContactSheet = false + } } // MARK: - Search Bar + Add Button - @ViewBuilder private var searchAndAddBar: some View { HStack(spacing: 12) { HStack(spacing: 12) { @@ -118,7 +123,6 @@ struct ContactsListView: View { // MARK: - My Profile Section - @ViewBuilder private func myProfileSection(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 0) { sectionHeader(t("contacts__my_profile")) @@ -163,7 +167,6 @@ struct ContactsListView: View { // MARK: - Section Header - @ViewBuilder private func sectionHeader(_ title: String) -> some View { CaptionMText(title, textColor: .white64) .padding(.vertical, 16) @@ -171,7 +174,6 @@ struct ContactsListView: View { // MARK: - Contact Row - @ViewBuilder private func contactRow(name: String, truncatedKey: String, imageUrl: String?, onTap: @escaping () -> Void) -> some View { Button(action: onTap) { HStack(spacing: 16) { @@ -191,20 +193,12 @@ struct ContactsListView: View { .accessibilityLabel(name) } - @ViewBuilder private func contactAvatar(name: String, imageUrl: String?) -> some View { Group { if let imageUrl { PubkyImage(uri: imageUrl, size: 48) } else { - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 48, height: 48) - .overlay { - Text(String(name.prefix(1)).uppercased()) - .font(Fonts.bold(size: 17)) - .foregroundColor(.textPrimary) - } + ContactAvatarLetter(source: name, size: 48) } } .accessibilityHidden(true) @@ -225,7 +219,6 @@ struct ContactsListView: View { // MARK: - Loading & Empty States - @ViewBuilder private var loadingContent: some View { VStack { Spacer() @@ -235,7 +228,6 @@ struct ContactsListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private func errorContent(message: String) -> some View { VStack(spacing: 16) { Spacer() @@ -260,7 +252,6 @@ struct ContactsListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private var emptyContent: some View { VStack(spacing: 0) { if pubkyProfile.isAuthenticated, let profile = pubkyProfile.profile { diff --git a/Bitkit/Views/Gift/GiftLoading.swift b/Bitkit/Views/Gift/GiftLoading.swift index f8127444..b903db4c 100644 --- a/Bitkit/Views/Gift/GiftLoading.swift +++ b/Bitkit/Views/Gift/GiftLoading.swift @@ -110,6 +110,7 @@ struct GiftLoading: View { message: code, timestamp: nowTimestamp, preimage: nil, + contact: nil, createdAt: nowTimestamp, updatedAt: nil, seenAt: nil diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 9eeb03cf..7e0889ec 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -1,9 +1,15 @@ import SwiftUI struct PayContactsView: View { + @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false + @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false + + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel @State private var enablePayments = true + @State private var isSaving = false var body: some View { VStack(spacing: 0) { @@ -39,11 +45,12 @@ struct PayContactsView: View { BodyMText(t("profile__pay_contacts_toggle"), textColor: .white) } .tint(.pubkyGreen) + .disabled(isSaving) .accessibilityIdentifier("PayContactsToggle") .padding(.horizontal, 32) - CustomButton(title: t("common__continue")) { - navigation.path = [.profile] + CustomButton(title: t("common__continue"), isLoading: isSaving) { + await continueFlow() } .accessibilityIdentifier("PayContactsContinue") .padding(.top, 16) @@ -53,13 +60,39 @@ struct PayContactsView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) + .task { + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true + } + } + + private func continueFlow() async { + let publish = enablePayments + isSaving = true + defer { isSaving = false } + + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish) + sharesPublicPaykitEndpoints = publish + hasConfirmedPublicPaykitEndpoints = true + navigation.path = [.profile] + } catch { + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true + Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView") + app.toast( + type: .error, + title: t("common__error"), + description: error.localizedDescription + ) + } } } #Preview { NavigationStack { PayContactsView() + .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) + .environmentObject(WalletViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift index c79684ac..9e27dffd 100644 --- a/Bitkit/Views/Scanner/ScannerScreen.swift +++ b/Bitkit/Views/Scanner/ScannerScreen.swift @@ -17,6 +17,10 @@ struct ScannerScreen: View { return .electrum } + if navigation.path.dropLast().last == .contacts { + return .addContact + } + return .main } diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 21341cd2..b208e29b 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -283,6 +283,7 @@ struct ActivityExplorer_Previews: PreviewProvider { message: "Test payment", timestamp: UInt64(Date().timeIntervalSince1970), preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -310,6 +311,7 @@ struct ActivityExplorer_Previews: PreviewProvider { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 8053984a..8bc37e36 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -576,6 +576,7 @@ struct ActivityItemView_Previews: PreviewProvider { message: "Splitting the lunch bill. Thanks for suggesting that amazing restaurant!", timestamp: UInt64(Date().timeIntervalSince1970), preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -605,6 +606,7 @@ struct ActivityItemView_Previews: PreviewProvider { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 09590f63..88f606fc 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -4,6 +4,7 @@ import SwiftUI struct ActivityLatest: View { @EnvironmentObject private var activity: ActivityListViewModel @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var settings: SettingsViewModel @@ -36,7 +37,7 @@ struct ActivityLatest: View { /// Three or four vertical slots (by screen size) shared by: transfer banner, widgets onboarding /// and activity items; only the item count shrinks so the total stays within the cap. private var maxActivityItemsOnHome: Int { - let slotCapacity = UIScreen.main.isSmall ? 3 : 4 + let slotCapacity = UIScreen.main.isSmall ? ActivityDisplayConstants.maxHomeActivityItems - 1 : ActivityDisplayConstants.maxHomeActivityItems var nonItemSlots = 0 if shouldShowBanner { nonItemSlots += 1 } if settings.showWidgets, !app.hasDismissedWidgetsOnboardingHint { nonItemSlots += 1 } @@ -60,7 +61,11 @@ struct ActivityLatest: View { LazyVStack(alignment: .leading, spacing: 16) { ForEach(Array(zip(rows.indices, rows)), id: \.1) { index, item in NavigationLink(value: Route.activityDetail(item)) { - ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates) + ActivityRow( + item: item, + feeEstimates: feeEstimatesManager.estimates, + contact: item.contact(in: contactsManager.contacts) + ) } .accessibilityIdentifier("ActivityShort-\(index)") } diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift index a9b1a3a2..5cd91bcc 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift @@ -4,14 +4,60 @@ import SwiftUI struct ActivityRow: View { let item: Activity let feeEstimates: FeeRates? + let contact: PubkyContact? + let titleOverride: String? + + init(item: Activity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil) { + self.item = item + self.feeEstimates = feeEstimates + self.contact = contact + self.titleOverride = titleOverride + } + + private var rowTitleOverride: String? { + if let titleOverride { + return titleOverride + } + + return contactTitle + } + + private var contactTitle: String? { + guard let contact else { return nil } + + let txType: PaymentType + switch item { + case let .lightning(lightning): + guard lightning.status == .succeeded else { + return nil + } + txType = lightning.txType + + case let .onchain(onchain): + guard onchain.doesExist, + !onchain.isTransfer, + !(onchain.isBoosted && !onchain.confirmed) + else { + return nil + } + txType = onchain.txType + } + + switch txType { + case .sent: + return t("contacts__activity_sent_to", variables: ["name": contact.displayName]) + case .received: + return t("contacts__activity_received_from", variables: ["name": contact.displayName]) + } + } var body: some View { Group { switch item { case let .lightning(activity): - ActivityRowLightning(item: activity) + ActivityRowLightning(item: activity, titleOverride: rowTitleOverride) case let .onchain(activity): - ActivityRowOnchain(item: activity, feeEstimates: feeEstimates) + ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, titleOverride: rowTitleOverride) } } .padding(16) diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift index ba354cbb..6b338291 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift @@ -3,6 +3,12 @@ import SwiftUI struct ActivityRowLightning: View { let item: LightningActivity + let titleOverride: String? + + init(item: LightningActivity, titleOverride: String? = nil) { + self.item = item + self.titleOverride = titleOverride + } private var amountPrefix: String { return item.txType == .sent ? "-" : "+" @@ -21,6 +27,10 @@ struct ActivityRowLightning: View { } private var status: String { + if let titleOverride { + return titleOverride + } + switch item.status { case .failed: return t("wallet__activity_failed") diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index aa975570..0149a444 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift @@ -4,6 +4,13 @@ import SwiftUI struct ActivityRowOnchain: View { let item: OnchainActivity let feeEstimates: FeeRates? + let titleOverride: String? + + init(item: OnchainActivity, feeEstimates: FeeRates?, titleOverride: String? = nil) { + self.item = item + self.feeEstimates = feeEstimates + self.titleOverride = titleOverride + } @State private var isCpfpChild: Bool = false @@ -24,6 +31,10 @@ struct ActivityRowOnchain: View { } private var status: String { + if let titleOverride { + return titleOverride + } + if item.isTransfer { return item.confirmed ? t("wallet__activity_transfer") : t("wallet__activity_transferring") } diff --git a/Bitkit/Views/Wallets/Activity/AllActivityView.swift b/Bitkit/Views/Wallets/Activity/AllActivityView.swift index 0939b097..64832e76 100644 --- a/Bitkit/Views/Wallets/Activity/AllActivityView.swift +++ b/Bitkit/Views/Wallets/Activity/AllActivityView.swift @@ -5,17 +5,16 @@ struct AllActivityView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var wallet: WalletViewModel + @State private var headerContentHeight: CGFloat = 116 + private var headerTopPadding: CGFloat { - // NavBar + Filter + SegmentedControl + spacing - return ScreenLayout.topPaddingWithoutSafeArea + 116 + ScreenLayout.topPaddingWithoutSafeArea + headerContentHeight } var body: some View { ZStack(alignment: .top) { - // ScrollView - base layer, full height, content scrolls behind header ScrollView(showsIndicators: false) { ActivityList(viewType: .all) - // .padding(.top, headerTopPadding) .scrollDismissesKeyboard(.interactively) .highPriorityGesture( // TODO: rewrite using TabView @@ -62,7 +61,6 @@ struct AllActivityView: View { } .transition(.move(edge: .leading).combined(with: .opacity)) - // Header - overlay on top, scroll content goes behind it VStack(spacing: 0) { NavigationBar(title: t("wallet__activity")) .padding(.bottom, 16) @@ -72,6 +70,14 @@ struct AllActivityView: View { SegmentedControl(selectedTab: $activity.selectedTab, tabs: ActivityTab.allCases) } + .background( + GeometryReader { proxy in + Color.clear.preference(key: ActivityHeaderHeightPreferenceKey.self, value: proxy.size.height) + } + ) + .onPreferenceChange(ActivityHeaderHeightPreferenceKey.self) { height in + headerContentHeight = height + } .frame(maxWidth: .infinity, alignment: .top) .padding(.horizontal, 16) .background( @@ -112,6 +118,14 @@ struct AllActivityView: View { } } +private struct ActivityHeaderHeightPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 116 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + #Preview { NavigationStack { AllActivityView() diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index d4b62566..d6672b27 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -197,6 +197,7 @@ struct LnurlPayConfirm: View { let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) + let contactPublicKey = app.contactPaymentContext?.publicKey do { // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) @@ -206,10 +207,11 @@ struct LnurlPayConfirm: View { bolt11: bolt11, sats: nil, onTimeout: { - app.addPendingPaymentHash(paymentHash) + app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) navigationPath.append(.pending(paymentHash: paymentHash)) } ) + app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey) Logger.info("LNURL payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index bb24fec4..98f5a2c4 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SendConfirmationView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var activityList: ActivityListViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var settings: SettingsViewModel @@ -447,6 +448,7 @@ struct SendConfirmationView: View { private func performPayment() async throws { var createdMetadataPaymentId: String? = nil + let contactPublicKey = app.contactPaymentContext?.publicKey do { if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { @@ -468,10 +470,11 @@ struct SendConfirmationView: View { bolt11: invoice.bolt11, sats: paymentSats, onTimeout: { - app.addPendingPaymentHash(paymentHash) + app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) navigationPath.append(.pending(paymentHash: paymentHash)) } ) + await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) Logger.info("Lightning payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { @@ -495,7 +498,8 @@ struct SendConfirmationView: View { address: invoice.address, amount: amount, fee: UInt64(transactionFee), - feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1 + feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, + contact: contactPublicKey ) // Set the amount for the success screen @@ -526,6 +530,20 @@ struct SendConfirmationView: View { } } + private func syncContactForActivity(paymentId: String, contactPublicKey: String?) async { + guard let contactPublicKey else { + return + } + + do { + app.addPendingContactPaymentContext(paymentId, contactPublicKey: contactPublicKey) + try await activityList.setContact(contactPublicKey, forPaymentId: paymentId) + app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) + } catch { + Logger.warn("Failed to set contact for activity \(paymentId): \(error)", context: "SendConfirmationView") + } + } + private func validatePayment() async -> [WarningType] { var warnings: [WarningType] = [] diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 5340d2a4..481b7191 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -81,8 +81,12 @@ struct SendPendingScreen: View { guard let resolution, resolution.paymentHash == paymentHash else { return } app.consumeSendSheetPendingResolution(paymentHash: paymentHash) if resolution.success { - navigationPath.append(.success(paymentId: paymentHash)) + Task { @MainActor in + await applyPendingContactContextIfNeeded() + navigationPath.append(.success(paymentId: paymentHash)) + } } else { + app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) navigationPath.append(.failure) } } @@ -97,9 +101,24 @@ struct SendPendingScreen: View { times: 12, interval: 2 ) - foundActivity = activity + await applyPendingContactContextIfNeeded() + let updatedActivity = try? await activityList.findActivity(byPaymentId: paymentHash) + foundActivity = updatedActivity ?? activity } catch { Logger.warn("Could not find activity for pending payment \(paymentHash): \(error)") } } + + private func applyPendingContactContextIfNeeded() async { + guard let contactPublicKey = app.contactPaymentContext(forPendingPaymentHash: paymentHash)?.publicKey else { + return + } + + do { + try await activityList.setContact(contactPublicKey, forPaymentId: paymentHash) + app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) + } catch { + Logger.warn("Failed to set pending contact for payment \(paymentHash): \(error)", context: "SendPendingScreen") + } + } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 3d3883ca..f09fab6f 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -108,6 +108,9 @@ struct SendSheet: View { } } } + .onDisappear { + app.contactPaymentContext = nil + } .onChange(of: wallet.nodeLifecycleState) { _, state in // When the node becomes running and we have a scanned invoice, run deferred validation. // This covers: diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index cc430dc6..c2170f0f 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -79,7 +79,9 @@ struct SendSuccess: View { variant: .secondary, isDisabled: foundActivity == nil ) { - navigation.navigate(.activityDetail(foundActivity!)) + if let foundActivity { + navigation.navigate(.activityDetail(foundActivity)) + } sheets.hideSheet() } .accessibilityIdentifier("Details") @@ -111,9 +113,24 @@ struct SendSuccess: View { interval: 5 ) - foundActivity = activity + await applyPendingContactContextIfNeeded() + let updatedActivity = try? await activityListViewModel.findActivity(byPaymentId: paymentId) + foundActivity = updatedActivity ?? activity } catch { Logger.warn("Could not find activity for payment ID: \(paymentId) after 12 attempts") } } + + private func applyPendingContactContextIfNeeded() async { + guard let contactPublicKey = app.contactPaymentContext(forPendingPaymentHash: paymentId)?.publicKey else { + return + } + + do { + try await activityListViewModel.setContact(contactPublicKey, forPaymentId: paymentId, syncLdkPayments: false) + app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) + } catch { + Logger.warn("Failed to set pending contact for payment \(paymentId): \(error)", context: "SendSuccess") + } + } } diff --git a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift index c47e6555..f514d47e 100644 --- a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift +++ b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift @@ -443,6 +443,7 @@ struct BoostSheet: View { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index dcf40ed9..989b68eb 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -42,6 +42,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -90,6 +91,7 @@ final class ActivityTests: XCTestCase { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -128,6 +130,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -166,6 +169,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 1", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -182,6 +186,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 2", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -224,6 +229,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 1", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -247,6 +253,7 @@ final class ActivityTests: XCTestCase { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -302,6 +309,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -322,6 +330,7 @@ final class ActivityTests: XCTestCase { message: "Updated test payment", timestamp: timestamp, preimage: "preimage123", + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -359,6 +368,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -396,6 +406,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 1", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -419,6 +430,7 @@ final class ActivityTests: XCTestCase { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -435,6 +447,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 3", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index e81c490e..a67909ad 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import XCTest @MainActor @@ -24,6 +25,61 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(PubkyPublicKeyFormat.matches(prefixedKey, "pubkyinvalid")) } + func testActivityContactResolvesLightningContactKey() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let contact = makeContact(publicKey: "pubky\(rawKey)") + let activity = Activity.lightning( + LightningActivity( + id: "test-lightning-contact", + txType: .sent, + status: .succeeded, + value: 1000, + fee: 10, + invoice: "lnbc...", + message: "", + timestamp: 0, + preimage: nil, + contact: rawKey, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + ) + + XCTAssertEqual(activity.contact(in: [contact])?.publicKey, contact.publicKey) + } + + func testActivityContactResolvesBoostingOnchainContactKey() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let contact = makeContact(publicKey: "pubky\(rawKey)") + let activity = Activity.onchain( + OnchainActivity( + id: "test-onchain-boosting-contact", + txType: .sent, + txId: "txid", + value: 1000, + fee: 10, + feeRate: 1, + address: "bcrt1...", + confirmed: false, + timestamp: 0, + isBoosted: true, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: nil, + channelId: nil, + transferTxId: nil, + contact: contact.publicKey, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + ) + + XCTAssertEqual(activity.contact(in: [contact])?.publicKey, contact.publicKey) + } + func testResolveAddContactValidationReturnsEmptyForBlankInput() { XCTAssertEqual(resolveAddContactValidation(input: " ", ownPublicKey: nil), .empty) } @@ -45,6 +101,20 @@ final class ContactsManagerTests: XCTestCase { ) } + func testResolveAddContactValidationReturnsExistingContactForDuplicate() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let publicKey = "pubky\(rawKey)" + + XCTAssertEqual( + resolveAddContactValidation( + input: rawKey, + ownPublicKey: nil, + existingContacts: [makeContact(publicKey: publicKey)] + ), + .existingContact + ) + } + func testResolveAddContactValidationReturnsNormalizedKeyForValidInput() { let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -62,6 +132,7 @@ final class ContactsManagerTests: XCTestCase { manager.contacts = [contact] manager.hasLoaded = true manager.loadErrorMessage = "still here" + manager.shouldOpenAddContactSheet = true manager.pendingImportProfile = profile manager.pendingImportContacts = [contact] @@ -70,6 +141,7 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(manager.contacts, [contact]) XCTAssertTrue(manager.hasLoaded) XCTAssertEqual(manager.loadErrorMessage, "still here") + XCTAssertTrue(manager.shouldOpenAddContactSheet) XCTAssertNil(manager.pendingImportProfile) XCTAssertTrue(manager.pendingImportContacts.isEmpty) XCTAssertFalse(manager.hasPendingImport) @@ -201,8 +273,8 @@ final class ContactsManagerTests: XCTestCase { ) } - private func makeProfile(publicKey: String) -> PubkyProfile { - PubkyProfile( + private func makeProfile(publicKey: String) -> Bitkit.PubkyProfile { + Bitkit.PubkyProfile( publicKey: publicKey, name: "Alice", bio: "bio", @@ -213,7 +285,7 @@ final class ContactsManagerTests: XCTestCase { ) } - private func makeContact(publicKey: String) -> PubkyContact { - PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey)) + private func makeContact(publicKey: String) -> Bitkit.PubkyContact { + Bitkit.PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey)) } } diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift new file mode 100644 index 00000000..f4593ae2 --- /dev/null +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -0,0 +1,227 @@ +@testable import Bitkit +import Foundation +import LDKNode +import XCTest + +final class PublicPaykitServiceTests: XCTestCase { + func testParseEndpointReadsSpecPayloadObject() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-bolt11", + endpointData: #"{"value":"lnbc1example","min":"1000","max":"2000"}"# + ) + + XCTAssertEqual(endpoint?.methodId, .bitcoinLightningBolt11) + XCTAssertEqual(endpoint?.value, "lnbc1example") + XCTAssertEqual(endpoint?.min, "1000") + XCTAssertEqual(endpoint?.max, "2000") + } + + func testParseEndpointRejectsRawStringPayload() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-bitcoin-p2wpkh", + endpointData: "bc1qexampleaddress" + ) + + XCTAssertNil(endpoint) + } + + func testParseEndpointRejectsUnsupportedMethodId() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-bolt12", + endpointData: #"{"value":"anything"}"# + ) + + XCTAssertNil(endpoint) + } + + func testParseEndpointReadsPaykyLnurlMethodId() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-lnurl", + endpointData: #"{"value":"lnurl1example"}"# + ) + + XCTAssertEqual(endpoint?.methodId, .bitcoinLightningLnurl) + XCTAssertEqual(endpoint?.value, "lnurl1example") + } + + func testParseEndpointReadsNetworkSpecificOnchainMethodIds() { + XCTAssertEqual( + PublicPaykitService.parseEndpoint( + methodId: "btc-testnet-p2wpkh", + endpointData: #"{"value":"tb1qexample"}"# + )?.methodId, + .testnetOnchainP2wpkh + ) + XCTAssertEqual( + PublicPaykitService.parseEndpoint( + methodId: "btc-regtest-p2tr", + endpointData: #"{"value":"bcrt1pexample"}"# + )?.methodId, + .regtestOnchainP2tr + ) + } + + func testParseEndpointRejectsNonSpecLegacyLnurlMethodId() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-lnurl-pay", + endpointData: #"{"value":"lnurl1example"}"# + ) + + XCTAssertNil(endpoint) + } + + func testKnownMethodIdsFollowPaymentEndpointIdentifierSpec() { + let specPattern = #"^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$"# + + for methodId in PublicPaykitService.MethodId.allCases { + XCTAssertNotNil(methodId.rawValue.range(of: specPattern, options: .regularExpression), "\(methodId.rawValue) must be asset-rail-endpoint") + } + } + + func testSerializePayloadWrapsValueInJsonObject() throws { + let payload = try PublicPaykitService.serializePayload(value: " lnbc1invoice ") + let json = try XCTUnwrap(payload.data(using: .utf8)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: String]) + + XCTAssertEqual(object["value"], "lnbc1invoice") + XCTAssertEqual(object.count, 1) + } + + func testPaymentRequestCombinesOnchainAndBolt11Endpoints() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"), + ]) + + XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnbc1invoice") + } + + func testPaymentRequestPercentEncodesLightningParameter() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice?amount=1&label=test"), + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"), + ]) + + XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnbc1invoice%3Famount%3D1%26label%3Dtest") + } + + func testPaymentRequestPrefersTaprootWhenMultipleOnchainEndpointsExist() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), + endpoint(.bitcoinOnchainP2pkh, value: "1legacy"), + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qsegwit"), + endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot"), + ]) + + XCTAssertEqual(request, "bitcoin:bc1ptaproot?lightning=lnbc1invoice") + } + + func testPaymentRequestFallsBackToPreferredEndpointWhenCombinedRequestIsNotAvailable() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), + ]) + + XCTAssertEqual(request, "lnbc1invoice") + } + + func testPaymentRequestFallsBackToLnurlOnlyEndpoint() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningLnurl, value: "lnurl1example"), + ]) + + XCTAssertEqual(request, "lnurl1example") + } + + func testOnchainMethodIdUsesAddressPrefixAndNetwork() { + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample", network: .bitcoin), .bitcoinOnchainP2tr) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample", network: .testnet), .testnetOnchainP2wpkh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bcrt1qexample", network: .regtest), .regtestOnchainP2wpkh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "3Example", network: .bitcoin), .bitcoinOnchainP2sh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "2Example", network: .regtest), .regtestOnchainP2sh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "1Example", network: .bitcoin), .bitcoinOnchainP2pkh) + } + + func testPaymentLaunchResultFailureMessageKeys() { + XCTAssertNil(PublicPaykitPaymentLaunchResult.opened(paymentRequest: "bitcoin:bcrt1ptest").contactPaymentFailureMessageKey) + XCTAssertEqual(PublicPaykitPaymentLaunchResult.noEndpoint.contactPaymentFailureMessageKey, "slashtags__error_pay_empty_msg") + XCTAssertEqual(PublicPaykitPaymentLaunchResult.notOpened.contactPaymentFailureMessageKey, "slashtags__error_pay_not_opened_msg") + } + + func testPayableEndpointsFiltersInvalidDecodedEndpoints() async { + let payable = await PublicPaykitService.payableEndpoints(from: [ + endpoint(.bitcoinLightningBolt11, value: "not-a-bolt11"), + endpoint(.bitcoinOnchainP2tr, value: "not-an-address"), + ]) + + XCTAssertTrue(payable.isEmpty) + } + + func testMethodIdsToRemoveWhenUnpublishingOnlyIncludesBitkitManagedEndpoints() { + let methodIds = PublicPaykitService.methodIdsToRemoveWhenUnpublishing(existingMethodIds: [ + .bitcoinLightningBolt11, + .bitcoinLightningLnurl, + .bitcoinOnchainP2tr, + ]) + + XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinOnchainP2tr]) + } + + func testPublishedEndpointSyncPlanRemovesStalePublishedMethods() { + let desired = [ + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), + endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot"), + ] + + let plan = PublicPaykitService.publishedEndpointSyncPlan( + existingEndpoints: [ + .bitcoinLightningBolt11: #"{"value":"oldinvoice"}"#, + .bitcoinOnchainP2wpkh: #"{"value":"bc1qsegwit"}"#, + .bitcoinOnchainP2sh: #"{"value":"3nested"}"#, + ], + desiredEndpoints: desired + ) + + XCTAssertEqual(plan.endpointsToSet, desired) + XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh]) + } + + func testPublishedEndpointSyncPlanSkipsUnchangedPublishedPayloads() { + let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice") + let taproot = endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot") + + let plan = PublicPaykitService.publishedEndpointSyncPlan( + existingEndpoints: [ + .bitcoinLightningBolt11: bolt11.rawPayload, + .bitcoinOnchainP2tr: #"{"value":"oldtaproot"}"#, + ], + desiredEndpoints: [bolt11, taproot] + ) + + XCTAssertEqual(plan.endpointsToSet, [taproot]) + XCTAssertTrue(plan.methodIdsToRemove.isEmpty) + } + + func testPublishedEndpointSyncPlanPreservesExternallyOwnedLnurlEndpoint() { + let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice") + + let plan = PublicPaykitService.publishedEndpointSyncPlan( + existingEndpoints: [ + .bitcoinLightningLnurl: #"{"value":"lnurl1external"}"#, + ], + desiredEndpoints: [bolt11] + ) + + XCTAssertEqual(plan.endpointsToSet, [bolt11]) + XCTAssertTrue(plan.methodIdsToRemove.isEmpty) + } + + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { + PublicPaykitService.Endpoint( + methodId: methodId, + value: value, + min: nil, + max: nil, + rawPayload: #"{"value":"\#(value)"}"# + ) + } +} diff --git a/BitkitTests/ZipServiceTests.swift b/BitkitTests/ZipServiceTests.swift index 59af86dd..573cfeb6 100644 --- a/BitkitTests/ZipServiceTests.swift +++ b/BitkitTests/ZipServiceTests.swift @@ -48,6 +48,20 @@ final class ZipServiceTests: XCTestCase { } } + func testCreateZipRejectsDotFilenames() throws { + let zipService = ZipService() + + for filename in [".", "..", "logs/.."] { + let filesToZip: [FileToZip] = [.data(Data([0x01]), filename: filename)] + + XCTAssertThrowsError(try zipService.getZipData(zipFilename: "invalid", filesToZip: filesToZip)) { error in + guard case CreateZipError.invalidFilename = error else { + return XCTFail("Expected invalidFilename error for \(filename), got \(error)") + } + } + } + } + func testCreateZipOverwritesExistingFileByDefault() throws { let sourceDirectory = testRootDirectoryURL.appendingPathComponent("input") try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) diff --git a/changelog.d/next/531.added.md b/changelog.d/next/531.added.md new file mode 100644 index 00000000..6cfb4f23 --- /dev/null +++ b/changelog.d/next/531.added.md @@ -0,0 +1 @@ +Support publishing public Paykit endpoints and paying pubky contacts through public payment endpoints.