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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 107 additions & 76 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,18 @@

@MainActor
class ContactsManager: ObservableObject {
@Published var contacts: [PubkyContact] = []
private var contactsRevision = 0
private var loadGeneration = 0
private let contactRecords: @Sendable () async throws -> [ContactRecord]

init(contactRecords: @escaping @Sendable () async throws -> [ContactRecord] = PubkyService.contactRecords) {

Check warning on line 121 in Bitkit/Managers/ContactsManager.swift

View workflow job for this annotation

GitHub Actions / Run Tests

converting non-sendable function value to '@sendable () async throws -> [ContactRecord]' may introduce data races
self.contactRecords = contactRecords
}

@Published var contacts: [PubkyContact] = [] {
didSet { contactsRevision += 1 }
}

@Published var isLoading = false
@Published var hasLoaded = false
@Published var loadErrorMessage: String?
Expand All @@ -136,6 +147,7 @@
}

func reset() {
loadGeneration += 1
contacts = []
isLoading = false
hasLoaded = false
Expand Down Expand Up @@ -173,97 +185,107 @@
return
}

loadGeneration += 1
let generation = loadGeneration
isLoading = true
loadErrorMessage = nil
defer { isLoading = false }
defer {
if generation == loadGeneration { isLoading = false }
}

Logger.info("Loading contacts for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "ContactsManager")

do {
let records = try await Task.detached {
try await PubkyService.contactRecords()
}.value

Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager")
while generation == loadGeneration {
try Task.checkCancellation()
let revision = contactsRevision
do {
let records = try await contactRecords()

Logger.debug("Loaded \(records.count) SDK contact records", context: "ContactsManager")

let loadedResult: (contacts: [PubkyContact], failures: Int,
missingFailures: Int, firstError: Error?) = await withTaskGroup(of: Result<PubkyContact, Error>.self) { group in
let overrides = Self.loadContactProfileOverrides()
for record in records {
group.addTask {
do {
let contact = try await Self.contact(from: record, overrides: overrides, includePlaceholder: true)
return .success(contact)
} catch {
Logger.warn(
"Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(record.publicKey))': \(error)",
context: "ContactsManager"
)
return .failure(error)
}
}
}

let loadedResult: (contacts: [PubkyContact], failures: Int,
missingFailures: Int, firstError: Error?) = await withTaskGroup(of: Result<PubkyContact, Error>.self) { group in
let overrides = Self.loadContactProfileOverrides()
for record in records {
group.addTask {
do {
let contact = try await Self.contact(from: record, overrides: overrides, includePlaceholder: true)
return .success(contact)
} catch {
Logger.warn(
"Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(record.publicKey))': \(error)",
context: "ContactsManager"
)
return .failure(error)
var results: [PubkyContact] = []
var failures = 0
var missingFailures = 0
var firstError: Error?

for await result in group {
switch result {
case let .success(contact):
results.append(contact)
case let .failure(error):
failures += 1
if Self.isMissingContactsDataError(error) {
missingFailures += 1
}
firstError = firstError ?? error
}
}

return (results, failures, missingFailures, firstError)
}

var results: [PubkyContact] = []
var failures = 0
var missingFailures = 0
var firstError: Error?
guard contactsRevision == revision else { continue }

for await result in group {
switch result {
case let .success(contact):
results.append(contact)
case let .failure(error):
failures += 1
if Self.isMissingContactsDataError(error) {
missingFailures += 1
}
firstError = firstError ?? error
if !records.isEmpty, loadedResult.contacts.isEmpty {
if loadedResult.failures == loadedResult.missingFailures {
contacts = []
hasLoaded = true
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager")
return
}
throw loadedResult.firstError ?? PubkyServiceError.profileNotFound
}

return (results, failures, missingFailures, firstError)
}
contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
hasLoaded = true
await PrivatePaykitService.shared
.pruneUnsavedContactState(savedPublicKeys: records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) })

if !records.isEmpty, loadedResult.contacts.isEmpty {
if loadedResult.failures == loadedResult.missingFailures {
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
if loadedResult.failures > 0 {
Logger.warn(
"Skipped \(loadedResult.failures) unreadable contacts while loading list",
context: "ContactsManager"
)
}

Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager")
return
} catch {
guard contactsRevision == revision else { continue }
if Self.isMissingContactsDataError(error) {
contacts = []
hasLoaded = true
Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager")
loadErrorMessage = nil
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
Logger.info("Contacts storage missing, treating list as empty", context: "ContactsManager")
return
}
throw loadedResult.firstError ?? PubkyServiceError.profileNotFound
}

contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
await PrivatePaykitService.shared
.pruneUnsavedContactState(savedPublicKeys: records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) })
hasLoaded = true

if loadedResult.failures > 0 {
Logger.warn(
"Skipped \(loadedResult.failures) unreadable contacts while loading list",
context: "ContactsManager"
)
}

Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager")
} catch {
if Self.isMissingContactsDataError(error) {
await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: [])
contacts = []
hasLoaded = true
loadErrorMessage = nil
Logger.info("Contacts storage missing, treating list as empty", context: "ContactsManager")
return
}

Logger.error("Failed to load contacts: \(error)", context: "ContactsManager")
if contacts.isEmpty {
loadErrorMessage = error.localizedDescription
Logger.error("Failed to load contacts: \(error)", context: "ContactsManager")
if contacts.isEmpty {
loadErrorMessage = error.localizedDescription
}
throw error
}
throw error
}
}

Expand Down Expand Up @@ -297,7 +319,12 @@
}

let receiverPaths = try await Self.relevantReceiverPaths(for: prefixedKey)
_ = try await PubkyService.saveContact(publicKey: prefixedKey, label: profile.name, receiverPaths: receiverPaths)
_ = try await PubkyService.saveContact(
publicKey: prefixedKey,
label: profile.name,
receiverPaths: receiverPaths,
restorePrivateConnection: true
)

Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")

Expand Down Expand Up @@ -342,7 +369,12 @@
do {
let profile = try await resolveContactProfile(publicKey: key, includePlaceholder: true)
let receiverPaths = try await Self.relevantReceiverPaths(for: key)
_ = try await PubkyService.saveContact(publicKey: key, label: profile.name, receiverPaths: receiverPaths)
_ = try await PubkyService.saveContact(
publicKey: key,
label: profile.name,
receiverPaths: receiverPaths,
restorePrivateConnection: true
)
return .success(PubkyContact(publicKey: key, profile: profile))
} catch is CancellationError {
return .failure(CancellationError())
Expand Down Expand Up @@ -423,12 +455,11 @@
try await Task.detached {
_ = try await PubkyService.removeContact(publicKey: prefixedKey)
}.value
await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey)
contacts.removeAll { $0.publicKey == prefixedKey }
Self.removeContactProfileOverride(publicKey: prefixedKey)
await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey)

Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager")

contacts.removeAll { $0.publicKey == prefixedKey }
}

func deleteAllContacts() async throws {
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,7 @@
"contacts__import_select_none" = "Select none";
"contacts__import_friends_count" = "{count} friends";
"contacts__import_selected_count" = "{count} selected";
"contacts__delete_active_subscription" = "End active subscriptions before deleting this contact.";
"contacts__delete_title" = "Delete {name}?";
"contacts__delete_description" = "Are you sure you want to delete {name} from your contacts?";
"contacts__delete_confirm" = "Yes, Delete";
Expand Down
31 changes: 29 additions & 2 deletions Bitkit/Services/PaykitPaymentRequestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,14 @@ struct PaykitPaymentRequestService {
logIntakeFailures(intakeReports)
let synchronizationDate = now()
let records = try await sdk.paymentRequests()
let blockedPeers = try await sdk.linkedPeers().filter { $0.state == .blocked }
let availableRecords = records.filter { record in
!blockedPeers.contains {
PubkyPublicKeyFormat.matches($0.counterparty, record.counterparty) && $0.counterpartyReceiverPath == record.counterpartyReceiverPath
}
}
var rejections: [IncomingPaykitPaymentRequestRejection] = []
let incoming = records.compactMap { record in
let incoming = availableRecords.compactMap { record in
switch PaykitPaymentRequest.parseIncoming(record: record, now: synchronizationDate) {
case let .success(request):
return request
Expand All @@ -566,7 +572,7 @@ struct PaykitPaymentRequestService {
let history = records.compactMap {
PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate)
}
let subscriptions = records.compactMap { PaykitSubscription(record: $0) }
let subscriptions = availableRecords.compactMap { PaykitSubscription(record: $0) }
Comment thread
jvsena42 marked this conversation as resolved.
return PaykitPaymentRequestSnapshot(
incoming: incoming,
history: history,
Expand Down Expand Up @@ -807,6 +813,13 @@ struct PaykitPaymentRequestService {
)
}

func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws {
guard try await !sdk.linkedPeers().contains(where: {
$0.state == .blocked && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) &&
$0.counterpartyReceiverPath == request.counterpartyReceiverPath
}) else { throw PaykitPaymentRequestError.requestUnavailable }
}

func accept(_ request: PaykitPaymentRequest) async throws {
guard !request.isExpired(at: now()) else {
throw PaykitPaymentRequestError.requestExpired
Expand Down Expand Up @@ -1444,17 +1457,31 @@ final class PaykitPaymentRequestManager {
refreshTask = nil
}

func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws {
do {
try await service.ensurePaymentAllowed(request)
} catch {
approvedPaymentRequestIds.remove(request.id)
throw error
}
}

func prepareForPayment(
_ request: PaykitPaymentRequest,
consumePrivatePaymentList: () async throws -> Void = {}
) async throws {
do {
if isApprovedForPayment(request) {
try await ensurePaymentAllowed(request)
return
}
try await perform(
request,
resultingState: .accepted,
markApprovedForPayment: true,
preservePending: !request.requiresAcceptance
) {
try await ensurePaymentAllowed($0)
try await consumePrivatePaymentList()
if $0.requiresAcceptance {
try await service.accept($0)
Expand Down
3 changes: 2 additions & 1 deletion Bitkit/Services/PrivatePaykitService+Contacts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ extension PrivatePaykitService {
)
for receiverPath in cleanupReceiverPaths {
do {
let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath)
guard let report = try await PaykitSdkService.shared.clearPrivatePaymentList(to: publicKey, receiverPath: receiverPath)
else { continue }
Comment thread
jvsena42 marked this conversation as resolved.
if !report.failedToQueue.isEmpty || !report.failedToDeliver.isEmpty {
throw PrivatePaykitError.privateUnavailable
}
Expand Down
Loading
Loading