From fccfd61064fb00477970ce5c83172a62d77977da Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:22:44 +0100 Subject: [PATCH 01/25] feat: use the pubky.app profile directly --- Bitkit/Managers/PubkyAppProfileStore.swift | 217 ++++++++++++++++++ Bitkit/Managers/PubkyProfileManager.swift | 6 +- .../Localization/ar.lproj/Localizable.strings | 3 +- .../Localization/ca.lproj/Localizable.strings | 3 +- .../Localization/cs.lproj/Localizable.strings | 3 +- .../Localization/de.lproj/Localizable.strings | 3 +- .../Localization/el.lproj/Localizable.strings | 3 +- .../Localization/en.lproj/Localizable.strings | 3 +- .../es-419.lproj/Localizable.strings | 3 +- .../Localization/es.lproj/Localizable.strings | 3 +- .../Localization/fr.lproj/Localizable.strings | 3 +- .../Localization/it.lproj/Localizable.strings | 3 +- .../Localization/nl.lproj/Localizable.strings | 3 +- .../Localization/pl.lproj/Localizable.strings | 3 +- .../pt-BR.lproj/Localizable.strings | 3 +- .../Localization/pt.lproj/Localizable.strings | 3 +- .../Localization/ru.lproj/Localizable.strings | 3 +- Bitkit/Services/PubkyService.swift | 68 +++++- Bitkit/Views/Profile/EditProfileView.swift | 5 + BitkitTests/ContactPaymentsServiceTests.swift | 2 +- BitkitTests/HwFundingSignerTests.swift | 2 + BitkitTests/PaykitAllowanceTests.swift | 2 + BitkitTests/PaykitContactLifecycleTests.swift | 8 +- BitkitTests/PaykitIssuerInteropTests.swift | 2 + .../PaykitPaymentProofServiceTests.swift | 2 + .../PaykitPaymentRequestServiceTests.swift | 2 + .../PaykitReceivedPaymentContactsTests.swift | 4 +- BitkitTests/PaykitSdkIdentityCheckTests.swift | 5 +- BitkitTests/PaykitSdkOperationLockTests.swift | 2 +- .../PaymentNavigationHelperTests.swift | 2 + BitkitTests/PrivatePaykitServiceTests.swift | 50 ++-- BitkitTests/PubkyAppProfileStoreTests.swift | 54 +++++ BitkitTests/ReceiveSheetSessionTests.swift | 2 + .../SendConfirmationLifecycleTests.swift | 2 + BitkitTests/SendSheetLifecycleTests.swift | 4 +- .../TransferServiceActivityTests.swift | 2 + journeys/profile/README.md | 2 + journeys/profile/edit-pubky-app-profile.xml | 18 ++ 38 files changed, 456 insertions(+), 52 deletions(-) create mode 100644 Bitkit/Managers/PubkyAppProfileStore.swift create mode 100644 BitkitTests/PubkyAppProfileStoreTests.swift create mode 100644 journeys/profile/edit-pubky-app-profile.xml diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift new file mode 100644 index 000000000..2c9ffca8e --- /dev/null +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -0,0 +1,217 @@ +import Foundation +import Paykit + +enum PubkyAppProfileError: LocalizedError { + /// The pubky.app profile changed since Bitkit loaded it, so the save was not applied. + case changedElsewhere + + var errorDescription: String? { + t("profile__edit_changed_error") + } +} + +/// The identity's own pubky.app profile, which Bitkit reads and edits directly. A save writes only the fields that differ +/// from the loaded profile, at the revision it was loaded at, so an edit made in pubky.app is never overwritten. Profile +/// tags have no pubky.app field: they live in Bitkit's public Paykit profile copy, which follows each save for other +/// Paykit apps. +actor PubkyAppProfileStore { + static let shared = PubkyAppProfileStore() + + // pubky.app profile limits (pubky-app-specs), applied when moving a Bitkit profile there. + private static let nameMaxLength = 50 + private static let bioMaxLength = 160 + private static let linksMaxCount = 5 + private static let linkTitleMaxLength = 100 + private static let avatarMaxBytes: UInt64 = 5 * 1024 * 1024 + + private struct Base { + let profile: PubkyProfile + let revision: String? + } + + private var bases: [String: Base] = [:] + + /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first. + func load(publicKey: String) async throws -> PubkyProfile { + let key = Self.normalized(publicKey) + let paykitData: PubkyProfileData? + do { + paykitData = try await PubkyService.fetchPaykitProfile(publicKey: key).map { + PubkyProfileData.from(paykitProfile: $0.profile) + } + } catch { + Logger.warn("Failed to read Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") + paykitData = nil + } + var snapshot = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) + let isSignedIn = await Self.isSignedIn(key) + if snapshot == nil, let paykitData, isSignedIn { + snapshot = await migrate(paykitData, publicKey: key) + } + guard let snapshot else { + bases[key] = nil + throw PubkyServiceError.profileNotFound + } + let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) + bases[key] = Base(profile: profile, revision: snapshot.revision) + if isSignedIn, let paykitData, paykitData != PubkyProfileData.from(profile: profile) { + await publishCopy(profile, expectedIdentity: key) + } + return profile + } + + /// Saves `data` for `expectedIdentity`, or the signed-in identity. Throws `changedElsewhere` after reloading when + /// pubky.app changed the profile since it was loaded. + func save(_ data: PubkyProfileData, expectedIdentity: String?) async throws { + let key = try await Self.identity(expectedIdentity) + let base = bases[key] + let expectedRevision: String? = if let base { + base.revision + } else { + // Creating: an existing pubky.app profile keeps every field the form does not set. + try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision + } + let update = Self.update(from: data, base: base?.profile) + let snapshot: Paykit.PubkyProfileSnapshot + do { + snapshot = try await withPubkyAppScope { + try await PubkyService.publishPubkyProfile(update, expectedRevision: expectedRevision, expectedIdentity: key) + } + } catch PaykitError.ConcurrentUpdate { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: data.tags) + bases[key] = Base(profile: profile, revision: snapshot.revision) + await publishCopy(profile, expectedIdentity: key) + } + + /// Uploads a JPEG avatar as a pubky.app blob and file and returns the file URI for the profile image. + func uploadAvatar(_ jpegData: Data, expectedIdentity: String?) async throws -> String { + let key = try await Self.identity(expectedIdentity) + return try await withPubkyAppScope { + try await PubkyService.uploadPubkyAppAvatar(bytes: jpegData, contentType: "image/jpeg", expectedIdentity: key) + } + } + + private func migrate(_ data: PubkyProfileData, publicKey: String) async -> Paykit.PubkyProfileSnapshot? { + var image: String? + if let uri = data.image { + do { + image = try await withPubkyAppScope { + let bytes = try await PubkyService.fetchFile(uri: uri, maxBytes: Self.avatarMaxBytes) + return try await PubkyService.uploadPubkyAppAvatar( + bytes: bytes, + contentType: Self.contentType(for: uri), + expectedIdentity: publicKey + ) + } + } catch { + Logger.warn("Failed to move avatar to pubky.app: \(error)", context: "PubkyAppProfileStore") + } + } + let links = data.links + .map { (label: $0.label.trimmed, url: $0.url.trimmed) } + .filter { !$0.label.isEmpty && !$0.url.isEmpty } + .prefix(Self.linksMaxCount) + .map { Paykit.PubkyProfileLink(title: String($0.label.prefix(Self.linkTitleMaxLength)), url: $0.url) } + let update = Paykit.PubkyProfileUpdate( + name: String(data.name.trimmed.prefix(Self.nameMaxLength)), + bio: String(data.bio.trimmed.prefix(Self.bioMaxLength)), + image: image, + links: links + ) + do { + let snapshot = try await withPubkyAppScope { + try await PubkyService.publishPubkyProfile(update, expectedRevision: nil, expectedIdentity: publicKey) + } + Logger.info("Moved Bitkit profile to pubky.app", context: "PubkyAppProfileStore") + return snapshot + } catch { + Logger.warn("Failed to move Bitkit profile to pubky.app: \(error)", context: "PubkyAppProfileStore") + return nil + } + } + + private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String) async { + do { + try await PubkyService.publishPaykitProfile( + PubkyProfileData.from(profile: profile).toPaykitProfile(), + expectedIdentity: expectedIdentity + ) + } catch { + Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") + } + } + + /// Sessions from builds before pubky.app profile writes lack its scope; sign in again once to gain it. + private func withPubkyAppScope(_ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch let error as PaykitError { + guard case .Identity = error, let secretKeyHex = PubkyProfileManager.activeSecretKeyHex() else { throw error } + _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + return try await operation() + } + } + + static func update(from data: PubkyProfileData, base: PubkyProfile?) -> Paykit.PubkyProfileUpdate { + let links = data.links.map { Paykit.PubkyProfileLink(title: $0.label, url: $0.url) } + guard let base else { + return Paykit.PubkyProfileUpdate(name: data.name, bio: data.bio, image: data.image, links: links) + } + let baseLinks = base.links.map { PubkyProfileData.Link(label: $0.label, url: $0.url) } + return Paykit.PubkyProfileUpdate( + name: data.name == base.name ? nil : data.name, + bio: data.bio == base.bio ? nil : data.bio, + // An empty image removes it. + image: data.image == base.imageUrl ? nil : data.image ?? "", + links: data.links == baseLinks ? nil : links + ) + } + + private static func profile(publicKey: String, snapshot: Paykit.PubkyProfileSnapshot, tags: [String]) -> PubkyProfile { + let profile = PubkyProfile(publicKey: publicKey, pubkyProfile: snapshot.profile) + return PubkyProfile( + publicKey: publicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl, + links: profile.links, + tags: tags, + status: profile.status + ) + } + + private static func identity(_ expectedIdentity: String?) async throws -> String { + if let expectedIdentity { + return normalized(expectedIdentity) + } + guard let current = await PubkyService.currentPublicKey() else { throw PubkyServiceError.sessionNotActive } + return normalized(current) + } + + private static func isSignedIn(_ publicKey: String) async -> Bool { + guard let current = await PubkyService.currentPublicKey() else { return false } + return PubkyPublicKeyFormat.matches(current, publicKey) + } + + private static func normalized(_ publicKey: String) -> String { + PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + } + + private static func contentType(for uri: String) -> String { + switch uri.split(separator: ".").last?.lowercased() { + case "png": "image/png" + case "webp": "image/webp" + case "gif": "image/gif" + default: "image/jpeg" + } + } +} + +private extension String { + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 52d7be2f6..950e1c22c 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -160,12 +160,12 @@ class PubkyProfileManager: ObservableObject { } init( - remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyProfileManager.resolveRemoteProfile(publicKey: $0) }, + remoteProfileResolver: @escaping RemoteProfileResolver = { try await PubkyAppProfileStore.shared.load(publicKey: $0) }, profilePublisher: @escaping ProfilePublisher = { - try await PubkyService.publishPaykitProfile($0.toPaykitProfile(), expectedIdentity: $1) + try await PubkyAppProfileStore.shared.save($0, expectedIdentity: $1) }, avatarUploader: @escaping AvatarUploader = { - try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) + try await PubkyAppProfileStore.shared.uploadAvatar($0, expectedIdentity: $1) }, profileDeleter: @escaping ProfileDeleter = { try await PubkyService.deletePaykitProfile(expectedIdentity: $0) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 0316d2a6b..4c16a48d3 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -955,7 +955,8 @@ "profile__pubky_copied" = "تم نسخ pubky إلى الحافظة"; "profile__edit_delete_section" = "حذف"; "profile__edit_unlink_section" = "إلغاء الربط"; -"profile__edit_public_note" = "يرجى ملاحظة أن معلومات الملف الشخصي مخزنة في ملفات عامة."; +"profile__edit_changed_error" = "تغيّر ملفك الشخصي في pubky.app. حمّل Bitkit أحدث نسخة، لذا أعد إجراء تعديلك."; +"profile__edit_public_note" = "هذا هو ملفك الشخصي على pubky.app. إنه عام، والتغييرات التي تجريها هنا تظهر أيضًا على pubky.app."; "profile__delete_title" = "حذف الملف الشخصي؟"; "profile__delete_description" = "يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا."; "profile__delete_confirm" = "نعم، احذف"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index b73fe7057..2b1765dc4 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1071,7 +1071,8 @@ "profile__pubky_copied" = "pubky copiat al porta-retalls"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Tingues en compte que la informació del perfil es desa en fitxers públics."; +"profile__edit_changed_error" = "El teu perfil ha canviat a pubky.app. Bitkit ha carregat la versió més recent, així que torna a fer la teva edició."; +"profile__edit_public_note" = "Aquest és el teu perfil de pubky.app. És públic, i els canvis que hi facis aquí també es veuen a pubky.app."; "profile__delete_title" = "Eliminar el perfil?"; "profile__delete_description" = "Eliminar el teu perfil l'esborra de Pubky. És possible que els contactes conservin la informació de perfil i de pagament que s'hi hagi compartit abans."; "profile__delete_confirm" = "Sí, elimina"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index bf8012df4..a0c42e7ec 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1176,7 +1176,8 @@ "profile__pubky_copied" = "pubky zkopírován do schránky"; "profile__edit_delete_section" = "SMAZAT"; "profile__edit_unlink_section" = "ZRUŠIT PROPOJENÍ"; -"profile__edit_public_note" = "Upozorňujeme, že informace o profilu jsou uloženy ve veřejných souborech."; +"profile__edit_changed_error" = "Tvůj profil se v pubky.app změnil. Bitkit načetl nejnovější verzi, proveď proto úpravu znovu."; +"profile__edit_public_note" = "Toto je tvůj profil na pubky.app. Je veřejný a změny, které zde provedeš, se zobrazí i na pubky.app."; "profile__delete_title" = "Smazat profil?"; "profile__delete_description" = "Smazáním profilu ho odstraníte z Pubky. Kontakty si mohou ponechat informace o profilu a platbách, které jste s nimi dříve sdíleli."; "profile__delete_confirm" = "Ano, smazat"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index edaa4d881..f790cd477 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1174,7 +1174,8 @@ "profile__pubky_copied" = "pubky in die Zwischenablage kopiert"; "profile__edit_delete_section" = "LÖSCHEN"; "profile__edit_unlink_section" = "VERKNÜPFUNG AUFHEBEN"; -"profile__edit_public_note" = "Bitte beachte, dass Profilinformationen in öffentlichen Dateien gespeichert werden."; +"profile__edit_changed_error" = "Dein Profil wurde in pubky.app geändert. Bitkit hat die neueste Version geladen, nimm deine Änderung bitte erneut vor."; +"profile__edit_public_note" = "Das ist dein pubky.app-Profil. Es ist öffentlich, und Änderungen, die du hier vornimmst, erscheinen auch auf pubky.app."; "profile__delete_title" = "Profil löschen?"; "profile__delete_description" = "Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten."; "profile__delete_confirm" = "Ja, löschen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 0e22742d1..28701b94f 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -972,7 +972,8 @@ "profile__pubky_copied" = "Το pubky αντιγράφηκε στο πρόχειρο"; "profile__edit_delete_section" = "ΔΙΑΓΡΑΦΗ"; "profile__edit_unlink_section" = "ΑΠΟΣΥΝΔΕΣΗ"; -"profile__edit_public_note" = "Τα στοιχεία του προφίλ αποθηκεύονται σε δημόσια αρχεία."; +"profile__edit_changed_error" = "Το προφίλ σου άλλαξε στο pubky.app. Το Bitkit φόρτωσε την πιο πρόσφατη έκδοση, οπότε κάνε ξανά την αλλαγή σου."; +"profile__edit_public_note" = "Αυτό είναι το προφίλ σου στο pubky.app. Είναι δημόσιο, και οι αλλαγές που κάνεις εδώ εμφανίζονται και στο pubky.app."; "profile__delete_title" = "Διαγραφή Προφίλ;"; "profile__delete_description" = "Η διαγραφή του προφίλ σας το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα."; "profile__delete_confirm" = "Ναι, Διαγραφή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 84b738a51..fa19cedfd 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1189,7 +1189,8 @@ "profile__pubky_copied" = "pubky copied to clipboard"; "profile__edit_delete_section" = "DELETE"; "profile__edit_unlink_section" = "UNLINK"; -"profile__edit_public_note" = "Please note profile information is stored in public files."; +"profile__edit_changed_error" = "Your profile changed in pubky.app. Bitkit loaded the latest version, so make your edit again."; +"profile__edit_public_note" = "This is your pubky.app profile. It is public, and changes you make here also show on pubky.app."; "profile__delete_title" = "Delete Profile?"; "profile__delete_description" = "Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them."; "profile__delete_confirm" = "Yes, Delete"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 0b954a90f..4f95defb1 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1179,7 +1179,8 @@ "profile__pubky_copied" = "pubky copiado al portapapeles"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__edit_changed_error" = "Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio."; +"profile__edit_public_note" = "Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app."; "profile__delete_title" = "¿Eliminar perfil?"; "profile__delete_description" = "Al eliminar su perfil, se borra de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 2e7d27918..cf5c4b79d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1089,7 +1089,8 @@ "profile__pubky_copied" = "pubky copiado al portapapeles"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__edit_changed_error" = "Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio."; +"profile__edit_public_note" = "Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app."; "profile__delete_title" = "¿Eliminar el perfil?"; "profile__delete_description" = "Al eliminar su perfil, se elimina de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 4fac9e1da..55fe112ce 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1187,7 +1187,8 @@ "profile__pubky_copied" = "pubky copié dans le presse-papiers"; "profile__edit_delete_section" = "SUPPRIMER"; "profile__edit_unlink_section" = "DISSOCIER"; -"profile__edit_public_note" = "Veuillez noter que les informations de profil sont stockées dans des fichiers publics."; +"profile__edit_changed_error" = "Ton profil a changé sur pubky.app. Bitkit a chargé la version la plus récente, refais donc ta modification."; +"profile__edit_public_note" = "Voici ton profil pubky.app. Il est public, et les modifications que tu fais ici apparaissent aussi sur pubky.app."; "profile__delete_title" = "Supprimer le profil ?"; "profile__delete_description" = "La suppression de votre profil le retire de Pubky. Vos contacts peuvent encore conserver les informations de profil et de paiement que vous leur avez partagées."; "profile__delete_confirm" = "Oui, supprimer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 43eefe7f5..fc7cff8c5 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1153,7 +1153,8 @@ "profile__pubky_copied" = "pubky copiato negli appunti"; "profile__edit_delete_section" = "ELIMINA"; "profile__edit_unlink_section" = "SCOLLEGA"; -"profile__edit_public_note" = "Tieni presente che le informazioni del profilo sono salvate in file pubblici."; +"profile__edit_changed_error" = "Il tuo profilo è cambiato su pubky.app. Bitkit ha caricato la versione più recente, quindi ripeti la modifica."; +"profile__edit_public_note" = "Questo è il tuo profilo pubky.app. È pubblico e le modifiche che fai qui compaiono anche su pubky.app."; "profile__delete_title" = "Eliminare il Profilo?"; "profile__delete_description" = "Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro."; "profile__delete_confirm" = "Sì, Elimina"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 2b892c34d..72f4e9dd4 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1182,7 +1182,8 @@ "profile__pubky_copied" = "pubky gekopieerd naar klembord"; "profile__edit_delete_section" = "VERWIJDEREN"; "profile__edit_unlink_section" = "ONTKOPPELEN"; -"profile__edit_public_note" = "Let op: profielgegevens worden opgeslagen in openbare bestanden."; +"profile__edit_changed_error" = "Je profiel is gewijzigd in pubky.app. Bitkit heeft de nieuwste versie geladen, dus voer je wijziging opnieuw uit."; +"profile__edit_public_note" = "Dit is je pubky.app-profiel. Het is openbaar, en wijzigingen die je hier maakt, verschijnen ook op pubky.app."; "profile__delete_title" = "Profiel verwijderen?"; "profile__delete_description" = "Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren."; "profile__delete_confirm" = "Ja, verwijderen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 4515ca72e..dddc2bc1f 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1186,7 +1186,8 @@ "profile__pubky_copied" = "pubky skopiowany do schowka"; "profile__edit_delete_section" = "USUWANIE"; "profile__edit_unlink_section" = "ODŁĄCZANIE"; -"profile__edit_public_note" = "Pamiętaj, że dane profilu są przechowywane w publicznych plikach."; +"profile__edit_changed_error" = "Twój profil zmienił się w pubky.app. Bitkit wczytał najnowszą wersję, więc wprowadź zmianę ponownie."; +"profile__edit_public_note" = "To jest Twój profil pubky.app. Jest publiczny, a zmiany wprowadzone tutaj pojawią się też w pubky.app."; "profile__delete_title" = "Usunąć profil?"; "profile__delete_description" = "Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności."; "profile__delete_confirm" = "Tak, usuń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 488220de6..094a8d0c6 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1187,7 +1187,8 @@ "profile__pubky_copied" = "pubky copiada para a área de transferência"; "profile__edit_delete_section" = "EXCLUIR"; "profile__edit_unlink_section" = "DESVINCULAR"; -"profile__edit_public_note" = "Observe que as informações do perfil são armazenadas em arquivos públicos."; +"profile__edit_changed_error" = "Seu perfil mudou no pubky.app. O Bitkit carregou a versão mais recente, então faça sua alteração de novo."; +"profile__edit_public_note" = "Este é o seu perfil do pubky.app. Ele é público, e as alterações que você faz aqui também aparecem no pubky.app."; "profile__delete_title" = "Excluir Perfil?"; "profile__delete_description" = "Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index c860dd592..bac4d77cf 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -947,7 +947,8 @@ "profile__pubky_copied" = "pubky copiado para a área de transferência"; "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESASSOCIAR"; -"profile__edit_public_note" = "Tenha em atenção que as informações do perfil são guardadas em ficheiros públicos."; +"profile__edit_changed_error" = "O teu perfil mudou no pubky.app. A Bitkit carregou a versão mais recente, por isso faz novamente a tua alteração."; +"profile__edit_public_note" = "Este é o teu perfil do pubky.app. É público, e as alterações que fazes aqui também aparecem no pubky.app."; "profile__delete_title" = "Eliminar perfil?"; "profile__delete_description" = "Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 59b25a886..1dca4f730 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1172,7 +1172,8 @@ "profile__pubky_copied" = "pubky скопирован в буфер обмена"; "profile__edit_delete_section" = "УДАЛЕНИЕ"; "profile__edit_unlink_section" = "ОТВЯЗКА"; -"profile__edit_public_note" = "Обратите внимание: данные профиля хранятся в публичных файлах."; +"profile__edit_changed_error" = "Ваш профиль изменился в pubky.app. Bitkit загрузил последнюю версию, поэтому внесите изменение снова."; +"profile__edit_public_note" = "Это ваш профиль pubky.app. Он публичный, и изменения, которые вы вносите здесь, также видны в pubky.app."; "profile__delete_title" = "Удалить Профиль?"; "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 4cc1a2674..d1b8e5e57 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -360,6 +360,34 @@ enum PubkyService { try await PaykitSdkService.shared.deletePaykitProfile(expectedIdentity: expectedIdentity) } + static func fetchPubkyProfileWithRevision(publicKey: String) async throws -> Paykit.PubkyProfileSnapshot? { + try await PaykitSdkService.shared.fetchPubkyProfileWithRevision(publicKey: publicKey) + } + + static func fetchPaykitProfile(publicKey: String) async throws -> Paykit.PaykitProfileRecord? { + try await PaykitSdkService.shared.fetchPaykitProfile(publicKey: publicKey) + } + + static func publishPubkyProfile( + _ update: Paykit.PubkyProfileUpdate, + expectedRevision: String?, + expectedIdentity: String + ) async throws -> Paykit.PubkyProfileSnapshot { + try await PaykitSdkService.shared.publishPubkyProfile( + update, + expectedRevision: expectedRevision, + expectedIdentity: expectedIdentity + ) + } + + static func uploadPubkyAppAvatar(bytes: Data, contentType: String, expectedIdentity: String) async throws -> String { + try await PaykitSdkService.shared.uploadPubkyAppAvatar( + bytes: bytes, + contentType: contentType, + expectedIdentity: expectedIdentity + ) + } + // MARK: - Contacts static func getContacts(publicKey: String) async throws -> [String] { @@ -678,7 +706,7 @@ actor PaykitSdkService { localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) markWalletBackupDataChanged() @@ -697,7 +725,7 @@ actor PaykitSdkService { localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) return PubkyRegisteredIdentity(result: result, walletGeneration: generation) } @@ -721,7 +749,7 @@ actor PaykitSdkService { let previousPublicKey = try await cachedIdentityPublicKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) markWalletBackupDataChanged() @@ -829,6 +857,34 @@ actor PaykitSdkService { } } + func fetchPubkyProfileWithRevision(publicKey: String) async throws -> Paykit.PubkyProfileSnapshot? { + try await withPublicRead { try await $0.fetchPubkyProfileWithRevision(publicKey: publicKey) } + } + + func fetchPaykitProfile(publicKey: String) async throws -> Paykit.PaykitProfileRecord? { + try await withPublicRead { try await $0.fetchPaykitProfile(publicKey: publicKey) } + } + + /// Writes only while `expectedIdentity` is signed in, checked in the same locked operation as the write. + func publishPubkyProfile( + _ update: Paykit.PubkyProfileUpdate, + expectedRevision: String?, + expectedIdentity: String + ) async throws -> Paykit.PubkyProfileSnapshot { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + return try await sdk.publishPubkyProfile(update: update, expectedRevision: expectedRevision) + } + } + + /// Uploads a pubky.app avatar blob and file only while `expectedIdentity` is signed in, and returns the file URI. + func uploadPubkyAppAvatar(bytes: Data, contentType: String, expectedIdentity: String) async throws -> String { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + return try await sdk.uploadPubkyAppAvatar(bytes: bytes, contentType: contentType) + } + } + func fetchPubkyFollows(publicKey: String) async throws -> [String] { try await withPublicRead { try await $0.fetchPubkyFollows(publicKey: publicKey, maxEntries: 10000) } } @@ -1623,10 +1679,16 @@ actor PaykitSdkService { secretKey.exportBytes().hex } + /// Scope a restored session must hold. Sessions from builds before pubky.app profile writes have only this. nonisolated static func requiredCapabilities() throws -> String { Paykit.paykitAuthorizerSessionCapabilities() } + /// Scope for sessions Bitkit creates: Paykit plus the shared pubky.app profile and its avatar. + nonisolated static func sessionCapabilities() throws -> String { + try "\(requiredCapabilities()),\(Paykit.pubkyAppProfileSessionCapabilities())" + } + private func handle() throws -> PaykitSdk { if let sdk { return sdk diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 183393e11..9c9545127 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -235,6 +235,11 @@ struct EditProfileView: View { } catch { Logger.error("Failed to save profile: \(error)", context: "EditProfileView") app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + if case PubkyAppProfileError.changedElsewhere = error { + await pubkyProfile.loadProfile() + avatarImage = nil + loadProfileData() + } } } } diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 0be8dfc8b..63033ef52 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -970,7 +970,7 @@ final class ContactPaymentsServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { _ in writes.append("private:published") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) let reachedHeldPoint = expectation(description: "\(testCase.name): publication reached its held point") diff --git a/BitkitTests/HwFundingSignerTests.swift b/BitkitTests/HwFundingSignerTests.swift index ce70534b4..321df22b0 100644 --- a/BitkitTests/HwFundingSignerTests.swift +++ b/BitkitTests/HwFundingSignerTests.swift @@ -24,6 +24,7 @@ final class HwFundingSignerTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -40,6 +41,7 @@ final class HwFundingSignerTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 62676a27f..b87fb760b 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -933,6 +933,7 @@ enum PaykitAllowanceFixtures { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: Paykit.PaymentRequestTerms( @@ -949,6 +950,7 @@ enum PaykitAllowanceFixtures { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitContactLifecycleTests.swift b/BitkitTests/PaykitContactLifecycleTests.swift index 4639062c7..adc2c96a2 100644 --- a/BitkitTests/PaykitContactLifecycleTests.swift +++ b/BitkitTests/PaykitContactLifecycleTests.swift @@ -78,8 +78,10 @@ final class PaykitContactLifecycleTests: XCTestCase { paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: testCase.role, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: nil, proposalAppId: testCase.appId, + proposalRecordedAt: nil, payerAppId: testCase.role == .payer ? testCase.appId : nil, executionClaimAppId: nil, terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, @@ -128,8 +130,9 @@ final class PaykitContactLifecycleTests: XCTestCase { counterparty: publicKey, paymentRequestId: UUID().uuidString, localRole: .payer, state: .activeRecurring, proposalStreamItemId: nil, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: nil, proposalAppId: "paykit-server", payerAppId: "paykit-server", executionClaimAppId: nil, + proposalEventId: nil, proposalAppId: "paykit-server", proposalRecordedAt: nil, payerAppId: "paykit-server", executionClaimAppId: nil, terms: terms, acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: nil, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil @@ -527,6 +530,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { queued: [], cleared: updates.filter { update in !failed.contains { $0.counterparty == update.counterparty } } .map { .init(counterparty: $0.counterparty, outboundMessageId: 1, error: nil) }, + acknowledged: [], failedToQueue: failed.map { .init(counterparty: $0.counterparty, outboundMessageId: nil, error: nil) }, failedToDeliver: [] ) @@ -540,7 +544,7 @@ private final class ContactLifecycleSdk: PaykitSdk, @unchecked Sendable { if failWithdrawal { throw PubkyServiceError.sessionNotActive } - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } override func blockPeer(counterparty: String) async throws -> LinkedPeerRecord { diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index 0754b5bfc..c99c8e880 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -100,6 +100,7 @@ final class PaykitIssuerInteropTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "71300000-0000-4000-8000-000000000002", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -116,6 +117,7 @@ final class PaykitIssuerInteropTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index e78b9ca58..a011c2678 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -3432,6 +3432,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -3448,6 +3449,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index c146130f2..ddf229296 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -10521,6 +10521,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { proposalOutboundStatus: proposalOutboundStatus, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: proposalAppId, + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -10537,6 +10538,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ), acceptedEventId: acceptedEventId, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: rejectedEventId, rejectedOutboundStatus: nil, canceledEventId: canceledEventId, diff --git a/BitkitTests/PaykitReceivedPaymentContactsTests.swift b/BitkitTests/PaykitReceivedPaymentContactsTests.swift index 2d5ed36a5..ddf51348f 100644 --- a/BitkitTests/PaykitReceivedPaymentContactsTests.swift +++ b/BitkitTests/PaykitReceivedPaymentContactsTests.swift @@ -303,7 +303,7 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { try PaymentRequestRecord( counterparty: counterparty, paymentRequestId: "550e8400-e29b-41d4-a716-446655440000", localRole: .payee, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "paykit-server", payerAppId: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "paykit-server", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), @@ -311,7 +311,7 @@ final class PaykitReceivedPaymentContactsTests: XCTestCase { paymentEndpoints: [identifier: PublicPaykitService.serializePayload(value: address)], requiredAppId: nil, conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: "2027-01-15T08:00:00Z", invalidReason: nil ) diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index e1d57c9b5..ee4841d8a 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -907,7 +907,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { localRole: .payee, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, proposalEventId: "71300000-0000-4000-8000-000000000002", - proposalAppId: appId, payerAppId: nil, executionClaimAppId: nil, + proposalAppId: appId, proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.001", asset: "btc"), paymentReference: PaymentReference(text: "invoice-123"), @@ -916,6 +916,7 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { metadata: PrivateJsonObject(text: "{}") ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 42, @@ -1407,6 +1408,6 @@ private final class AbandonedCleanupSdk: PaykitSdk, @unchecked Sendable { updates _: [PrivatePaymentListReservationUpdateInput], clearUnlistedLinkedPeers _: Bool ) async throws -> PrivatePaymentListDeliveryReport { mutations.append("privateEndpoints") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } } diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index cddac524b..9874ab1f5 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -1336,7 +1336,7 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { updates _: [PrivatePaymentListReservationUpdateInput], clearUnlistedLinkedPeers _: Bool ) async throws -> PrivatePaymentListDeliveryReport { await intake() - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } override func submitPaymentProof( diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index f0f0afb21..606445af6 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -502,6 +502,7 @@ final class PaymentNavigationHelperTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -518,6 +519,7 @@ final class PaymentNavigationHelperTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 554cc702c..665899f46 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -283,7 +283,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("Failed links must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: { [] } ) ) @@ -1049,7 +1049,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } syncedUpdates = updates published.fulfill() - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1093,7 +1093,7 @@ final class PrivatePaykitServiceTests: XCTestCase { currentPublicKey: { "pubkylocal" }, ensureLink: { linked.append($0); return .linked }, buildEndpoints: { _ in [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) var sessionCurrent = true var prune: Task? @@ -1154,7 +1154,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Ended sessions must not build endpoints"); return [] }, syncPaymentLists: { _ in XCTFail("Ended sessions must not publish") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -1196,7 +1196,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { updates in published += updates.map(\.counterparty) - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) @@ -2200,7 +2200,7 @@ final class PrivatePaykitServiceTests: XCTestCase { currentPublicKey: { nil }, ensureLink: { _ in .notLinked }, buildEndpoints: { _ in [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ), messageRetryOperations: .init(drain: { _ in .init(ensureLink: { _ in }, pendingOutbound: { [] }, linkedPeers: { [] }, processPending: { _ in }, receive: { _ in }) @@ -2220,7 +2220,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [] }, syncPaymentLists: { updates in published += updates.map(\.counterparty) - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) _ = await service.rememberSavedContacts([publicKey], replacing: true) @@ -2266,7 +2266,7 @@ final class PrivatePaykitServiceTests: XCTestCase { publishing.fulfill() for await _ in resume {} } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) let wallet = WalletViewModel() @@ -2869,6 +2869,7 @@ final class PrivatePaykitServiceTests: XCTestCase { withdrawals += 1 return .init( queued: [], cleared: [.init(counterparty: key, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -2920,12 +2921,14 @@ final class PrivatePaykitServiceTests: XCTestCase { if shouldFail, failureStage == "queue" { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], + acknowledged: [], failedToQueue: [PrivatePaymentListSyncChange(counterparty: publicKey, outboundMessageId: nil, error: nil)], failedToDeliver: [] ) } return PrivatePaymentListDeliveryReport( queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -2994,6 +2997,7 @@ final class PrivatePaykitServiceTests: XCTestCase { cleared.append(contentsOf: $0) return PrivatePaymentListDeliveryReport( queued: [], cleared: $0.map { .init(counterparty: $0, outboundMessageId: 1, error: nil) }, + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -3056,6 +3060,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(Set(keys), [failed, successful]) return .init( queued: [], cleared: [.init(counterparty: successful, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: failQueue ? [.init(counterparty: failed, outboundMessageId: nil, error: nil)] : [], failedToDeliver: failQueue ? [] : [.init( counterparty: failed, outboundMessageId: 2, reservationId: nil, @@ -3109,7 +3114,7 @@ final class PrivatePaykitServiceTests: XCTestCase { }, syncPaymentLists: { _ in XCTFail("An unsupported contact should not receive a private list") - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) @@ -3151,6 +3156,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) @@ -3223,6 +3229,7 @@ final class PrivatePaykitServiceTests: XCTestCase { return PrivatePaymentListDeliveryReport( queued: [], cleared: [], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) @@ -3277,7 +3284,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } XCTAssertEqual(updates.map(\.counterparty), [key]) publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: { try await deferOnce("peers"); return [] }, canPublish: { try await deferOnce("availability"); return true } @@ -3329,7 +3336,7 @@ final class PrivatePaykitServiceTests: XCTestCase { syncPaymentLists: { updates in XCTAssertEqual(updates.map(\.counterparty), [key]) publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { canPublish } )) @@ -3471,7 +3478,7 @@ final class PrivatePaykitServiceTests: XCTestCase { deferred.fulfill() throw PaykitPaymentRequestError.operationInProgress } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { if isPaused { @@ -3547,7 +3554,7 @@ final class PrivatePaykitServiceTests: XCTestCase { deferred.fulfill() throw PaykitPaymentRequestError.operationInProgress } - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, canPublish: { isActive && sharingEnabled }, isApplicationActive: { @@ -4066,7 +4073,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertFalse(Task.isCancelled) XCTAssertEqual(updates.map(\.counterparty), [key]) published = true - return PrivatePaymentListDeliveryReport(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return PrivatePaymentListDeliveryReport(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) _ = await service.syncLocalEndpointPublication( @@ -4090,7 +4097,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("No prepared link"); return [] }, - syncPaymentLists: { _ in XCTFail("No prepared link"); return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in XCTFail("No prepared link"); return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: { reads += 1; return [] } ) let task = Task { @@ -4250,7 +4257,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in [endpoint] }, syncPaymentLists: { _ in publications += 1 - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } )) var contactState = PrivatePaykitService.ContactState() @@ -4272,6 +4279,7 @@ final class PrivatePaykitServiceTests: XCTestCase { } return .init( queued: [], cleared: [.init(counterparty: publicKey, outboundMessageId: 1, error: nil)], + acknowledged: [], failedToQueue: [], failedToDeliver: [] ) }, @@ -4353,7 +4361,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Cleanup invalidates preparation"); return [] }, syncPaymentLists: { _ in XCTFail("Cleanup invalidates publication") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) ) @@ -4394,7 +4402,7 @@ final class PrivatePaykitServiceTests: XCTestCase { buildEndpoints: { _ in XCTFail("Removed contacts must not reserve addresses"); return [] }, syncPaymentLists: { _ in XCTFail("Removed contacts must not receive publications") - return .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) + return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) ) @@ -4413,7 +4421,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) for _ in 0 ..< 3 { _ = await service.syncLocalEndpointPublication( @@ -4454,7 +4462,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("Failed links must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, linkedPeers: peers ) _ = await service.syncLocalEndpointPublication( @@ -4491,7 +4499,7 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.NotFound(code: "not_found", context: "No App Registry") }, buildEndpoints: { _ in XCTFail("Unavailable contacts must not reserve addresses"); return [] }, - syncPaymentLists: { _ in .init(queued: [], cleared: [], failedToQueue: [], failedToDeliver: []) } + syncPaymentLists: { _ in .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) } ) let removals: [(PrivatePaykitService) async -> Void] = [ { await $0.clearContactState(publicKey: publicKey) }, diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift new file mode 100644 index 000000000..bfc5de176 --- /dev/null +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -0,0 +1,54 @@ +@testable import Bitkit +import XCTest + +final class PubkyAppProfileStoreTests: XCTestCase { + private let base = PubkyProfile( + publicKey: "pubkytest", + name: "Alice", + bio: "Bio", + imageUrl: "pubky://test/pub/pubky.app/files/0000000000000", + links: [PubkyProfileLink(label: "X", url: "https://x.com/alice")], + status: "away" + ) + + private func data(name: String = "Alice", bio: String = "Bio", image: String? = nil, links: [PubkyProfileData.Link]? = nil) + -> PubkyProfileData + { + PubkyProfileData( + name: name, + bio: bio, + image: image ?? base.imageUrl, + links: links ?? [.init(label: "X", url: "https://x.com/alice")], + tags: [] + ) + } + + func testUnchangedFieldsAreLeftOut() { + let update = PubkyAppProfileStore.update(from: data(name: "Alicia"), base: base) + + XCTAssertEqual(update.name, "Alicia") + XCTAssertNil(update.bio) + XCTAssertNil(update.image) + XCTAssertNil(update.links) + } + + func testRemovedImageAndLinksAreCleared() { + var edited = data(links: []) + edited.image = nil + + let update = PubkyAppProfileStore.update(from: edited, base: base) + + XCTAssertEqual(update.image, "") + XCTAssertEqual(update.links?.isEmpty, true) + XCTAssertNil(update.name) + } + + func testWithoutBaseEveryFieldIsWritten() { + let update = PubkyAppProfileStore.update(from: data(), base: nil) + + XCTAssertEqual(update.name, "Alice") + XCTAssertEqual(update.bio, "Bio") + XCTAssertEqual(update.image, base.imageUrl) + XCTAssertEqual(update.links?.first?.title, "X") + } +} diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift index 99c9a04db..e9275733e 100644 --- a/BitkitTests/ReceiveSheetSessionTests.swift +++ b/BitkitTests/ReceiveSheetSessionTests.swift @@ -67,11 +67,13 @@ final class ReceiveSheetSessionTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: nil, proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: nil, acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index a6d960d90..0d4702ce1 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -1198,6 +1198,7 @@ final class SendConfirmationLifecycleTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -1217,6 +1218,7 @@ final class SendConfirmationLifecycleTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/BitkitTests/SendSheetLifecycleTests.swift b/BitkitTests/SendSheetLifecycleTests.swift index d108f5773..c79af1aba 100644 --- a/BitkitTests/SendSheetLifecycleTests.swift +++ b/BitkitTests/SendSheetLifecycleTests.swift @@ -135,7 +135,7 @@ final class SendSheetLifecycleTests: XCTestCase { counterparty: "pubkypayee", paymentRequestId: UUID().uuidString, localRole: .payer, state: .proposed, proposalStreamItemId: 1, proposalOutboundMessageId: nil, proposalOutboundStatus: nil, - proposalEventId: UUID().uuidString, proposalAppId: "bitkit", + proposalEventId: UUID().uuidString, proposalAppId: "bitkit", proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( amount: PaymentRequestAmount(value: "0.00001", asset: "btc"), @@ -145,7 +145,7 @@ final class SendSheetLifecycleTests: XCTestCase { paymentEndpoints: nil, requiredAppId: "bitkit", conversion: nil, paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), - acceptedEventId: nil, acceptedOutboundStatus: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, + acceptedEventId: nil, acceptedOutboundStatus: nil, acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, lastOutboundStatus: nil, lastEventAt: nil, invalidReason: nil diff --git a/BitkitTests/TransferServiceActivityTests.swift b/BitkitTests/TransferServiceActivityTests.swift index 16492304e..48c00c10a 100644 --- a/BitkitTests/TransferServiceActivityTests.swift +++ b/BitkitTests/TransferServiceActivityTests.swift @@ -57,6 +57,7 @@ final class TransferServiceActivityTests: XCTestCase { proposalOutboundStatus: nil, proposalEventId: "650e8400-e29b-41d4-a716-446655440000", proposalAppId: "bitkit", + proposalRecordedAt: nil, payerAppId: nil, executionClaimAppId: nil, terms: PaymentRequestTerms( @@ -73,6 +74,7 @@ final class TransferServiceActivityTests: XCTestCase { ), acceptedEventId: nil, acceptedOutboundStatus: nil, + acceptanceRecordedAt: nil, rejectedEventId: nil, rejectedOutboundStatus: nil, canceledEventId: nil, diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 3c83c19a1..b097fc568 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -56,4 +56,6 @@ On iOS, open a saved contact's link while a stored identity's session restoratio `create-profile.xml` records the name-only saving interval and checks the saved name after reopening and relaunching. Use a fresh unfunded identity per run and record the exact app/SDK revisions and network. With controlled network fault injection, block direct UDP while keeping the configured HTTP relays and homeserver reachable; saving should still complete. Reject the required profile write separately and verify saving reports failure instead of navigating to Pay Contacts. Fault injection is a manual check beyond journey-runner capabilities. +`edit-pubky-app-profile.xml` checks that Edit Profile presents the profile as the public pubky.app profile and that a name edit is saved and survives a relaunch. Use an identity whose pubky.app profile you can open on pubky.app; checking the edit there, and an edit made in pubky.app while Bitkit is open, are manual checks beyond journey-runner capabilities. + On iOS, the restart check waits for the full profile (`ProfileViewName`). A matching `ProfileCachedName` during loading does not complete that check. Record an unfinished load separately from a loaded profile with a missing or incorrect name. diff --git a/journeys/profile/edit-pubky-app-profile.xml b/journeys/profile/edit-pubky-app-profile.xml new file mode 100644 index 000000000..ee1913eba --- /dev/null +++ b/journeys/profile/edit-pubky-app-profile.xml @@ -0,0 +1,18 @@ + + + Precondition: a disposable wallet signed in to a Pubky profile, PIN off and an English device. + This checks that Edit Profile presents the profile as the user's public pubky.app profile and + that a name change is saved and shown again after a relaunch. Whether the change also reached + pubky.app is checked outside the app, under the PR's manual tests. + + + Open Profile using "ProfileButton" and wait for "ProfileViewName" + Tap "ProfileEdit" and wait for "ProfileEditName" + Verify the footer note says this is your pubky.app profile and that it is public + Replace the name with "Edited In Bitkit" and dismiss the keyboard + Tap "ProfileEditSave" and wait for "ProfileUpdatedToast" + Verify "ProfileViewName" shows "Edited In Bitkit" + Relaunch the app, open Profile using "ProfileButton" and wait for the full profile name (id "ProfileViewName", predicate exists) + Verify "ProfileViewName" shows "Edited In Bitkit" + + From 27f8cb7634fc2d8e49d9be21e500a458bb9c69a5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:22:57 +0100 Subject: [PATCH 02/25] chore: pin paykit 0.1.1-rc7 --- Bitkit.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index c688f66c5..dae4625a8 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1209,7 +1209,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.1-rc2"; + version = "0.1.1-rc7"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { From 23ff2c69f9c4ba5ed1335b648f492b5fef6768ce Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:47:56 +0100 Subject: [PATCH 03/25] fix: keep the newest profile revision and move profiles text first --- Bitkit/Managers/PubkyAppProfileStore.swift | 56 +++++++++++++-------- BitkitTests/PrivatePaykitServiceTests.swift | 8 ++- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 2c9ffca8e..c667e3844 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -22,6 +22,7 @@ actor PubkyAppProfileStore { private static let bioMaxLength = 160 private static let linksMaxCount = 5 private static let linkTitleMaxLength = 100 + private static let linkUrlMaxLength = 300 private static let avatarMaxBytes: UInt64 = 5 * 1024 * 1024 private struct Base { @@ -30,10 +31,12 @@ actor PubkyAppProfileStore { } private var bases: [String: Base] = [:] + private var saveCount = 0 /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first. func load(publicKey: String) async throws -> PubkyProfile { let key = Self.normalized(publicKey) + let savesBeforeLoad = saveCount let paykitData: PubkyProfileData? do { paykitData = try await PubkyService.fetchPaykitProfile(publicKey: key).map { @@ -48,12 +51,18 @@ actor PubkyAppProfileStore { if snapshot == nil, let paykitData, isSignedIn { snapshot = await migrate(paykitData, publicKey: key) } + // A save that finished while this load ran holds a newer revision than this read. + let isLatest = saveCount == savesBeforeLoad guard let snapshot else { - bases[key] = nil + if isLatest { + bases[key] = nil + } throw PubkyServiceError.profileNotFound } let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) - bases[key] = Base(profile: profile, revision: snapshot.revision) + if isLatest { + bases[key] = Base(profile: profile, revision: snapshot.revision) + } if isSignedIn, let paykitData, paykitData != PubkyProfileData.from(profile: profile) { await publishCopy(profile, expectedIdentity: key) } @@ -82,6 +91,7 @@ actor PubkyAppProfileStore { throw PubkyAppProfileError.changedElsewhere } let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: data.tags) + saveCount += 1 bases[key] = Base(profile: profile, revision: snapshot.revision) await publishCopy(profile, expectedIdentity: key) } @@ -95,42 +105,46 @@ actor PubkyAppProfileStore { } private func migrate(_ data: PubkyProfileData, publicKey: String) async -> Paykit.PubkyProfileSnapshot? { - var image: String? - if let uri = data.image { - do { - image = try await withPubkyAppScope { - let bytes = try await PubkyService.fetchFile(uri: uri, maxBytes: Self.avatarMaxBytes) - return try await PubkyService.uploadPubkyAppAvatar( - bytes: bytes, - contentType: Self.contentType(for: uri), - expectedIdentity: publicKey - ) - } - } catch { - Logger.warn("Failed to move avatar to pubky.app: \(error)", context: "PubkyAppProfileStore") - } - } let links = data.links .map { (label: $0.label.trimmed, url: $0.url.trimmed) } - .filter { !$0.label.isEmpty && !$0.url.isEmpty } + .filter { !$0.label.isEmpty && $0.url.count <= Self.linkUrlMaxLength && URL(string: $0.url)?.scheme != nil } .prefix(Self.linksMaxCount) .map { Paykit.PubkyProfileLink(title: String($0.label.prefix(Self.linkTitleMaxLength)), url: $0.url) } let update = Paykit.PubkyProfileUpdate( name: String(data.name.trimmed.prefix(Self.nameMaxLength)), bio: String(data.bio.trimmed.prefix(Self.bioMaxLength)), - image: image, + image: nil, links: links ) + let snapshot: Paykit.PubkyProfileSnapshot do { - let snapshot = try await withPubkyAppScope { + snapshot = try await withPubkyAppScope { try await PubkyService.publishPubkyProfile(update, expectedRevision: nil, expectedIdentity: publicKey) } Logger.info("Moved Bitkit profile to pubky.app", context: "PubkyAppProfileStore") - return snapshot } catch { Logger.warn("Failed to move Bitkit profile to pubky.app: \(error)", context: "PubkyAppProfileStore") return nil } + guard let uri = data.image else { return snapshot } + do { + return try await withPubkyAppScope { + let bytes = try await PubkyService.fetchFile(uri: uri, maxBytes: Self.avatarMaxBytes) + let image = try await PubkyService.uploadPubkyAppAvatar( + bytes: bytes, + contentType: Self.contentType(for: uri), + expectedIdentity: publicKey + ) + return try await PubkyService.publishPubkyProfile( + Paykit.PubkyProfileUpdate(name: nil, bio: nil, image: image, links: nil), + expectedRevision: snapshot.revision, + expectedIdentity: publicKey + ) + } + } catch { + Logger.warn("Failed to move avatar to pubky.app: \(error)", context: "PubkyAppProfileStore") + return snapshot + } } private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String) async { diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 665899f46..8f1230be1 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -4097,7 +4097,13 @@ final class PrivatePaykitServiceTests: XCTestCase { throw PaykitError.Transport(code: "offline", context: "Unavailable homeserver") }, buildEndpoints: { _ in XCTFail("No prepared link"); return [] }, - syncPaymentLists: { _ in XCTFail("No prepared link"); return .init(queued: [], cleared: [], acknowledged: [], failedToQueue: [], failedToDeliver: []) }, + syncPaymentLists: { _ in XCTFail("No prepared link"); return .init( + queued: [], + cleared: [], + acknowledged: [], + failedToQueue: [], + failedToDeliver: [] + ) }, linkedPeers: { reads += 1; return [] } ) let task = Task { From 1b1448ff211a356f598d6eca607006f7f5d5f218 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 17:50:40 +0100 Subject: [PATCH 04/25] chore: add changelog fragment --- changelog.d/next/1235.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1235.added.md diff --git a/changelog.d/next/1235.added.md b/changelog.d/next/1235.added.md new file mode 100644 index 000000000..0a48534fc --- /dev/null +++ b/changelog.d/next/1235.added.md @@ -0,0 +1 @@ +Bitkit now uses your pubky.app profile, so edits in Bitkit and on pubky.app show in both. From 5003641ad8ccd9a29f06418113dd79abce424647 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:33:18 +0100 Subject: [PATCH 05/25] fix: keep contact avatars on paykit and a deleted pubky.app profile deleted --- Bitkit/Managers/PubkyAppProfileStore.swift | 14 +++++++++++++- Bitkit/Managers/PubkyProfileManager.swift | 8 ++++++++ Bitkit/Views/Contacts/EditContactView.swift | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index c667e3844..b6e8cbf05 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -25,6 +25,13 @@ actor PubkyAppProfileStore { private static let linkUrlMaxLength = 300 private static let avatarMaxBytes: UInt64 = 5 * 1024 * 1024 + private static let pubkyAppProfileOwnersKey = "pubky_app_profile_owners" + + private static var pubkyAppProfileOwners: Set { + get { Set(UserDefaults.standard.stringArray(forKey: pubkyAppProfileOwnersKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: pubkyAppProfileOwnersKey) } + } + private struct Base { let profile: PubkyProfile let revision: String? @@ -48,9 +55,14 @@ actor PubkyAppProfileStore { } var snapshot = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) let isSignedIn = await Self.isSignedIn(key) - if snapshot == nil, let paykitData, isSignedIn { + // Once a pubky.app profile was seen, one missing there was deleted there, so it is not moved back from Bitkit. + let hadPubkyAppProfile = Self.pubkyAppProfileOwners.contains(key) + if snapshot == nil, let paykitData, isSignedIn, !hadPubkyAppProfile { snapshot = await migrate(paykitData, publicKey: key) } + if snapshot != nil, !hadPubkyAppProfile { + Self.pubkyAppProfileOwners.insert(key) + } // A save that finished while this load ran holds a newer revision than this read. let isLatest = saveCount == savesBeforeLoad guard let snapshot else { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 950e1c22c..d253958a5 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -447,6 +447,14 @@ class PubkyProfileManager: ObservableObject { return try await avatarUploader(imageData, expectedIdentity) } + /// Upload a contact's avatar to Bitkit's own Paykit blob storage, which contact edits keep using. Returns the `pubky://` + /// URI, and writes nothing unless `expectedIdentity` is still signed in when it runs. + func uploadContactAvatar(image: UIImage, expectedIdentity: String) async throws -> String { + _ = try activeSessionSecret() + let imageData = try compressAvatar(image) + return try await PubkyService.uploadProfileAvatar(bytes: imageData, contentType: "image/jpeg", expectedIdentity: expectedIdentity) + } + private func compressAvatar(_ image: UIImage, maxSize: CGFloat = 400) throws -> Data { // Resize to max dimensions let scale = min(maxSize / image.size.width, maxSize / image.size.height, 1.0) diff --git a/Bitkit/Views/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift index 5f5603824..370869ee8 100644 --- a/Bitkit/Views/Contacts/EditContactView.swift +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -160,7 +160,7 @@ struct EditContactView: View { ) { fillFormFromContact() let uploadedImageUrl = if let avatarImage { - try await pubkyProfile.uploadAvatar(image: avatarImage, expectedIdentity: session.publicKey) + try await pubkyProfile.uploadContactAvatar(image: avatarImage, expectedIdentity: session.publicKey) } else { form.imageUrl } From 08f6ce8f3e77e70c4902d4334daf8397180a8244 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 18:44:03 +0100 Subject: [PATCH 06/25] fix: save a multi-line bio as one line --- Bitkit/Managers/PubkyAppProfileStore.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index b6e8cbf05..7b15af66f 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -84,6 +84,8 @@ actor PubkyAppProfileStore { /// Saves `data` for `expectedIdentity`, or the signed-in identity. Throws `changedElsewhere` after reloading when /// pubky.app changed the profile since it was loaded. func save(_ data: PubkyProfileData, expectedIdentity: String?) async throws { + var data = data + data.bio = data.bio.replacingOccurrences(of: "\n", with: " ") let key = try await Self.identity(expectedIdentity) let base = bases[key] let expectedRevision: String? = if let base { From 5d805c480f528bb6830df67be84723ec09683a5d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 20:41:06 +0100 Subject: [PATCH 07/25] fix: delete the pubky.app profile and keep a pending avatar --- Bitkit/Components/ProfileEditFormView.swift | 4 +- Bitkit/Managers/PubkyAppProfileStore.swift | 51 +++++++++++++++++--- Bitkit/Managers/PubkyProfileManager.swift | 8 +++ Bitkit/Models/PubkyProfile.swift | 16 ++++++ Bitkit/Services/PubkyService.swift | 12 +++++ Bitkit/Views/Profile/CreateProfileView.swift | 11 +++-- Bitkit/Views/Profile/EditProfileView.swift | 13 ++++- 7 files changed, 104 insertions(+), 11 deletions(-) diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index e092283d1..a20f96331 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -17,6 +17,8 @@ struct ProfileEditFormView: View { var bioLabel: String = t("profile__create_bio_label") let bioPlaceholder: String let isSaving: Bool + /// Whether the name can be saved; contact edits accept any non-empty name. + var isNameValid: (String) -> Bool = { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } let footerNote: String? let deleteLabel: String? var deleteSectionCaption: String = t("profile__edit_delete_section") @@ -294,7 +296,7 @@ struct ProfileEditFormView: View { await onSave() } .accessibilityIdentifier("ProfileEditSave") - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!isNameValid(name)) } .padding(.horizontal, 16) .padding(.top, 16) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 7b15af66f..32886f103 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -32,6 +32,14 @@ actor PubkyAppProfileStore { set { UserDefaults.standard.set(Array(newValue), forKey: pubkyAppProfileOwnersKey) } } + private static let pubkyAppAvatarPendingKey = "pubky_app_avatar_pending" + + /// Identities whose Bitkit avatar is still to be moved to pubky.app, retried on each load. + private static var pubkyAppAvatarPending: Set { + get { Set(UserDefaults.standard.stringArray(forKey: pubkyAppAvatarPendingKey) ?? []) } + set { UserDefaults.standard.set(Array(newValue), forKey: pubkyAppAvatarPendingKey) } + } + private struct Base { let profile: PubkyProfile let revision: String? @@ -53,13 +61,17 @@ actor PubkyAppProfileStore { Logger.warn("Failed to read Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") paykitData = nil } - var snapshot = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) + let fetched = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) + var snapshot = fetched let isSignedIn = await Self.isSignedIn(key) // Once a pubky.app profile was seen, one missing there was deleted there, so it is not moved back from Bitkit. let hadPubkyAppProfile = Self.pubkyAppProfileOwners.contains(key) if snapshot == nil, let paykitData, isSignedIn, !hadPubkyAppProfile { snapshot = await migrate(paykitData, publicKey: key) + } else if let current = snapshot, isSignedIn, Self.pubkyAppAvatarPending.contains(key), let uri = paykitData?.image { + snapshot = await moveAvatar(uri, onto: current, publicKey: key) ?? current } + let wrote = snapshot?.revision != fetched?.revision if snapshot != nil, !hadPubkyAppProfile { Self.pubkyAppProfileOwners.insert(key) } @@ -71,11 +83,16 @@ actor PubkyAppProfileStore { } throw PubkyServiceError.profileNotFound } - let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) - if isLatest { + let avatarPending = Self.pubkyAppAvatarPending.contains(key) + var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) + if avatarPending, let uri = paykitData?.image { + profile = profile.withImageUrl(uri) + } + // A refresh keeps the revision an open edit started from, so the edit's save is checked against it. + if isLatest, bases[key] == nil || wrote { bases[key] = Base(profile: profile, revision: snapshot.revision) } - if isSignedIn, let paykitData, paykitData != PubkyProfileData.from(profile: profile) { + if isSignedIn, !avatarPending, let paykitData, paykitData != PubkyProfileData.from(profile: profile) { await publishCopy(profile, expectedIdentity: key) } return profile @@ -101,6 +118,7 @@ actor PubkyAppProfileStore { try await PubkyService.publishPubkyProfile(update, expectedRevision: expectedRevision, expectedIdentity: key) } } catch PaykitError.ConcurrentUpdate { + bases[key] = nil _ = try? await load(publicKey: key) throw PubkyAppProfileError.changedElsewhere } @@ -141,8 +159,16 @@ actor PubkyAppProfileStore { return nil } guard let uri = data.image else { return snapshot } + return await moveAvatar(uri, onto: snapshot, publicKey: publicKey) ?? snapshot + } + + private func moveAvatar( + _ uri: String, + onto snapshot: Paykit.PubkyProfileSnapshot, + publicKey: String + ) async -> Paykit.PubkyProfileSnapshot? { do { - return try await withPubkyAppScope { + let moved = try await withPubkyAppScope { let bytes = try await PubkyService.fetchFile(uri: uri, maxBytes: Self.avatarMaxBytes) let image = try await PubkyService.uploadPubkyAppAvatar( bytes: bytes, @@ -155,10 +181,23 @@ actor PubkyAppProfileStore { expectedIdentity: publicKey ) } + Self.pubkyAppAvatarPending.remove(publicKey) + return moved } catch { Logger.warn("Failed to move avatar to pubky.app: \(error)", context: "PubkyAppProfileStore") - return snapshot + Self.pubkyAppAvatarPending.insert(publicKey) + return nil + } + } + + /// Deletes the identity's pubky.app profile, for a wallet-owned identity deleting its profile. + func deleteProfile(expectedIdentity: String) async throws { + let key = Self.normalized(expectedIdentity) + guard let revision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision else { return } + try await withPubkyAppScope { + try await PubkyService.deletePubkyProfile(expectedRevision: revision, expectedIdentity: key) } + bases[key] = nil } private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String) async { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index d253958a5..0d9fcb16a 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -168,6 +168,10 @@ class PubkyProfileManager: ObservableObject { try await PubkyAppProfileStore.shared.uploadAvatar($0, expectedIdentity: $1) }, profileDeleter: @escaping ProfileDeleter = { + // A Ring identity's profile belongs to Ring, so unlinking it leaves its pubky.app profile in place. + if !PubkyProfileManager.isRingAdopted(publicKey: $0) { + try await PubkyAppProfileStore.shared.deleteProfile(expectedIdentity: $0) + } try await PubkyService.deletePaykitProfile(expectedIdentity: $0) } ) { @@ -1770,6 +1774,10 @@ class PubkyProfileManager: ObservableObject { /// The signed-in pubky is Pubky Ring's, adopted through the shared keychain, so removing it here only unlinks it. var isRingAdoptedIdentity: Bool { + Self.isRingAdopted(publicKey: publicKey) + } + + nonisolated static func isRingAdopted(publicKey: String?) -> Bool { guard let adopted = AdoptedPubkyReference.current, adopted.sourceApp == SharedPubkyKeychain.ringSourceApp else { return false } diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index 64067dcc7..3f4d47b67 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -116,6 +116,10 @@ struct PubkyProfile { let tags: [String] let status: String? + func withImageUrl(_ imageUrl: String?) -> PubkyProfile { + PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + } + var truncatedPublicKey: String { Self.truncate(publicKey) } @@ -213,3 +217,15 @@ struct PubkyProfile { return PubkyPublicKeyFormat.normalized(trimmedKey) ?? (trimmedKey.hasPrefix("pubky") ? trimmedKey : "pubky\(trimmedKey)") } } + +// MARK: - pubky.app profile limits + +/// Limits pubky-app-specs sets on the pubky.app profile. +enum PubkyAppProfileLimits { + static let nameLength = 3 ... 50 + static let bioMaxLength = 160 + + static func isValidName(_ name: String) -> Bool { + nameLength.contains(name.trimmingCharacters(in: .whitespacesAndNewlines).count) + } +} diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index d1b8e5e57..d01f7ebd3 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -380,6 +380,10 @@ enum PubkyService { ) } + static func deletePubkyProfile(expectedRevision: String, expectedIdentity: String) async throws { + try await PaykitSdkService.shared.deletePubkyProfile(expectedRevision: expectedRevision, expectedIdentity: expectedIdentity) + } + static func uploadPubkyAppAvatar(bytes: Data, contentType: String, expectedIdentity: String) async throws -> String { try await PaykitSdkService.shared.uploadPubkyAppAvatar( bytes: bytes, @@ -877,6 +881,14 @@ actor PaykitSdkService { } } + /// Deletes the pubky.app profile only while `expectedIdentity` is signed in. + func deletePubkyProfile(expectedRevision: String, expectedIdentity: String) async throws { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + try await sdk.deletePubkyProfile(expectedRevision: expectedRevision) + } + } + /// Uploads a pubky.app avatar blob and file only while `expectedIdentity` is signed in, and returns the file URI. func uploadPubkyAppAvatar(bytes: Data, contentType: String, expectedIdentity: String) async throws -> String { try await withStateRevisionTracking { sdk in diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 42119d49f..dd51e7061 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -37,6 +37,11 @@ struct CreateProfileView: View { .task { await loadInitialData() } + .onChange(of: username) { _, value in + if value.count > PubkyAppProfileLimits.nameLength.upperBound { + username = String(value.prefix(PubkyAppProfileLimits.nameLength.upperBound)) + } + } } // MARK: - Form Content @@ -64,7 +69,7 @@ struct CreateProfileView: View { ) { await saveProfile() } - .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!PubkyAppProfileLimits.isValidName(username)) .accessibilityIdentifier("CreateProfileSave") .padding(.top, 16) .padding(.horizontal, 16) @@ -187,7 +192,7 @@ struct CreateProfileView: View { } do { - let profile = try await PubkyProfileManager.resolveRemoteProfile(publicKey: publicKey) + let profile = try await PubkyAppProfileStore.shared.load(publicKey: publicKey) remoteLookupFailed = false return profile } catch PubkyServiceError.profileNotFound { @@ -213,7 +218,7 @@ struct CreateProfileView: View { } let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } + guard PubkyAppProfileLimits.isValidName(trimmedName) else { return } isSaving = true defer { isSaving = false } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 9c9545127..8979cb978 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -32,6 +32,7 @@ struct EditProfileView: View { publicKeyLabel: t("profile__create_pubky_display_label"), bioPlaceholder: t("profile__create_bio_placeholder"), isSaving: isSaving, + isNameValid: PubkyAppProfileLimits.isValidName, footerNote: t("profile__edit_public_note"), deleteLabel: copy.label, deleteSectionCaption: copy.sectionCaption, @@ -60,6 +61,16 @@ struct EditProfileView: View { .task { loadProfileData() } + .onChange(of: username) { _, value in + if value.count > PubkyAppProfileLimits.nameLength.upperBound { + username = String(value.prefix(PubkyAppProfileLimits.nameLength.upperBound)) + } + } + .onChange(of: bio) { _, value in + if value.count > PubkyAppProfileLimits.bioMaxLength { + bio = String(value.prefix(PubkyAppProfileLimits.bioMaxLength)) + } + } .alert(copy.title, isPresented: $showDeleteConfirmation) { Button(copy.confirm, role: .destructive) { Task { await deleteProfile() } @@ -216,7 +227,7 @@ struct EditProfileView: View { /// or the profile publishes, it stops quietly, with no toast and no navigation. private func saveProfile() async { let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } + guard PubkyAppProfileLimits.isValidName(trimmedName) else { return } isSaving = true defer { isSaving = false } From 1eaabe4a2631bb207f7e6d2c2cfcb77f71cceeda Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 20:54:43 +0100 Subject: [PATCH 08/25] fix: carry the edit's revision and move every bitkit profile to pubky.app --- Bitkit/Managers/PubkyAppProfileStore.swift | 172 ++++++++++++++------ Bitkit/Managers/PubkyProfileManager.swift | 45 ++++- Bitkit/Models/PubkyProfile.swift | 12 +- Bitkit/Views/Profile/EditProfileView.swift | 5 +- Bitkit/Views/Profile/ProfileView.swift | 6 +- BitkitTests/PubkyAppProfileStoreTests.swift | 8 + 6 files changed, 187 insertions(+), 61 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 32886f103..c9bcc72ab 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -40,92 +40,124 @@ actor PubkyAppProfileStore { set { UserDefaults.standard.set(Array(newValue), forKey: pubkyAppAvatarPendingKey) } } - private struct Base { - let profile: PubkyProfile - let revision: String? - } - - private var bases: [String: Base] = [:] - private var saveCount = 0 + /// The profile each recently seen revision held, so a save diffs against what its form was built from. + private var profilesByRevision: [String: [String: PubkyProfile]] = [:] + private var revisionOrder: [String: [String]] = [:] + private var lastSavedRevisions: [String: String] = [:] + private var tagsUnavailable: Set = [] + private static let rememberedRevisions = 8 - /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first. + /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first; while + /// that move cannot finish, the Bitkit profile is shown and the next save creates the pubky.app profile. func load(publicKey: String) async throws -> PubkyProfile { let key = Self.normalized(publicKey) - let savesBeforeLoad = saveCount let paykitData: PubkyProfileData? do { paykitData = try await PubkyService.fetchPaykitProfile(publicKey: key).map { PubkyProfileData.from(paykitProfile: $0.profile) } + tagsUnavailable.remove(key) } catch { Logger.warn("Failed to read Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") paykitData = nil + tagsUnavailable.insert(key) } let fetched = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) var snapshot = fetched let isSignedIn = await Self.isSignedIn(key) // Once a pubky.app profile was seen, one missing there was deleted there, so it is not moved back from Bitkit. let hadPubkyAppProfile = Self.pubkyAppProfileOwners.contains(key) - if snapshot == nil, let paykitData, isSignedIn, !hadPubkyAppProfile { + let migrating = snapshot == nil && isSignedIn && !hadPubkyAppProfile + if migrating, let paykitData { snapshot = await migrate(paykitData, publicKey: key) } else if let current = snapshot, isSignedIn, Self.pubkyAppAvatarPending.contains(key), let uri = paykitData?.image { snapshot = await moveAvatar(uri, onto: current, publicKey: key) ?? current } - let wrote = snapshot?.revision != fetched?.revision - if snapshot != nil, !hadPubkyAppProfile { - Self.pubkyAppProfileOwners.insert(key) - } - // A save that finished while this load ran holds a newer revision than this read. - let isLatest = saveCount == savesBeforeLoad guard let snapshot else { - if isLatest { - bases[key] = nil + if migrating, let paykitData { + return paykitData.toProfile(publicKey: key) } throw PubkyServiceError.profileNotFound } + if !hadPubkyAppProfile { + Self.pubkyAppProfileOwners.insert(key) + } let avatarPending = Self.pubkyAppAvatarPending.contains(key) var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) if avatarPending, let uri = paykitData?.image { profile = profile.withImageUrl(uri) } - // A refresh keeps the revision an open edit started from, so the edit's save is checked against it. - if isLatest, bases[key] == nil || wrote { - bases[key] = Base(profile: profile, revision: snapshot.revision) - } + remember(profile, for: key) if isSignedIn, !avatarPending, let paykitData, paykitData != PubkyProfileData.from(profile: profile) { - await publishCopy(profile, expectedIdentity: key) + try? await publishCopy(profile, expectedIdentity: key) } return profile } - /// Saves `data` for `expectedIdentity`, or the signed-in identity. Throws `changedElsewhere` after reloading when - /// pubky.app changed the profile since it was loaded. + /// Saves `data` for `expectedIdentity`, or the signed-in identity. A save carrying the revision its form was built + /// from writes only the fields that differ from that revision; without one it creates or replaces the profile. + /// Throws `changedElsewhere` after reloading when pubky.app changed the profile since that revision. func save(_ data: PubkyProfileData, expectedIdentity: String?) async throws { - var data = data - data.bio = data.bio.replacingOccurrences(of: "\n", with: " ") + let data = Self.normalized(data) let key = try await Self.identity(expectedIdentity) - let base = bases[key] - let expectedRevision: String? = if let base { - base.revision + var base: PubkyProfile? + let expectedRevision: String? + if let baseRevision = data.baseRevision { + guard let known = profilesByRevision[key]?[baseRevision] else { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + base = known + expectedRevision = baseRevision } else { // Creating: an existing pubky.app profile keeps every field the form does not set. - try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision + expectedRevision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision } - let update = Self.update(from: data, base: base?.profile) + let update = Self.update(from: data, base: base) let snapshot: Paykit.PubkyProfileSnapshot do { snapshot = try await withPubkyAppScope { try await PubkyService.publishPubkyProfile(update, expectedRevision: expectedRevision, expectedIdentity: key) } } catch PaykitError.ConcurrentUpdate { - bases[key] = nil _ = try? await load(publicKey: key) throw PubkyAppProfileError.changedElsewhere } - let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: data.tags) - saveCount += 1 - bases[key] = Base(profile: profile, revision: snapshot.revision) - await publishCopy(profile, expectedIdentity: key) + Self.pubkyAppProfileOwners.insert(key) + let tags = await tagsToKeep(data.tags, for: key) + let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: tags) + remember(profile, for: key) + lastSavedRevisions[key] = snapshot.revision + // Tags live only in the Paykit copy, so a changed tag that it does not take is reported. + let tagsChanged = tags != (base?.tags ?? []) + do { + try await publishCopy(profile, expectedIdentity: key) + } catch where tagsChanged { + throw error + } catch {} + } + + /// The revision the last successful save for `publicKey` produced. + func lastSavedRevision(publicKey: String) -> String? { + lastSavedRevisions[Self.normalized(publicKey)] + } + + private func remember(_ profile: PubkyProfile, for key: String) { + guard let revision = profile.revision else { return } + profilesByRevision[key, default: [:]][revision] = profile + var order = revisionOrder[key, default: []].filter { $0 != revision } + [revision] + while order.count > Self.rememberedRevisions { + profilesByRevision[key]?[order.removeFirst()] = nil + } + revisionOrder[key] = order + } + + /// When the last load could not read the Paykit copy, the form showed no tags, so the stored ones are kept. + private func tagsToKeep(_ tags: [String], for key: String) async -> [String] { + guard tagsUnavailable.contains(key), let stored = try? await PubkyService.fetchPaykitProfile(publicKey: key) else { return tags } + tagsUnavailable.remove(key) + let storedTags = PubkyProfileData.from(paykitProfile: stored.profile).tags + return storedTags + tags.filter { !storedTags.contains($0) } } /// Uploads a JPEG avatar as a pubky.app blob and file and returns the file URI for the profile image. @@ -138,15 +170,17 @@ actor PubkyAppProfileStore { private func migrate(_ data: PubkyProfileData, publicKey: String) async -> Paykit.PubkyProfileSnapshot? { let links = data.links - .map { (label: $0.label.trimmed, url: $0.url.trimmed) } - .filter { !$0.label.isEmpty && $0.url.count <= Self.linkUrlMaxLength && URL(string: $0.url)?.scheme != nil } + .compactMap { link -> Paykit.PubkyProfileLink? in + let title = Self.singleLine(link.label) + guard !title.isEmpty, let url = Self.linkUrl(link.url) else { return nil } + return Paykit.PubkyProfileLink(title: Self.prefix(title, Self.linkTitleMaxLength), url: url) + } .prefix(Self.linksMaxCount) - .map { Paykit.PubkyProfileLink(title: String($0.label.prefix(Self.linkTitleMaxLength)), url: $0.url) } let update = Paykit.PubkyProfileUpdate( - name: String(data.name.trimmed.prefix(Self.nameMaxLength)), - bio: String(data.bio.trimmed.prefix(Self.bioMaxLength)), + name: Self.prefix(Self.singleLine(data.name), Self.nameMaxLength), + bio: Self.prefix(Self.singleLine(data.bio), Self.bioMaxLength), image: nil, - links: links + links: Array(links) ) let snapshot: Paykit.PubkyProfileSnapshot do { @@ -197,10 +231,11 @@ actor PubkyAppProfileStore { try await withPubkyAppScope { try await PubkyService.deletePubkyProfile(expectedRevision: revision, expectedIdentity: key) } - bases[key] = nil + profilesByRevision[key] = nil + revisionOrder[key] = nil } - private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String) async { + private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String) async throws { do { try await PubkyService.publishPaykitProfile( PubkyProfileData.from(profile: profile).toPaykitProfile(), @@ -208,6 +243,7 @@ actor PubkyAppProfileStore { ) } catch { Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") + throw error } } @@ -239,7 +275,7 @@ actor PubkyAppProfileStore { private static func profile(publicKey: String, snapshot: Paykit.PubkyProfileSnapshot, tags: [String]) -> PubkyProfile { let profile = PubkyProfile(publicKey: publicKey, pubkyProfile: snapshot.profile) - return PubkyProfile( + var result = PubkyProfile( publicKey: publicKey, name: profile.name, bio: profile.bio, @@ -248,6 +284,44 @@ actor PubkyAppProfileStore { tags: tags, status: profile.status ) + result.revision = snapshot.revision + return result + } + + /// pubky.app keeps profile text on one line and links as full URLs. + private static func normalized(_ data: PubkyProfileData) -> PubkyProfileData { + var data = data + data.name = singleLine(data.name) + data.bio = singleLine(data.bio) + data.links = data.links.map { PubkyProfileData.Link(label: singleLine($0.label), url: linkUrl($0.url) ?? $0.url) } + return data + } + + private static func singleLine(_ text: String) -> String { + String(String.UnicodeScalarView(text.unicodeScalars.map { CharacterSet.controlCharacters.contains($0) ? " " : $0 })) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Truncates by Unicode scalars, as pubky.app counts them. + private static func prefix(_ text: String, _ maxScalars: Int) -> String { + String(String.UnicodeScalarView(text.unicodeScalars.prefix(maxScalars))) + } + + /// Bitkit accepted links without a scheme (a site, an email address, a phone number); pubky.app needs full URLs. + static func linkUrl(_ value: String) -> String? { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + let url: String = if URL(string: value)?.scheme != nil { + value + } else if value.contains("@") { + "mailto:\(value)" + } else if value.allSatisfy({ "+0123456789 -()".contains($0) }) { + "tel:\(value.filter { !" -()".contains($0) })" + } else { + "https://\(value)" + } + guard url.unicodeScalars.count <= linkUrlMaxLength, URL(string: url) != nil else { return nil } + return url } private static func identity(_ expectedIdentity: String?) async throws -> String { @@ -276,9 +350,3 @@ actor PubkyAppProfileStore { } } } - -private extension String { - var trimmed: String { - trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 0d9fcb16a..c9800211d 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -891,11 +891,20 @@ class PubkyProfileManager: ObservableObject { bio: String, links: [PubkyProfileLink], tags: [String] = [], - avatarImage: UIImage? = nil + avatarImage: UIImage? = nil, + baseRevision: String? = nil ) async throws -> Bool { guard let session = currentSession else { return false } _ = try activeSessionSecret() - return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) + return try await saveProfileEdit( + name: name, + bio: bio, + links: links, + tags: tags, + avatarImage: avatarImage, + baseRevision: baseRevision ?? profile?.revision, + session: session + ) } /// Starting the edit drops every profile read that started before it, ahead of the avatar upload, so a refresh that finds @@ -911,6 +920,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], avatarImage: UIImage?, + baseRevision: String?, session: SignedInSession ) async throws -> Bool { if profileReadAwaitingSaves?.session != session { @@ -925,6 +935,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, avatarImage: avatarImage, + baseRevision: baseRevision, session: session ) profileSavesInFlight -= 1 @@ -942,6 +953,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], avatarImage: UIImage?, + baseRevision: String?, session: SignedInSession ) async throws -> Bool { var newImageUrl: String? @@ -960,7 +972,15 @@ class PubkyProfileManager: ObservableObject { return false } } - return try await publishProfileEdit(name: name, bio: bio, links: links, tags: tags, newImageUrl: newImageUrl, session: session) + return try await publishProfileEdit( + name: name, + bio: bio, + links: links, + tags: tags, + newImageUrl: newImageUrl, + baseRevision: baseRevision, + session: session + ) } /// A published profile ends any pending setup. The SDK publishes only while the session's identity is still signed in, @@ -972,6 +992,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], newImageUrl: String?, + baseRevision: String?, session: SignedInSession ) async throws -> Bool { let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) @@ -983,6 +1004,7 @@ class PubkyProfileManager: ObservableObject { imageUrl: resolvedImageUrl, links: links, tags: tags, + baseRevision: baseRevision, expectedIdentity: session.publicKey ) } catch PubkyServiceError.identityChanged { @@ -997,7 +1019,7 @@ class PubkyProfileManager: ObservableObject { return false } - let updatedProfile = PubkyProfile( + var updatedProfile = PubkyProfile( publicKey: session.publicKey, name: name, bio: bio, @@ -1006,6 +1028,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, status: profile?.status ) + updatedProfile.revision = await PubkyAppProfileStore.shared.lastSavedRevision(publicKey: session.publicKey) commitProfile(updatedProfile) setProfileSetupPending(false) return true @@ -1080,15 +1103,17 @@ class PubkyProfileManager: ObservableObject { imageUrl: String?, links: [PubkyProfileLink], tags: [String] = [], + baseRevision: String? = nil, expectedIdentity: String? = nil ) async throws { - let profileData = PubkyProfileData( + var profileData = PubkyProfileData( name: name, bio: bio, image: imageUrl, links: links.map { PubkyProfileData.Link(label: $0.label, url: $0.url) }, tags: tags ) + profileData.baseRevision = baseRevision let publish = profilePublisher try await Task.detached { @@ -1168,7 +1193,15 @@ class PubkyProfileManager: ObservableObject { avatarImage: UIImage? = nil ) async throws -> Bool { guard let session = currentSession else { return false } - return try await saveProfileEdit(name: name, bio: bio, links: links, tags: tags, avatarImage: avatarImage, session: session) + return try await saveProfileEdit( + name: name, + bio: bio, + links: links, + tags: tags, + avatarImage: avatarImage, + baseRevision: profile?.revision, + session: session + ) } func clearAuthenticatedStateForTesting() { diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index 3f4d47b67..cc279b10b 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -9,6 +9,12 @@ struct PubkyProfileData: Codable, Equatable { var image: String? var links: [Link] var tags: [String] + /// The pubky.app revision an edit started from; never part of the published copy. + var baseRevision: String? + + private enum CodingKeys: String, CodingKey { + case name, bio, image, links, tags + } struct Link: Codable, Equatable { let label: String @@ -115,9 +121,13 @@ struct PubkyProfile { let links: [PubkyProfileLink] let tags: [String] let status: String? + /// The pubky.app revision this profile was read or saved at, for a save to check against. + var revision: String? func withImageUrl(_ imageUrl: String?) -> PubkyProfile { - PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + var profile = PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + profile.revision = revision + return profile } var truncatedPublicKey: String { diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 8979cb978..aed734c66 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -11,6 +11,7 @@ struct EditProfileView: View { @State private var bio: String = "" @State private var links: [ProfileLinkInput] = [] @State private var tags: [String] = [] + @State private var baseRevision: String? @State private var isSaving = false @State private var isDeleting = false @State private var showDeleteConfirmation = false @@ -154,6 +155,7 @@ struct EditProfileView: View { bio = profile.bio links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } tags = profile.tags + baseRevision = profile.revision } // MARK: - Delete Profile @@ -238,7 +240,8 @@ struct EditProfileView: View { bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, tags: tags, - avatarImage: avatarImage + avatarImage: avatarImage, + baseRevision: baseRevision ) guard isSaved else { return } app.toast(type: .success, title: t("profile__edit_saved"), accessibilityIdentifier: "ProfileUpdatedToast") diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index 9b3c4100b..e4165530b 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -279,11 +279,15 @@ struct ProfileView: View { name: profile.name, bio: profile.bio, links: profile.links, - tags: tags + tags: tags, + baseRevision: profile.revision ) } catch { Logger.error("Failed to update profile tags: \(error)", context: "ProfileView") app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + if case PubkyAppProfileError.changedElsewhere = error { + await pubkyProfile.loadProfile() + } } } } diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index bfc5de176..4200fc305 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -51,4 +51,12 @@ final class PubkyAppProfileStoreTests: XCTestCase { XCTAssertEqual(update.image, base.imageUrl) XCTAssertEqual(update.links?.first?.title, "X") } + + func testLinksWithoutSchemeBecomeFullUrls() { + XCTAssertEqual(PubkyAppProfileStore.linkUrl("example.com"), "https://example.com") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("alice@example.com"), "mailto:alice@example.com") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("+49 123 456"), "tel:+49123456") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("https://x.com/alice"), "https://x.com/alice") + XCTAssertNil(PubkyAppProfileStore.linkUrl(" ")) + } } From cd310aba93198de5e383dd90a73e7aed36e49644 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 22:35:04 +0100 Subject: [PATCH 09/25] fix: keep pubky.app edits on create, avatar moves and tag saves --- Bitkit/Components/ProfileEditFormView.swift | 19 +- Bitkit/Managers/PubkyAppProfileStore.swift | 165 +++++++++++++----- Bitkit/Managers/PubkyProfileManager.swift | 52 +++++- Bitkit/Models/PubkyProfile.swift | 16 +- .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + Bitkit/Views/Profile/CreateProfileView.swift | 8 +- Bitkit/Views/Profile/EditProfileView.swift | 16 +- Bitkit/Views/Profile/ProfileView.swift | 5 +- BitkitTests/PubkyAppProfileStoreTests.swift | 23 +++ 23 files changed, 246 insertions(+), 73 deletions(-) diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index a20f96331..77e1480eb 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -17,6 +17,7 @@ struct ProfileEditFormView: View { var bioLabel: String = t("profile__create_bio_label") let bioPlaceholder: String let isSaving: Bool + var maxLinks: Int? /// Whether the name can be saved; contact edits accept any non-empty name. var isNameValid: (String) -> Bool = { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } let footerNote: String? @@ -156,14 +157,16 @@ struct ProfileEditFormView: View { linkRow(index: index) } - IconActionButton( - icon: "link", - isSystemIcon: true, - title: t("profile__create_add_link"), - accessibilityId: "ProfileEditAddLink" - ) { - dismissKeyboard() - showAddLinkSheet = true + if maxLinks.map({ links.count < $0 }) ?? true { + IconActionButton( + icon: "link", + isSystemIcon: true, + title: t("profile__create_add_link"), + accessibilityId: "ProfileEditAddLink" + ) { + dismissKeyboard() + showAddLinkSheet = true + } } } } diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index c9bcc72ab..0f998ad8e 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -4,9 +4,14 @@ import Paykit enum PubkyAppProfileError: LocalizedError { /// The pubky.app profile changed since Bitkit loaded it, so the save was not applied. case changedElsewhere + /// The other fields were saved at `revision`, but the tag change was not. + case tagsNotSaved(revision: String) var errorDescription: String? { - t("profile__edit_changed_error") + switch self { + case .changedElsewhere: t("profile__edit_changed_error") + case .tagsNotSaved: t("profile__edit_tags_error") + } } } @@ -43,15 +48,18 @@ actor PubkyAppProfileStore { /// The profile each recently seen revision held, so a save diffs against what its form was built from. private var profilesByRevision: [String: [String: PubkyProfile]] = [:] private var revisionOrder: [String: [String]] = [:] - private var lastSavedRevisions: [String: String] = [:] + private var savedProfiles: [UUID: PubkyProfile] = [:] + private var pendingAvatarUris: [String: String] = [:] private var tagsUnavailable: Set = [] private static let rememberedRevisions = 8 - /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first; while - /// that move cannot finish, the Bitkit profile is shown and the next save creates the pubky.app profile. + /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first; until + /// that move happens (signed out, or it cannot finish) the Bitkit profile is shown and the next save creates the + /// pubky.app profile. func load(publicKey: String) async throws -> PubkyProfile { let key = Self.normalized(publicKey) let paykitData: PubkyProfileData? + var paykitReadError: Error? do { paykitData = try await PubkyService.fetchPaykitProfile(publicKey: key).map { PubkyProfileData.from(paykitProfile: $0.profile) @@ -60,6 +68,7 @@ actor PubkyAppProfileStore { } catch { Logger.warn("Failed to read Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") paykitData = nil + paykitReadError = error tagsUnavailable.insert(key) } let fetched = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) @@ -67,22 +76,31 @@ actor PubkyAppProfileStore { let isSignedIn = await Self.isSignedIn(key) // Once a pubky.app profile was seen, one missing there was deleted there, so it is not moved back from Bitkit. let hadPubkyAppProfile = Self.pubkyAppProfileOwners.contains(key) - let migrating = snapshot == nil && isSignedIn && !hadPubkyAppProfile - if migrating, let paykitData { + if fetched == nil, isSignedIn, !hadPubkyAppProfile, let paykitData { snapshot = await migrate(paykitData, publicKey: key) - } else if let current = snapshot, isSignedIn, Self.pubkyAppAvatarPending.contains(key), let uri = paykitData?.image { - snapshot = await moveAvatar(uri, onto: current, publicKey: key) ?? current + } else if let current = fetched, isSignedIn, Self.pubkyAppAvatarPending.contains(key), let uri = paykitData?.image { + if current.profile.image == nil, Self.isPaykitStorage(uri) { + snapshot = await moveAvatar(uri, onto: current, publicKey: key) ?? current + } else { + // An avatar set on pubky.app meanwhile wins over the one still to move, and only a Bitkit avatar on Paykit + // storage is moved. + Self.pubkyAppAvatarPending.remove(key) + } } guard let snapshot else { - if migrating, let paykitData { + if !hadPubkyAppProfile, let paykitData { return paykitData.toProfile(publicKey: key) } + if let paykitReadError { + throw paykitReadError + } throw PubkyServiceError.profileNotFound } if !hadPubkyAppProfile { Self.pubkyAppProfileOwners.insert(key) } let avatarPending = Self.pubkyAppAvatarPending.contains(key) + pendingAvatarUris[key] = avatarPending ? paykitData?.image : nil var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) if avatarPending, let uri = paykitData?.image { profile = profile.withImageUrl(uri) @@ -95,51 +113,78 @@ actor PubkyAppProfileStore { } /// Saves `data` for `expectedIdentity`, or the signed-in identity. A save carrying the revision its form was built - /// from writes only the fields that differ from that revision; without one it creates or replaces the profile. - /// Throws `changedElsewhere` after reloading when pubky.app changed the profile since that revision. + /// from writes only the fields that differ from that revision; one without a revision creates the profile, and is + /// refused when a pubky.app profile exists by then. Throws `changedElsewhere` after reloading when pubky.app changed + /// the profile since that revision. func save(_ data: PubkyProfileData, expectedIdentity: String?) async throws { let data = Self.normalized(data) let key = try await Self.identity(expectedIdentity) var base: PubkyProfile? - let expectedRevision: String? if let baseRevision = data.baseRevision { guard let known = profilesByRevision[key]?[baseRevision] else { _ = try? await load(publicKey: key) throw PubkyAppProfileError.changedElsewhere } base = known - expectedRevision = baseRevision - } else { - // Creating: an existing pubky.app profile keeps every field the form does not set. - expectedRevision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision + } else if try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) != nil { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + var update = Self.update(from: data, base: base) + // A Bitkit avatar still on Paykit storage is moved by a later load rather than written to pubky.app as is. + let paykitAvatar = data.image.flatMap { Self.isPaykitStorage($0) ? $0 : nil } + if let image = update.image, Self.isPaykitStorage(image) { + update.image = nil } - let update = Self.update(from: data, base: base) let snapshot: Paykit.PubkyProfileSnapshot do { snapshot = try await withPubkyAppScope { - try await PubkyService.publishPubkyProfile(update, expectedRevision: expectedRevision, expectedIdentity: key) + try await PubkyService.publishPubkyProfile(update, expectedRevision: data.baseRevision, expectedIdentity: key) } } catch PaykitError.ConcurrentUpdate { _ = try? await load(publicKey: key) throw PubkyAppProfileError.changedElsewhere } Self.pubkyAppProfileOwners.insert(key) + if let paykitAvatar, snapshot.profile.image == nil { + Self.pubkyAppAvatarPending.insert(key) + pendingAvatarUris[key] = paykitAvatar + } else { + Self.pubkyAppAvatarPending.remove(key) + pendingAvatarUris[key] = nil + } + let previousTags = base?.tags ?? [] let tags = await tagsToKeep(data.tags, for: key) - let profile = Self.profile(publicKey: key, snapshot: snapshot, tags: tags) - remember(profile, for: key) - lastSavedRevisions[key] = snapshot.revision - // Tags live only in the Paykit copy, so a changed tag that it does not take is reported. - let tagsChanged = tags != (base?.tags ?? []) - do { - try await publishCopy(profile, expectedIdentity: key) - } catch where tagsChanged { - throw error - } catch {} + var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: tags ?? previousTags) + if Self.pubkyAppAvatarPending.contains(key), let uri = pendingAvatarUris[key] { + profile = profile.withImageUrl(uri) + } + // Tags live only in the Paykit copy, so a tag change it does not take is reported, and the remembered base keeps + // the tags it last stored so a retry is reported again. + var copyError: Error? + if tags != nil { + do { + try await publishCopy(profile, expectedIdentity: key) + } catch { + copyError = error + } + } + let storedProfile = tags != nil && copyError == nil ? profile : profile.withTags(previousTags) + remember(storedProfile, for: key) + if let saveId = data.saveId { + savedProfiles[saveId] = storedProfile + } + if tags == nil || copyError != nil, data.tags != previousTags { + if let copyError { + Logger.warn("Saved the profile without its tag change: \(copyError)", context: "PubkyAppProfileStore") + } + throw PubkyAppProfileError.tagsNotSaved(revision: snapshot.revision) + } } - /// The revision the last successful save for `publicKey` produced. - func lastSavedRevision(publicKey: String) -> String? { - lastSavedRevisions[Self.normalized(publicKey)] + /// The profile a save stored, by the id its data carried; read once. + func savedProfile(saveId: UUID) -> PubkyProfile? { + savedProfiles.removeValue(forKey: saveId) } private func remember(_ profile: PubkyProfile, for key: String) { @@ -152,11 +197,18 @@ actor PubkyAppProfileStore { revisionOrder[key] = order } - /// When the last load could not read the Paykit copy, the form showed no tags, so the stored ones are kept. - private func tagsToKeep(_ tags: [String], for key: String) async -> [String] { - guard tagsUnavailable.contains(key), let stored = try? await PubkyService.fetchPaykitProfile(publicKey: key) else { return tags } + /// When the last load could not read the Paykit copy, the form showed no tags, so the stored ones are kept; nil while + /// they still cannot be read. + private func tagsToKeep(_ tags: [String], for key: String) async -> [String]? { + guard tagsUnavailable.contains(key) else { return tags } + let stored: Paykit.PaykitProfileRecord? + do { + stored = try await PubkyService.fetchPaykitProfile(publicKey: key) + } catch { + return nil + } tagsUnavailable.remove(key) - let storedTags = PubkyProfileData.from(paykitProfile: stored.profile).tags + let storedTags = stored.map { PubkyProfileData.from(paykitProfile: $0.profile).tags } ?? [] return storedTags + tags.filter { !storedTags.contains($0) } } @@ -192,7 +244,7 @@ actor PubkyAppProfileStore { Logger.warn("Failed to move Bitkit profile to pubky.app: \(error)", context: "PubkyAppProfileStore") return nil } - guard let uri = data.image else { return snapshot } + guard let uri = data.image, Self.isPaykitStorage(uri) else { return snapshot } return await moveAvatar(uri, onto: snapshot, publicKey: publicKey) ?? snapshot } @@ -297,8 +349,8 @@ actor PubkyAppProfileStore { return data } - private static func singleLine(_ text: String) -> String { - String(String.UnicodeScalarView(text.unicodeScalars.map { CharacterSet.controlCharacters.contains($0) ? " " : $0 })) + static func singleLine(_ text: String) -> String { + String(String.UnicodeScalarView(text.unicodeScalars.map { $0.properties.generalCategory == .control ? " " : $0 })) .trimmingCharacters(in: .whitespacesAndNewlines) } @@ -308,22 +360,43 @@ actor PubkyAppProfileStore { } /// Bitkit accepted links without a scheme (a site, an email address, a phone number); pubky.app needs full URLs. + /// Free text that is none of these is not a link pubky.app can hold. static func linkUrl(_ value: String) -> String? { let value = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty else { return nil } - let url: String = if URL(string: value)?.scheme != nil { - value - } else if value.contains("@") { - "mailto:\(value)" - } else if value.allSatisfy({ "+0123456789 -()".contains($0) }) { - "tel:\(value.filter { !" -()".contains($0) })" + guard !value.isEmpty, !value.contains(where: \.isWhitespace) || isPhoneNumber(value) else { return nil } + let url: String + if URL(string: value)?.scheme != nil, !isPhoneNumber(value) { + url = value + } else if isEmailAddress(value) { + url = "mailto:\(value)" + } else if isPhoneNumber(value) { + url = "tel:\(value.filter { !" -()".contains($0) })" + } else if value.contains("."), !value.hasPrefix(".") { + url = "https://\(value)" } else { - "https://\(value)" + return nil } guard url.unicodeScalars.count <= linkUrlMaxLength, URL(string: url) != nil else { return nil } return url } + private static func isEmailAddress(_ value: String) -> Bool { + let parts = value.split(separator: "@", omittingEmptySubsequences: false) + return parts.count == 2 && !parts[0].isEmpty && parts[1].contains(".") && !value.contains("/") + } + + private static func isPhoneNumber(_ value: String) -> Bool { + let digits = value.filter(\.isNumber).count + return value.hasPrefix("+") || digits >= 7 + ? value.allSatisfy { "+0123456789 -()".contains($0) } && digits >= 5 && !value.dropFirst().contains("+") + && value.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) == nil + : false + } + + private static func isPaykitStorage(_ uri: String) -> Bool { + uri.contains("/pub/paykit/") + } + private static func identity(_ expectedIdentity: String?) async throws -> String { if let expectedIdentity { return normalized(expectedIdentity) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index c9800211d..5d9ecc3db 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -484,6 +484,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String] = [], existingImageUrl: String? = nil, + baseRevision: String? = nil, avatarImage: UIImage? = nil, loadStoredSecretKey: () async throws -> String? = { await Task.detached { PubkyProfileManager.activeSecretKeyHex() }.value @@ -499,6 +500,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, existingImageUrl: existingImageUrl, + baseRevision: baseRevision, avatarImage: avatarImage ) return @@ -533,6 +535,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, existingImageUrl: existingImageUrl, + baseRevision: baseRevision, avatarImage: avatarImage ) }, @@ -751,6 +754,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], existingImageUrl: String?, + baseRevision: String?, avatarImage: UIImage? ) async throws { var avatarUri: String? @@ -759,10 +763,18 @@ class PubkyProfileManager: ObservableObject { } let imageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - try await writeProfile(name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) + let savedProfile = try await writeProfile( + name: name, + bio: bio, + imageUrl: imageUrl, + links: links, + tags: tags, + baseRevision: baseRevision, + expectedIdentity: publicKey + ) Self.notifyAppStateBackupChanged() - let createdProfile = PubkyProfile( + let createdProfile = savedProfile ?? PubkyProfile( publicKey: publicKey, name: name, bio: bio, @@ -997,8 +1009,9 @@ class PubkyProfileManager: ObservableObject { ) async throws -> Bool { let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) + let savedProfile: PubkyProfile? do { - try await writeProfile( + savedProfile = try await writeProfile( name: name, bio: bio, imageUrl: resolvedImageUrl, @@ -1019,7 +1032,7 @@ class PubkyProfileManager: ObservableObject { return false } - var updatedProfile = PubkyProfile( + let updatedProfile = savedProfile ?? PubkyProfile( publicKey: session.publicKey, name: name, bio: bio, @@ -1028,7 +1041,6 @@ class PubkyProfileManager: ObservableObject { tags: tags, status: profile?.status ) - updatedProfile.revision = await PubkyAppProfileStore.shared.lastSavedRevision(publicKey: session.publicKey) commitProfile(updatedProfile) setProfileSetupPending(false) return true @@ -1105,7 +1117,7 @@ class PubkyProfileManager: ObservableObject { tags: [String] = [], baseRevision: String? = nil, expectedIdentity: String? = nil - ) async throws { + ) async throws -> PubkyProfile? { var profileData = PubkyProfileData( name: name, bio: bio, @@ -1114,11 +1126,24 @@ class PubkyProfileManager: ObservableObject { tags: tags ) profileData.baseRevision = baseRevision + let saveId = UUID() + profileData.saveId = saveId let publish = profilePublisher - try await Task.detached { - try await publish(profileData, expectedIdentity) - }.value + do { + try await Task.detached { + try await publish(profileData, expectedIdentity) + }.value + } catch let PubkyAppProfileError.tagsNotSaved(revision) { + // The other fields are on pubky.app, so the screen shows them and a retry starts from their revision. + if let saved = await PubkyAppProfileStore.shared.savedProfile(saveId: saveId), + PubkyPublicKeyFormat.matches(saved.publicKey, currentSession?.publicKey) + { + commitProfile(saved) + } + throw PubkyAppProfileError.tagsNotSaved(revision: revision) + } + return await PubkyAppProfileStore.shared.savedProfile(saveId: saveId) } private func discardAbandonedSession() async { @@ -1233,6 +1258,15 @@ class PubkyProfileManager: ObservableObject { readOnlyProfile = nil cacheProfileMetadata(loadedProfile) } + } catch PubkyServiceError.profileNotFound { + // A profile deleted on pubky.app is set up again rather than left unreachable. + guard publicKey == pk, profileWriteGeneration == read.generation else { return } + Logger.info("Profile no longer exists, starting profile setup", context: "PubkyProfileManager") + profile = nil + if PubkyPublicKeyFormat.matches(cachedProfileOwner, pk) { + clearCachedProfileMetadata() + } + setProfileSetupPending(true) } catch { Logger.error("Failed to load profile: \(error)", context: "PubkyProfileManager") } diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index cc279b10b..ac946263a 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -11,6 +11,8 @@ struct PubkyProfileData: Codable, Equatable { var tags: [String] /// The pubky.app revision an edit started from; never part of the published copy. var baseRevision: String? + /// Identifies the save, so its caller can read back the profile it stored; never part of the published copy. + var saveId: UUID? private enum CodingKeys: String, CodingKey { case name, bio, image, links, tags @@ -199,7 +201,9 @@ struct PubkyProfile { } func withTags(_ tags: [String]) -> PubkyProfile { - PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + var profile = PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) + profile.revision = revision + return profile } func withNameFallback(_ fallbackName: String?) -> PubkyProfile { @@ -234,8 +238,16 @@ struct PubkyProfile { enum PubkyAppProfileLimits { static let nameLength = 3 ... 50 static let bioMaxLength = 160 + static let maxLinks = 5 + /// pubky.app counts Unicode scalars, not characters. static func isValidName(_ name: String) -> Bool { - nameLength.contains(name.trimmingCharacters(in: .whitespacesAndNewlines).count) + nameLength.contains(name.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars.count) + } + + /// `text` cut to `maxLength` Unicode scalars, or nil when it already fits. + static func capped(_ text: String, to maxLength: Int) -> String? { + guard text.unicodeScalars.count > maxLength else { return nil } + return String(String.UnicodeScalarView(text.unicodeScalars.prefix(maxLength))) } } diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 4c16a48d3..4de87410e 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -957,6 +957,7 @@ "profile__edit_unlink_section" = "إلغاء الربط"; "profile__edit_changed_error" = "تغيّر ملفك الشخصي في pubky.app. حمّل Bitkit أحدث نسخة، لذا أعد إجراء تعديلك."; "profile__edit_public_note" = "هذا هو ملفك الشخصي على pubky.app. إنه عام، والتغييرات التي تجريها هنا تظهر أيضًا على pubky.app."; +"profile__edit_tags_error" = "تعذّر على Bitkit حفظ وسومك. حُفظت تغييراتك الأخرى، لذا حاول حفظ الوسوم مرة أخرى."; "profile__delete_title" = "حذف الملف الشخصي؟"; "profile__delete_description" = "يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا."; "profile__delete_confirm" = "نعم، احذف"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 2b1765dc4..79c385bcc 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1073,6 +1073,7 @@ "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_changed_error" = "El teu perfil ha canviat a pubky.app. Bitkit ha carregat la versió més recent, així que torna a fer la teva edició."; "profile__edit_public_note" = "Aquest és el teu perfil de pubky.app. És públic, i els canvis que hi facis aquí també es veuen a pubky.app."; +"profile__edit_tags_error" = "Bitkit no ha pogut desar les teves etiquetes. Els altres canvis s'han desat, així que torna a provar amb les etiquetes."; "profile__delete_title" = "Eliminar el perfil?"; "profile__delete_description" = "Eliminar el teu perfil l'esborra de Pubky. És possible que els contactes conservin la informació de perfil i de pagament que s'hi hagi compartit abans."; "profile__delete_confirm" = "Sí, elimina"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index a0c42e7ec..536529521 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1178,6 +1178,7 @@ "profile__edit_unlink_section" = "ZRUŠIT PROPOJENÍ"; "profile__edit_changed_error" = "Tvůj profil se v pubky.app změnil. Bitkit načetl nejnovější verzi, proveď proto úpravu znovu."; "profile__edit_public_note" = "Toto je tvůj profil na pubky.app. Je veřejný a změny, které zde provedeš, se zobrazí i na pubky.app."; +"profile__edit_tags_error" = "Bitkit nemohl uložit vaše štítky. Ostatní změny byly uloženy, tak zkuste štítky znovu."; "profile__delete_title" = "Smazat profil?"; "profile__delete_description" = "Smazáním profilu ho odstraníte z Pubky. Kontakty si mohou ponechat informace o profilu a platbách, které jste s nimi dříve sdíleli."; "profile__delete_confirm" = "Ano, smazat"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index f790cd477..8b225819d 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1176,6 +1176,7 @@ "profile__edit_unlink_section" = "VERKNÜPFUNG AUFHEBEN"; "profile__edit_changed_error" = "Dein Profil wurde in pubky.app geändert. Bitkit hat die neueste Version geladen, nimm deine Änderung bitte erneut vor."; "profile__edit_public_note" = "Das ist dein pubky.app-Profil. Es ist öffentlich, und Änderungen, die du hier vornimmst, erscheinen auch auf pubky.app."; +"profile__edit_tags_error" = "Bitkit konnte deine Tags nicht speichern. Deine anderen Änderungen wurden gespeichert, versuche es mit den Tags erneut."; "profile__delete_title" = "Profil löschen?"; "profile__delete_description" = "Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten."; "profile__delete_confirm" = "Ja, löschen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 28701b94f..dc2acff90 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -974,6 +974,7 @@ "profile__edit_unlink_section" = "ΑΠΟΣΥΝΔΕΣΗ"; "profile__edit_changed_error" = "Το προφίλ σου άλλαξε στο pubky.app. Το Bitkit φόρτωσε την πιο πρόσφατη έκδοση, οπότε κάνε ξανά την αλλαγή σου."; "profile__edit_public_note" = "Αυτό είναι το προφίλ σου στο pubky.app. Είναι δημόσιο, και οι αλλαγές που κάνεις εδώ εμφανίζονται και στο pubky.app."; +"profile__edit_tags_error" = "Το Bitkit δεν μπόρεσε να αποθηκεύσει τις ετικέτες σου. Οι άλλες αλλαγές σου αποθηκεύτηκαν, οπότε δοκίμασε ξανά τις ετικέτες."; "profile__delete_title" = "Διαγραφή Προφίλ;"; "profile__delete_description" = "Η διαγραφή του προφίλ σας το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα."; "profile__delete_confirm" = "Ναι, Διαγραφή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index fa19cedfd..f3e0d002a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1191,6 +1191,7 @@ "profile__edit_unlink_section" = "UNLINK"; "profile__edit_changed_error" = "Your profile changed in pubky.app. Bitkit loaded the latest version, so make your edit again."; "profile__edit_public_note" = "This is your pubky.app profile. It is public, and changes you make here also show on pubky.app."; +"profile__edit_tags_error" = "Bitkit could not save your tags. Your other changes were saved, so try the tags again."; "profile__delete_title" = "Delete Profile?"; "profile__delete_description" = "Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them."; "profile__delete_confirm" = "Yes, Delete"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 4f95defb1..8aa8efb17 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1181,6 +1181,7 @@ "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_changed_error" = "Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio."; "profile__edit_public_note" = "Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app."; +"profile__edit_tags_error" = "Bitkit no pudo guardar tus etiquetas. Tus otros cambios se guardaron, así que vuelve a intentar con las etiquetas."; "profile__delete_title" = "¿Eliminar perfil?"; "profile__delete_description" = "Al eliminar su perfil, se borra de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index cf5c4b79d..535911fa8 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1091,6 +1091,7 @@ "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_changed_error" = "Tu perfil cambió en pubky.app. Bitkit cargó la versión más reciente, así que vuelve a hacer tu cambio."; "profile__edit_public_note" = "Este es tu perfil de pubky.app. Es público, y los cambios que hagas aquí también se ven en pubky.app."; +"profile__edit_tags_error" = "Bitkit no pudo guardar tus etiquetas. Tus otros cambios se guardaron, así que vuelve a intentar con las etiquetas."; "profile__delete_title" = "¿Eliminar el perfil?"; "profile__delete_description" = "Al eliminar su perfil, se elimina de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 55fe112ce..e3ba05f52 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1189,6 +1189,7 @@ "profile__edit_unlink_section" = "DISSOCIER"; "profile__edit_changed_error" = "Ton profil a changé sur pubky.app. Bitkit a chargé la version la plus récente, refais donc ta modification."; "profile__edit_public_note" = "Voici ton profil pubky.app. Il est public, et les modifications que tu fais ici apparaissent aussi sur pubky.app."; +"profile__edit_tags_error" = "Bitkit n'a pas pu enregistrer vos étiquettes. Vos autres modifications ont été enregistrées, réessayez pour les étiquettes."; "profile__delete_title" = "Supprimer le profil ?"; "profile__delete_description" = "La suppression de votre profil le retire de Pubky. Vos contacts peuvent encore conserver les informations de profil et de paiement que vous leur avez partagées."; "profile__delete_confirm" = "Oui, supprimer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index fc7cff8c5..6b0c45ea3 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1155,6 +1155,7 @@ "profile__edit_unlink_section" = "SCOLLEGA"; "profile__edit_changed_error" = "Il tuo profilo è cambiato su pubky.app. Bitkit ha caricato la versione più recente, quindi ripeti la modifica."; "profile__edit_public_note" = "Questo è il tuo profilo pubky.app. È pubblico e le modifiche che fai qui compaiono anche su pubky.app."; +"profile__edit_tags_error" = "Bitkit non è riuscito a salvare i tuoi tag. Le altre modifiche sono state salvate, quindi riprova con i tag."; "profile__delete_title" = "Eliminare il Profilo?"; "profile__delete_description" = "Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro."; "profile__delete_confirm" = "Sì, Elimina"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 72f4e9dd4..d3bd7e297 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1184,6 +1184,7 @@ "profile__edit_unlink_section" = "ONTKOPPELEN"; "profile__edit_changed_error" = "Je profiel is gewijzigd in pubky.app. Bitkit heeft de nieuwste versie geladen, dus voer je wijziging opnieuw uit."; "profile__edit_public_note" = "Dit is je pubky.app-profiel. Het is openbaar, en wijzigingen die je hier maakt, verschijnen ook op pubky.app."; +"profile__edit_tags_error" = "Bitkit kon je tags niet opslaan. Je andere wijzigingen zijn opgeslagen, dus probeer de tags opnieuw."; "profile__delete_title" = "Profiel verwijderen?"; "profile__delete_description" = "Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren."; "profile__delete_confirm" = "Ja, verwijderen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index dddc2bc1f..9345d1391 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1188,6 +1188,7 @@ "profile__edit_unlink_section" = "ODŁĄCZANIE"; "profile__edit_changed_error" = "Twój profil zmienił się w pubky.app. Bitkit wczytał najnowszą wersję, więc wprowadź zmianę ponownie."; "profile__edit_public_note" = "To jest Twój profil pubky.app. Jest publiczny, a zmiany wprowadzone tutaj pojawią się też w pubky.app."; +"profile__edit_tags_error" = "Bitkit nie mógł zapisać Twoich tagów. Pozostałe zmiany zostały zapisane, więc spróbuj ponownie z tagami."; "profile__delete_title" = "Usunąć profil?"; "profile__delete_description" = "Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności."; "profile__delete_confirm" = "Tak, usuń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 094a8d0c6..f2b121112 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1189,6 +1189,7 @@ "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_changed_error" = "Seu perfil mudou no pubky.app. O Bitkit carregou a versão mais recente, então faça sua alteração de novo."; "profile__edit_public_note" = "Este é o seu perfil do pubky.app. Ele é público, e as alterações que você faz aqui também aparecem no pubky.app."; +"profile__edit_tags_error" = "O Bitkit não conseguiu salvar suas tags. Suas outras alterações foram salvas, então tente as tags novamente."; "profile__delete_title" = "Excluir Perfil?"; "profile__delete_description" = "Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index bac4d77cf..fa17b5418 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -949,6 +949,7 @@ "profile__edit_unlink_section" = "DESASSOCIAR"; "profile__edit_changed_error" = "O teu perfil mudou no pubky.app. A Bitkit carregou a versão mais recente, por isso faz novamente a tua alteração."; "profile__edit_public_note" = "Este é o teu perfil do pubky.app. É público, e as alterações que fazes aqui também aparecem no pubky.app."; +"profile__edit_tags_error" = "O Bitkit não conseguiu guardar as suas etiquetas. As outras alterações foram guardadas, por isso tente as etiquetas novamente."; "profile__delete_title" = "Eliminar perfil?"; "profile__delete_description" = "Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 1dca4f730..179894ab8 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1174,6 +1174,7 @@ "profile__edit_unlink_section" = "ОТВЯЗКА"; "profile__edit_changed_error" = "Ваш профиль изменился в pubky.app. Bitkit загрузил последнюю версию, поэтому внесите изменение снова."; "profile__edit_public_note" = "Это ваш профиль pubky.app. Он публичный, и изменения, которые вы вносите здесь, также видны в pubky.app."; +"profile__edit_tags_error" = "Bitkit не удалось сохранить ваши теги. Остальные изменения сохранены, поэтому попробуйте сохранить теги ещё раз."; "profile__delete_title" = "Удалить Профиль?"; "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index dd51e7061..fb18ec508 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -38,8 +38,8 @@ struct CreateProfileView: View { await loadInitialData() } .onChange(of: username) { _, value in - if value.count > PubkyAppProfileLimits.nameLength.upperBound { - username = String(value.prefix(PubkyAppProfileLimits.nameLength.upperBound)) + if let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.nameLength.upperBound) { + username = capped } } } @@ -230,11 +230,15 @@ struct CreateProfileView: View { links: existingProfile?.links ?? [], tags: existingProfile?.tags ?? [], existingImageUrl: existingProfile?.imageUrl, + baseRevision: existingProfile?.revision, avatarImage: avatarImage ) navigation.navigate(.payContacts) } catch is CancellationError { return + } catch PubkyAppProfileError.changedElsewhere { + app.toast(type: .error, title: t("profile__create_error_title"), description: PubkyAppProfileError.changedElsewhere.localizedDescription) + await loadInitialData() } catch { Logger.error("Failed to save profile: \(error)", context: "CreateProfileView") app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index aed734c66..5b871008d 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -12,6 +12,7 @@ struct EditProfileView: View { @State private var links: [ProfileLinkInput] = [] @State private var tags: [String] = [] @State private var baseRevision: String? + @State private var loadedBio = "" @State private var isSaving = false @State private var isDeleting = false @State private var showDeleteConfirmation = false @@ -33,6 +34,7 @@ struct EditProfileView: View { publicKeyLabel: t("profile__create_pubky_display_label"), bioPlaceholder: t("profile__create_bio_placeholder"), isSaving: isSaving, + maxLinks: PubkyAppProfileLimits.maxLinks, isNameValid: PubkyAppProfileLimits.isValidName, footerNote: t("profile__edit_public_note"), deleteLabel: copy.label, @@ -63,14 +65,14 @@ struct EditProfileView: View { loadProfileData() } .onChange(of: username) { _, value in - if value.count > PubkyAppProfileLimits.nameLength.upperBound { - username = String(value.prefix(PubkyAppProfileLimits.nameLength.upperBound)) + if let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.nameLength.upperBound) { + username = capped } } .onChange(of: bio) { _, value in - if value.count > PubkyAppProfileLimits.bioMaxLength { - bio = String(value.prefix(PubkyAppProfileLimits.bioMaxLength)) - } + // A longer bio stored before the limit is shown in full; only an edit is cut. + guard value != loadedBio, let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.bioMaxLength) else { return } + bio = capped } .alert(copy.title, isPresented: $showDeleteConfirmation) { Button(copy.confirm, role: .destructive) { @@ -152,6 +154,7 @@ struct EditProfileView: View { private func loadProfileData() { guard let profile = pubkyProfile.profile else { return } username = profile.name + loadedBio = profile.bio bio = profile.bio links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } tags = profile.tags @@ -253,6 +256,9 @@ struct EditProfileView: View { await pubkyProfile.loadProfile() avatarImage = nil loadProfileData() + } else if case let PubkyAppProfileError.tagsNotSaved(revision) = error { + avatarImage = nil + baseRevision = revision } } } diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index e4165530b..fca71b41f 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -295,7 +295,10 @@ struct ProfileView: View { private func refreshProfile() async { isRefreshing = true defer { isRefreshing = false } - guard pubkyProfile.profile == nil else { return } + guard pubkyProfile.profile == nil else { + await pubkyProfile.loadProfile() + return + } async let restoration: Void = pubkyProfile.restoreSessionIfNeeded() await pubkyProfile.loadProfile() await restoration diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index 4200fc305..fd44e51b2 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -59,4 +59,27 @@ final class PubkyAppProfileStoreTests: XCTestCase { XCTAssertEqual(PubkyAppProfileStore.linkUrl("https://x.com/alice"), "https://x.com/alice") XCTAssertNil(PubkyAppProfileStore.linkUrl(" ")) } + + func testFreeTextIsNotMadeALink() { + XCTAssertNil(PubkyAppProfileStore.linkUrl("my site")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("12345")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("a@b")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("2024-01-01")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("New York")) + XCTAssertEqual(PubkyAppProfileStore.linkUrl("medium.com/@alice"), "https://medium.com/@alice") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("example.com/a@b"), "https://example.com/a@b") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("0123 456 789"), "tel:0123456789") + } + + func testOnlyControlCharactersAreFlattened() { + XCTAssertEqual(PubkyAppProfileStore.singleLine("Builder\nMaker"), "Builder Maker") + XCTAssertEqual(PubkyAppProfileStore.singleLine("👩\u{200D}💻 می\u{200C}خواهم"), "👩\u{200D}💻 می\u{200C}خواهم") + } + + func testCapsCountUnicodeScalars() { + XCTAssertNil(PubkyAppProfileLimits.capped("abc", to: 3)) + XCTAssertEqual(PubkyAppProfileLimits.capped("👩\u{200D}💻x", to: 3), "👩\u{200D}💻") + XCTAssertFalse(PubkyAppProfileLimits.isValidName("é\u{301}")) + XCTAssertTrue(PubkyAppProfileLimits.isValidName("e\u{301}e")) + } } From b516b7ec7e71dd3fcc0ff3202e339ed5b15f76f2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 6 Nov 2026 23:33:51 +0100 Subject: [PATCH 10/25] fix: fit restored profiles and keep tags and avatars across failed copy reads --- Bitkit/Managers/PubkyAppProfileStore.swift | 73 ++++++++++++++------ Bitkit/Managers/PubkyProfileManager.swift | 28 ++++---- Bitkit/Views/Profile/CreateProfileView.swift | 3 + BitkitTests/PubkyAppProfileStoreTests.swift | 19 +++++ 4 files changed, 88 insertions(+), 35 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 0f998ad8e..40b5e1c73 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -119,6 +119,7 @@ actor PubkyAppProfileStore { func save(_ data: PubkyProfileData, expectedIdentity: String?) async throws { let data = Self.normalized(data) let key = try await Self.identity(expectedIdentity) + let copyUnread = tagsUnavailable.contains(key) var base: PubkyProfile? if let baseRevision = data.baseRevision { guard let known = profilesByRevision[key]?[baseRevision] else { @@ -130,7 +131,7 @@ actor PubkyAppProfileStore { _ = try? await load(publicKey: key) throw PubkyAppProfileError.changedElsewhere } - var update = Self.update(from: data, base: base) + var update = Self.update(from: base == nil ? Self.fitted(data) : data, base: base) // A Bitkit avatar still on Paykit storage is moved by a later load rather than written to pubky.app as is. let paykitAvatar = data.image.flatMap { Self.isPaykitStorage($0) ? $0 : nil } if let image = update.image, Self.isPaykitStorage(image) { @@ -146,15 +147,19 @@ actor PubkyAppProfileStore { throw PubkyAppProfileError.changedElsewhere } Self.pubkyAppProfileOwners.insert(key) + let previousTags = base?.tags ?? [] + let stored = await storedCopy(for: key) if let paykitAvatar, snapshot.profile.image == nil { Self.pubkyAppAvatarPending.insert(key) pendingAvatarUris[key] = paykitAvatar - } else { + } else if snapshot.profile.image != nil || !(copyUnread && Self.pubkyAppAvatarPending.contains(key)) { Self.pubkyAppAvatarPending.remove(key) pendingAvatarUris[key] = nil + } else if let uri = stored?.image, Self.isPaykitStorage(uri) { + // The form could not show the avatar still to move, as the copy was unread; it stays pending. + pendingAvatarUris[key] = uri } - let previousTags = base?.tags ?? [] - let tags = await tagsToKeep(data.tags, for: key) + let tags = stored.map { Self.tags(data.tags, keeping: $0.tags) } var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: tags ?? previousTags) if Self.pubkyAppAvatarPending.contains(key), let uri = pendingAvatarUris[key] { profile = profile.withImageUrl(uri) @@ -165,6 +170,7 @@ actor PubkyAppProfileStore { if tags != nil { do { try await publishCopy(profile, expectedIdentity: key) + tagsUnavailable.remove(key) } catch { copyError = error } @@ -197,18 +203,21 @@ actor PubkyAppProfileStore { revisionOrder[key] = order } - /// When the last load could not read the Paykit copy, the form showed no tags, so the stored ones are kept; nil while - /// they still cannot be read. - private func tagsToKeep(_ tags: [String], for key: String) async -> [String]? { - guard tagsUnavailable.contains(key) else { return tags } - let stored: Paykit.PaykitProfileRecord? + /// What a save keeps from the Paykit copy after the last load could not read it, as the form then lacked its tags + /// and the avatar still to move: empty when that load read it, nil while it still cannot be read. + private func storedCopy(for key: String) async -> (tags: [String]?, image: String?)? { + guard tagsUnavailable.contains(key) else { return (nil, nil) } do { - stored = try await PubkyService.fetchPaykitProfile(publicKey: key) + let data = try await PubkyService.fetchPaykitProfile(publicKey: key).map { PubkyProfileData.from(paykitProfile: $0.profile) } + return (data?.tags ?? [], data?.image) } catch { return nil } - tagsUnavailable.remove(key) - let storedTags = stored.map { PubkyProfileData.from(paykitProfile: $0.profile).tags } ?? [] + } + + /// The form's tags, plus the stored ones it could not show after a failed copy read. + private static func tags(_ tags: [String], keeping storedTags: [String]?) -> [String] { + guard let storedTags else { return tags } return storedTags + tags.filter { !storedTags.contains($0) } } @@ -220,19 +229,33 @@ actor PubkyAppProfileStore { } } + /// A Bitkit profile fitted to pubky.app's limits, for a profile written there without a base: text on one line and + /// cut to length, and only links pubky.app can hold, at most five. + static func fitted(_ data: PubkyProfileData) -> PubkyProfileData { + var data = data + data.name = prefix(singleLine(data.name), nameMaxLength) + data.bio = prefix(singleLine(data.bio), bioMaxLength) + data.links = Array( + data.links + .compactMap { link -> PubkyProfileData.Link? in + let title = singleLine(link.label) + guard !title.isEmpty, let url = linkUrl(link.url) else { return nil } + return PubkyProfileData.Link(label: prefix(title, linkTitleMaxLength), url: url) + } + .prefix(linksMaxCount) + ) + return data + } + private func migrate(_ data: PubkyProfileData, publicKey: String) async -> Paykit.PubkyProfileSnapshot? { - let links = data.links - .compactMap { link -> Paykit.PubkyProfileLink? in - let title = Self.singleLine(link.label) - guard !title.isEmpty, let url = Self.linkUrl(link.url) else { return nil } - return Paykit.PubkyProfileLink(title: Self.prefix(title, Self.linkTitleMaxLength), url: url) - } - .prefix(Self.linksMaxCount) + let fitted = Self.fitted(data) + // An avatar already on pubky.app is kept as is; one on Paykit storage is moved below. + let keptImage = data.image.flatMap { Self.isPubkyAppFile($0) ? $0 : nil } let update = Paykit.PubkyProfileUpdate( - name: Self.prefix(Self.singleLine(data.name), Self.nameMaxLength), - bio: Self.prefix(Self.singleLine(data.bio), Self.bioMaxLength), - image: nil, - links: Array(links) + name: fitted.name, + bio: fitted.bio, + image: keptImage, + links: fitted.links.map { Paykit.PubkyProfileLink(title: $0.label, url: $0.url) } ) let snapshot: Paykit.PubkyProfileSnapshot do { @@ -397,6 +420,10 @@ actor PubkyAppProfileStore { uri.contains("/pub/paykit/") } + private static func isPubkyAppFile(_ uri: String) -> Bool { + uri.contains("/pub/pubky.app/files/") && uri.unicodeScalars.count <= linkUrlMaxLength + } + private static func identity(_ expectedIdentity: String?) async throws -> String { if let expectedIdentity { return normalized(expectedIdentity) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 5d9ecc3db..d4031d4dd 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -763,7 +763,8 @@ class PubkyProfileManager: ObservableObject { } let imageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - let savedProfile = try await writeProfile( + // A profile whose only miss is its tags is created all the same; its tags can be added from Profile. + let (savedProfile, tagsNotSavedAt) = try await writeProfile( name: name, bio: bio, imageUrl: imageUrl, @@ -772,6 +773,9 @@ class PubkyProfileManager: ObservableObject { baseRevision: baseRevision, expectedIdentity: publicKey ) + if tagsNotSavedAt != nil { + Logger.warn("Created the profile without its tags", context: "PubkyProfileManager") + } Self.notifyAppStateBackupChanged() let createdProfile = savedProfile ?? PubkyProfile( @@ -914,7 +918,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, avatarImage: avatarImage, - baseRevision: baseRevision ?? profile?.revision, + baseRevision: baseRevision, session: session ) } @@ -1010,8 +1014,9 @@ class PubkyProfileManager: ObservableObject { let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) let savedProfile: PubkyProfile? + let tagsNotSavedAt: String? do { - savedProfile = try await writeProfile( + (savedProfile, tagsNotSavedAt) = try await writeProfile( name: name, bio: bio, imageUrl: resolvedImageUrl, @@ -1043,6 +1048,10 @@ class PubkyProfileManager: ObservableObject { ) commitProfile(updatedProfile) setProfileSetupPending(false) + // The other fields are on pubky.app, so the screen shows them and a retry starts from their revision. + if let tagsNotSavedAt { + throw PubkyAppProfileError.tagsNotSaved(revision: tagsNotSavedAt) + } return true } @@ -1117,7 +1126,7 @@ class PubkyProfileManager: ObservableObject { tags: [String] = [], baseRevision: String? = nil, expectedIdentity: String? = nil - ) async throws -> PubkyProfile? { + ) async throws -> (profile: PubkyProfile?, tagsNotSavedAt: String?) { var profileData = PubkyProfileData( name: name, bio: bio, @@ -1135,15 +1144,10 @@ class PubkyProfileManager: ObservableObject { try await publish(profileData, expectedIdentity) }.value } catch let PubkyAppProfileError.tagsNotSaved(revision) { - // The other fields are on pubky.app, so the screen shows them and a retry starts from their revision. - if let saved = await PubkyAppProfileStore.shared.savedProfile(saveId: saveId), - PubkyPublicKeyFormat.matches(saved.publicKey, currentSession?.publicKey) - { - commitProfile(saved) - } - throw PubkyAppProfileError.tagsNotSaved(revision: revision) + // The other fields are on pubky.app; the caller decides what that means for its flow. + return await (PubkyAppProfileStore.shared.savedProfile(saveId: saveId), revision) } - return await PubkyAppProfileStore.shared.savedProfile(saveId: saveId) + return await (PubkyAppProfileStore.shared.savedProfile(saveId: saveId), nil) } private func discardAbandonedSession() async { diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index fb18ec508..63ec3a939 100644 --- a/Bitkit/Views/Profile/CreateProfileView.swift +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -174,6 +174,9 @@ struct CreateProfileView: View { username = remote.name existingProfile = remote isRestoring = true + } else { + existingProfile = nil + isRestoring = false } } catch is CancellationError { return diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index fd44e51b2..ab643cb79 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -82,4 +82,23 @@ final class PubkyAppProfileStoreTests: XCTestCase { XCTAssertFalse(PubkyAppProfileLimits.isValidName("é\u{301}")) XCTAssertTrue(PubkyAppProfileLimits.isValidName("e\u{301}e")) } + + func testProfileWithoutBaseIsFittedToPubkyAppLimits() { + let links = (1 ... 6).map { PubkyProfileData.Link(label: "Site \($0)", url: "site\($0).com") } + + [.init(label: "City", url: "New York")] + let fitted = PubkyAppProfileStore.fitted( + PubkyProfileData( + name: "Alice", + bio: String(repeating: "b", count: 200), + image: nil, + links: [.init(label: "City", url: "New York")] + links, + tags: [] + ) + ) + + XCTAssertEqual(fitted.bio.unicodeScalars.count, 160) + XCTAssertEqual(fitted.links.count, 5) + XCTAssertEqual(fitted.links.first?.url, "https://site1.com") + XCTAssertFalse(fitted.links.contains { $0.label == "City" }) + } } From d792e85b8f68a6d43ff3b63296a61e5678362c14 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:14:44 +0100 Subject: [PATCH 11/25] fix: reject numeric link hosts paykit refuses --- Bitkit/Managers/PubkyAppProfileStore.swift | 14 +++++++++++--- BitkitTests/PubkyAppProfileStoreTests.swift | 4 ++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 40b5e1c73..dabf8dfb2 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -393,16 +393,23 @@ actor PubkyAppProfileStore { } else if isEmailAddress(value) { url = "mailto:\(value)" } else if isPhoneNumber(value) { - url = "tel:\(value.filter { !" -()".contains($0) })" + url = "tel:\(value.filter { !" -().".contains($0) })" } else if value.contains("."), !value.hasPrefix(".") { url = "https://\(value)" } else { return nil } - guard url.unicodeScalars.count <= linkUrlMaxLength, URL(string: url) != nil else { return nil } + guard url.unicodeScalars.count <= linkUrlMaxLength, let parsed = URL(string: url), hasValidHost(parsed) else { return nil } return url } + /// Paykit rejects a host whose last label is all digits unless the host is an IPv4 address, as URL parsers do. + private static func hasValidHost(_ url: URL) -> Bool { + guard let host = url.host, let last = host.split(separator: ".").last, last.allSatisfy(\.isNumber) else { return true } + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + return octets.count == 4 && octets.allSatisfy { !$0.isEmpty && $0.allSatisfy(\.isNumber) && (Int($0) ?? 256) <= 255 } + } + private static func isEmailAddress(_ value: String) -> Bool { let parts = value.split(separator: "@", omittingEmptySubsequences: false) return parts.count == 2 && !parts[0].isEmpty && parts[1].contains(".") && !value.contains("/") @@ -411,8 +418,9 @@ actor PubkyAppProfileStore { private static func isPhoneNumber(_ value: String) -> Bool { let digits = value.filter(\.isNumber).count return value.hasPrefix("+") || digits >= 7 - ? value.allSatisfy { "+0123456789 -()".contains($0) } && digits >= 5 && !value.dropFirst().contains("+") + ? value.allSatisfy { "+0123456789 -().".contains($0) } && digits >= 5 && !value.dropFirst().contains("+") && value.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) == nil + && value.range(of: #"^\d{1,3}(\.\d{1,3}){3}$"#, options: .regularExpression) == nil : false } diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index ab643cb79..a346bb9f8 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -65,6 +65,10 @@ final class PubkyAppProfileStoreTests: XCTestCase { XCTAssertNil(PubkyAppProfileStore.linkUrl("12345")) XCTAssertNil(PubkyAppProfileStore.linkUrl("a@b")) XCTAssertNil(PubkyAppProfileStore.linkUrl("2024-01-01")) + XCTAssertEqual(PubkyAppProfileStore.linkUrl("555.123.4567"), "tel:5551234567") + XCTAssertNil(PubkyAppProfileStore.linkUrl("v1.2.3")) + XCTAssertNil(PubkyAppProfileStore.linkUrl("foo.123")) + XCTAssertEqual(PubkyAppProfileStore.linkUrl("192.168.1.10"), "https://192.168.1.10") XCTAssertNil(PubkyAppProfileStore.linkUrl("New York")) XCTAssertEqual(PubkyAppProfileStore.linkUrl("medium.com/@alice"), "https://medium.com/@alice") XCTAssertEqual(PubkyAppProfileStore.linkUrl("example.com/a@b"), "https://example.com/a@b") From 5c476dc2a1300940ab64f9da4c8038e24a4b596a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 7 Nov 2026 00:46:13 +0100 Subject: [PATCH 12/25] fix: check numeric link hosts only for web urls --- Bitkit/Managers/PubkyAppProfileStore.swift | 14 ++++++++++---- BitkitTests/PubkyAppProfileStoreTests.swift | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index dabf8dfb2..83e8fc7c2 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -403,11 +403,17 @@ actor PubkyAppProfileStore { return url } - /// Paykit rejects a host whose last label is all digits unless the host is an IPv4 address, as URL parsers do. + /// Paykit's URL parser reads a web URL's host that ends in an all-digit label as an IPv4 address (one to four + /// numbers, as in `127.1`) and rejects it when it is not one; other schemes keep such hosts as they are. private static func hasValidHost(_ url: URL) -> Bool { - guard let host = url.host, let last = host.split(separator: ".").last, last.allSatisfy(\.isNumber) else { return true } - let octets = host.split(separator: ".", omittingEmptySubsequences: false) - return octets.count == 4 && octets.allSatisfy { !$0.isEmpty && $0.allSatisfy(\.isNumber) && (Int($0) ?? 256) <= 255 } + guard ["http", "https", "ws", "wss", "ftp"].contains(url.scheme?.lowercased() ?? ""), + let host = url.host, let last = host.split(separator: ".").last, last.allSatisfy(\.isNumber) + else { return true } + let parts = host.split(separator: ".", omittingEmptySubsequences: false).map { UInt64($0) } + guard (1 ... 4).contains(parts.count), parts.allSatisfy({ $0 != nil }) else { return false } + let numbers = parts.compactMap { $0 } + let lastLimit = (UInt64(1) << (8 * UInt64(5 - numbers.count))) - 1 + return numbers.dropLast().allSatisfy { $0 <= 255 } && numbers[numbers.count - 1] <= lastLimit } private static func isEmailAddress(_ value: String) -> Bool { diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index a346bb9f8..2fa933031 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -68,6 +68,8 @@ final class PubkyAppProfileStoreTests: XCTestCase { XCTAssertEqual(PubkyAppProfileStore.linkUrl("555.123.4567"), "tel:5551234567") XCTAssertNil(PubkyAppProfileStore.linkUrl("v1.2.3")) XCTAssertNil(PubkyAppProfileStore.linkUrl("foo.123")) + XCTAssertEqual(PubkyAppProfileStore.linkUrl("tel://5551234567"), "tel://5551234567") + XCTAssertEqual(PubkyAppProfileStore.linkUrl("https://127.1"), "https://127.1") XCTAssertEqual(PubkyAppProfileStore.linkUrl("192.168.1.10"), "https://192.168.1.10") XCTAssertNil(PubkyAppProfileStore.linkUrl("New York")) XCTAssertEqual(PubkyAppProfileStore.linkUrl("medium.com/@alice"), "https://medium.com/@alice") From bf595960bd491e7d37dd5f355d5e214c8035fb36 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 16:38:46 +0100 Subject: [PATCH 13/25] chore: resolve paykit 0.1.1-rc7 --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d0490865b..cec68543b 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "01de5dd232d2b22f132d066697cce041700e7136", - "version" : "0.1.1-rc2" + "revision" : "8f1cd4e9fea8b99043f2437757dd27b0f96efb9e", + "version" : "0.1.1-rc7" } }, { From afb5a634a0c57c323de19492b1df3e2ded287a04 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 17:04:23 +0100 Subject: [PATCH 14/25] fix: keep a deleted profile from being written back --- Bitkit/Managers/PubkyAppProfileStore.swift | 128 ++++++++++++++++---- Bitkit/Managers/PubkyProfileManager.swift | 8 +- BitkitTests/PubkyAppProfileStoreTests.swift | 52 ++++++++ 3 files changed, 159 insertions(+), 29 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 83e8fc7c2..1013a02e0 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -15,6 +15,57 @@ enum PubkyAppProfileError: LocalizedError { } } +/// Orders profile writes with profile deletion per identity. A deletion refuses every write until it ends and every +/// later write of a load or save that started before it, and waits for the writes already under way. +struct ProfileWriteGate { + private var generations: [String: Int] = [:] + private var deleting: Set = [] + private var writesInFlight: [String: Int] = [:] + private var writesSettled: [String: [CheckedContinuation]] = [:] + + /// The generation a load or save starts at, which its writes pass to `admit`. + func generation(_ key: String) -> Int { + generations[key, default: 0] + } + + /// Admits a write; pair each admitted write with `end`. + mutating func admit(_ key: String, generation: Int) throws { + guard !deleting.contains(key), generations[key, default: 0] == generation else { + throw PubkyServiceError.identityChanged + } + writesInFlight[key, default: 0] += 1 + } + + mutating func end(_ key: String) { + let remaining = writesInFlight[key, default: 1] - 1 + guard remaining == 0 else { + writesInFlight[key] = remaining + return + } + writesInFlight[key] = nil + writesSettled.removeValue(forKey: key)?.forEach { $0.resume() } + } + + /// Starts a deletion, which then waits until `hasWritesInFlight` is false (`onWritesSettled`) and ends with + /// `endDeletion`. + mutating func beginDeletion(_ key: String) { + generations[key, default: 0] += 1 + deleting.insert(key) + } + + mutating func endDeletion(_ key: String) { + deleting.remove(key) + } + + func hasWritesInFlight(_ key: String) -> Bool { + writesInFlight[key, default: 0] > 0 + } + + mutating func onWritesSettled(_ key: String, _ continuation: CheckedContinuation) { + writesSettled[key, default: []].append(continuation) + } +} + /// The identity's own pubky.app profile, which Bitkit reads and edits directly. A save writes only the fields that differ /// from the loaded profile, at the revision it was loaded at, so an edit made in pubky.app is never overwritten. Profile /// tags have no pubky.app field: they live in Bitkit's public Paykit profile copy, which follows each save for other @@ -51,6 +102,7 @@ actor PubkyAppProfileStore { private var savedProfiles: [UUID: PubkyProfile] = [:] private var pendingAvatarUris: [String: String] = [:] private var tagsUnavailable: Set = [] + private var writeGate = ProfileWriteGate() private static let rememberedRevisions = 8 /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first; until @@ -58,6 +110,7 @@ actor PubkyAppProfileStore { /// pubky.app profile. func load(publicKey: String) async throws -> PubkyProfile { let key = Self.normalized(publicKey) + let generation = writeGate.generation(key) let paykitData: PubkyProfileData? var paykitReadError: Error? do { @@ -77,10 +130,10 @@ actor PubkyAppProfileStore { // Once a pubky.app profile was seen, one missing there was deleted there, so it is not moved back from Bitkit. let hadPubkyAppProfile = Self.pubkyAppProfileOwners.contains(key) if fetched == nil, isSignedIn, !hadPubkyAppProfile, let paykitData { - snapshot = await migrate(paykitData, publicKey: key) + snapshot = await migrate(paykitData, publicKey: key, generation: generation) } else if let current = fetched, isSignedIn, Self.pubkyAppAvatarPending.contains(key), let uri = paykitData?.image { if current.profile.image == nil, Self.isPaykitStorage(uri) { - snapshot = await moveAvatar(uri, onto: current, publicKey: key) ?? current + snapshot = await moveAvatar(uri, onto: current, publicKey: key, generation: generation) ?? current } else { // An avatar set on pubky.app meanwhile wins over the one still to move, and only a Bitkit avatar on Paykit // storage is moved. @@ -107,7 +160,7 @@ actor PubkyAppProfileStore { } remember(profile, for: key) if isSignedIn, !avatarPending, let paykitData, paykitData != PubkyProfileData.from(profile: profile) { - try? await publishCopy(profile, expectedIdentity: key) + try? await publishCopy(profile, expectedIdentity: key, generation: generation) } return profile } @@ -119,6 +172,7 @@ actor PubkyAppProfileStore { func save(_ data: PubkyProfileData, expectedIdentity: String?) async throws { let data = Self.normalized(data) let key = try await Self.identity(expectedIdentity) + let generation = writeGate.generation(key) let copyUnread = tagsUnavailable.contains(key) var base: PubkyProfile? if let baseRevision = data.baseRevision { @@ -139,8 +193,10 @@ actor PubkyAppProfileStore { } let snapshot: Paykit.PubkyProfileSnapshot do { - snapshot = try await withPubkyAppScope { - try await PubkyService.publishPubkyProfile(update, expectedRevision: data.baseRevision, expectedIdentity: key) + snapshot = try await admitted(key, generation: generation) { + try await withPubkyAppScope { + try await PubkyService.publishPubkyProfile(update, expectedRevision: data.baseRevision, expectedIdentity: key) + } } } catch PaykitError.ConcurrentUpdate { _ = try? await load(publicKey: key) @@ -169,7 +225,7 @@ actor PubkyAppProfileStore { var copyError: Error? if tags != nil { do { - try await publishCopy(profile, expectedIdentity: key) + try await publishCopy(profile, expectedIdentity: key, generation: generation) tagsUnavailable.remove(key) } catch { copyError = error @@ -247,7 +303,7 @@ actor PubkyAppProfileStore { return data } - private func migrate(_ data: PubkyProfileData, publicKey: String) async -> Paykit.PubkyProfileSnapshot? { + private func migrate(_ data: PubkyProfileData, publicKey: String, generation: Int) async -> Paykit.PubkyProfileSnapshot? { let fitted = Self.fitted(data) // An avatar already on pubky.app is kept as is; one on Paykit storage is moved below. let keptImage = data.image.flatMap { Self.isPubkyAppFile($0) ? $0 : nil } @@ -259,8 +315,10 @@ actor PubkyAppProfileStore { ) let snapshot: Paykit.PubkyProfileSnapshot do { - snapshot = try await withPubkyAppScope { - try await PubkyService.publishPubkyProfile(update, expectedRevision: nil, expectedIdentity: publicKey) + snapshot = try await admitted(publicKey, generation: generation) { + try await withPubkyAppScope { + try await PubkyService.publishPubkyProfile(update, expectedRevision: nil, expectedIdentity: publicKey) + } } Logger.info("Moved Bitkit profile to pubky.app", context: "PubkyAppProfileStore") } catch { @@ -268,16 +326,17 @@ actor PubkyAppProfileStore { return nil } guard let uri = data.image, Self.isPaykitStorage(uri) else { return snapshot } - return await moveAvatar(uri, onto: snapshot, publicKey: publicKey) ?? snapshot + return await moveAvatar(uri, onto: snapshot, publicKey: publicKey, generation: generation) ?? snapshot } private func moveAvatar( _ uri: String, onto snapshot: Paykit.PubkyProfileSnapshot, - publicKey: String + publicKey: String, + generation: Int ) async -> Paykit.PubkyProfileSnapshot? { do { - let moved = try await withPubkyAppScope { + let moved = try await admitted(publicKey, generation: generation) { try await withPubkyAppScope { let bytes = try await PubkyService.fetchFile(uri: uri, maxBytes: Self.avatarMaxBytes) let image = try await PubkyService.uploadPubkyAppAvatar( bytes: bytes, @@ -289,7 +348,7 @@ actor PubkyAppProfileStore { expectedRevision: snapshot.revision, expectedIdentity: publicKey ) - } + } } Self.pubkyAppAvatarPending.remove(publicKey) return moved } catch { @@ -299,23 +358,42 @@ actor PubkyAppProfileStore { } } - /// Deletes the identity's pubky.app profile, for a wallet-owned identity deleting its profile. - func deleteProfile(expectedIdentity: String) async throws { + /// Deletes the identity's Bitkit profile copy and, unless `keepPubkyAppProfile`, its pubky.app profile. Writes already + /// under way finish first, and no load or save writes while it runs or, when it started before, after it, so + /// neither profile is written back once deleted. + func deleteProfile(expectedIdentity: String, keepPubkyAppProfile: Bool) async throws { let key = Self.normalized(expectedIdentity) - guard let revision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision else { return } - try await withPubkyAppScope { - try await PubkyService.deletePubkyProfile(expectedRevision: revision, expectedIdentity: key) + writeGate.beginDeletion(key) + defer { writeGate.endDeletion(key) } + if writeGate.hasWritesInFlight(key) { + await withCheckedContinuation { writeGate.onWritesSettled(key, $0) } } - profilesByRevision[key] = nil - revisionOrder[key] = nil + if !keepPubkyAppProfile, let revision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision { + try await withPubkyAppScope { + try await PubkyService.deletePubkyProfile(expectedRevision: revision, expectedIdentity: key) + } + profilesByRevision[key] = nil + revisionOrder[key] = nil + } + try await PubkyService.deletePaykitProfile(expectedIdentity: key) + } + + /// Runs a write of a load or save that started at `generation`, refused during a deletion or after one that began + /// since. + private func admitted(_ key: String, generation: Int, _ write: () async throws -> T) async throws -> T { + try writeGate.admit(key, generation: generation) + defer { writeGate.end(key) } + return try await write() } - private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String) async throws { + private func publishCopy(_ profile: PubkyProfile, expectedIdentity: String, generation: Int) async throws { do { - try await PubkyService.publishPaykitProfile( - PubkyProfileData.from(profile: profile).toPaykitProfile(), - expectedIdentity: expectedIdentity - ) + try await admitted(expectedIdentity, generation: generation) { + try await PubkyService.publishPaykitProfile( + PubkyProfileData.from(profile: profile).toPaykitProfile(), + expectedIdentity: expectedIdentity + ) + } } catch { Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") throw error diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index d4031d4dd..c9f53fe1e 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -169,10 +169,10 @@ class PubkyProfileManager: ObservableObject { }, profileDeleter: @escaping ProfileDeleter = { // A Ring identity's profile belongs to Ring, so unlinking it leaves its pubky.app profile in place. - if !PubkyProfileManager.isRingAdopted(publicKey: $0) { - try await PubkyAppProfileStore.shared.deleteProfile(expectedIdentity: $0) - } - try await PubkyService.deletePaykitProfile(expectedIdentity: $0) + try await PubkyAppProfileStore.shared.deleteProfile( + expectedIdentity: $0, + keepPubkyAppProfile: PubkyProfileManager.isRingAdopted(publicKey: $0) + ) } ) { self.remoteProfileResolver = remoteProfileResolver diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index 2fa933031..f90a3fdfb 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -108,3 +108,55 @@ final class PubkyAppProfileStoreTests: XCTestCase { XCTAssertFalse(fitted.links.contains { $0.label == "City" }) } } + +final class ProfileWriteGateTests: XCTestCase { + func testWriteFromBeforeADeletionIsRefused() { + var gate = ProfileWriteGate() + let generation = gate.generation("a") + + gate.beginDeletion("a") + gate.endDeletion("a") + + XCTAssertThrowsError(try gate.admit("a", generation: generation)) + XCTAssertNoThrow(try gate.admit("a", generation: gate.generation("a"))) + } + + func testNoWriteIsAdmittedDuringADeletion() { + var gate = ProfileWriteGate() + + gate.beginDeletion("a") + + XCTAssertThrowsError(try gate.admit("a", generation: gate.generation("a"))) + XCTAssertNoThrow(try gate.admit("b", generation: gate.generation("b"))) + } + + func testDeletionWaitsForWritesUnderWay() async throws { + actor Box { + var gate = ProfileWriteGate() + var settled = false + func admit() throws { try gate.admit("a", generation: gate.generation("a")) } + func end() { gate.end("a") } + func delete() async { + gate.beginDeletion("a") + if gate.hasWritesInFlight("a") { + await withCheckedContinuation { gate.onWritesSettled("a", $0) } + } + settled = true + } + } + let box = Box() + try await box.admit() + try await box.admit() + let deletion = Task { await box.delete() } + + await box.end() + try await Task.sleep(nanoseconds: 50_000_000) + let settledEarly = await box.settled + XCTAssertFalse(settledEarly) + + await box.end() + await deletion.value + let settled = await box.settled + XCTAssertTrue(settled) + } +} From 849b5fa9b5ca9838170de0945a26c7828e7f6a0b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 17:32:41 +0100 Subject: [PATCH 15/25] style: format the profile write gate tests --- BitkitTests/PubkyAppProfileStoreTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index f90a3fdfb..59d8155a4 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -134,8 +134,14 @@ final class ProfileWriteGateTests: XCTestCase { actor Box { var gate = ProfileWriteGate() var settled = false - func admit() throws { try gate.admit("a", generation: gate.generation("a")) } - func end() { gate.end("a") } + func admit() throws { + try gate.admit("a", generation: gate.generation("a")) + } + + func end() { + gate.end("a") + } + func delete() async { gate.beginDeletion("a") if gate.hasWritesInFlight("a") { From c81b0452e968d254c325aa1865a1bdbcf7da533a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 17:56:57 +0100 Subject: [PATCH 16/25] fix: refuse profile writes begun during a deletion --- Bitkit/Managers/PubkyAppProfileStore.swift | 2 ++ BitkitTests/PubkyAppProfileStoreTests.swift | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 1013a02e0..a88b23495 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -53,7 +53,9 @@ struct ProfileWriteGate { deleting.insert(key) } + /// Ends a deletion, moving the generation on again so a load or save that started during it writes nothing either. mutating func endDeletion(_ key: String) { + generations[key, default: 0] += 1 deleting.remove(key) } diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index 59d8155a4..5e2c52d5b 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -125,8 +125,11 @@ final class ProfileWriteGateTests: XCTestCase { var gate = ProfileWriteGate() gate.beginDeletion("a") + let startedDuring = gate.generation("a") - XCTAssertThrowsError(try gate.admit("a", generation: gate.generation("a"))) + XCTAssertThrowsError(try gate.admit("a", generation: startedDuring)) + gate.endDeletion("a") + XCTAssertThrowsError(try gate.admit("a", generation: startedDuring)) XCTAssertNoThrow(try gate.admit("b", generation: gate.generation("b"))) } From 63d31a1ddc8e2b3bfd03af6acb149eb525bc7a8a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 22:44:33 +0100 Subject: [PATCH 17/25] fix: keep a load under way from writing back replaced tags --- Bitkit/Managers/PubkyAppProfileStore.swift | 40 +++++++++++++++++++-- BitkitTests/PubkyAppProfileStoreTests.swift | 23 ++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index a88b23495..60f1bb26d 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -16,9 +16,11 @@ enum PubkyAppProfileError: LocalizedError { } /// Orders profile writes with profile deletion per identity. A deletion refuses every write until it ends and every -/// later write of a load or save that started before it, and waits for the writes already under way. +/// later write of a load or save that started before it, and waits for the writes already under way. A load refreshes +/// the Paykit copy only when no save started since it read the copy, so it never writes back tags a save replaced. struct ProfileWriteGate { private var generations: [String: Int] = [:] + private var copyEpochs: [String: Int] = [:] private var deleting: Set = [] private var writesInFlight: [String: Int] = [:] private var writesSettled: [String: [CheckedContinuation]] = [:] @@ -59,6 +61,24 @@ struct ProfileWriteGate { deleting.remove(key) } + /// The copy epoch a load reads the Paykit copy at; odd while a save runs. + func copyEpoch(_ key: String) -> Int { + copyEpochs[key, default: 0] + } + + mutating func beginSave(_ key: String) { + copyEpochs[key, default: 0] += 1 + } + + mutating func endSave(_ key: String) { + copyEpochs[key, default: 0] += 1 + } + + /// Whether a load that read the Paykit copy at `epoch` may write it back: no save ran since or was running then. + func mayRefreshCopy(_ key: String, epoch: Int) -> Bool { + epoch.isMultiple(of: 2) && copyEpochs[key, default: 0] == epoch + } + func hasWritesInFlight(_ key: String) -> Bool { writesInFlight[key, default: 0] > 0 } @@ -105,6 +125,7 @@ actor PubkyAppProfileStore { private var pendingAvatarUris: [String: String] = [:] private var tagsUnavailable: Set = [] private var writeGate = ProfileWriteGate() + private var copyRefreshes: [String: [UUID: Task]] = [:] private static let rememberedRevisions = 8 /// Loads the profile. An identity that is signed in and has only a Bitkit profile moves it to pubky.app first; until @@ -113,6 +134,7 @@ actor PubkyAppProfileStore { func load(publicKey: String) async throws -> PubkyProfile { let key = Self.normalized(publicKey) let generation = writeGate.generation(key) + let copyEpoch = writeGate.copyEpoch(key) let paykitData: PubkyProfileData? var paykitReadError: Error? do { @@ -161,8 +183,14 @@ actor PubkyAppProfileStore { profile = profile.withImageUrl(uri) } remember(profile, for: key) - if isSignedIn, !avatarPending, let paykitData, paykitData != PubkyProfileData.from(profile: profile) { - try? await publishCopy(profile, expectedIdentity: key, generation: generation) + if isSignedIn, !avatarPending, let paykitData, paykitData != PubkyProfileData.from(profile: profile), + writeGate.mayRefreshCopy(key, epoch: copyEpoch) + { + let id = UUID() + let refresh = Task { _ = try? await publishCopy(profile, expectedIdentity: key, generation: generation) } + copyRefreshes[key, default: [:]][id] = refresh + await refresh.value + copyRefreshes[key]?[id] = nil } return profile } @@ -175,6 +203,8 @@ actor PubkyAppProfileStore { let data = Self.normalized(data) let key = try await Self.identity(expectedIdentity) let generation = writeGate.generation(key) + writeGate.beginSave(key) + defer { writeGate.endSave(key) } let copyUnread = tagsUnavailable.contains(key) var base: PubkyProfile? if let baseRevision = data.baseRevision { @@ -226,6 +256,10 @@ actor PubkyAppProfileStore { // the tags it last stored so a retry is reported again. var copyError: Error? if tags != nil { + // A copy refresh begun before this save started would otherwise land after it with the old tags. + for refresh in copyRefreshes[key, default: [:]].values { + await refresh.value + } do { try await publishCopy(profile, expectedIdentity: key, generation: generation) tagsUnavailable.remove(key) diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index 5e2c52d5b..bbeb14df0 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -133,6 +133,29 @@ final class ProfileWriteGateTests: XCTestCase { XCTAssertNoThrow(try gate.admit("b", generation: gate.generation("b"))) } + func testCopyRefreshFromBeforeASaveIsRefused() { + var gate = ProfileWriteGate() + let readBeforeSave = gate.copyEpoch("a") + + gate.beginSave("a") + gate.endSave("a") + + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readBeforeSave)) + XCTAssertTrue(gate.mayRefreshCopy("a", epoch: gate.copyEpoch("a"))) + } + + func testNoCopyRefreshDuringASave() { + var gate = ProfileWriteGate() + + gate.beginSave("a") + let readDuringSave = gate.copyEpoch("a") + + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSave)) + gate.endSave("a") + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSave)) + XCTAssertTrue(gate.mayRefreshCopy("b", epoch: gate.copyEpoch("b"))) + } + func testDeletionWaitsForWritesUnderWay() async throws { actor Box { var gate = ProfileWriteGate() From 308c7c866c60c707cc0c665c6fac0dd3522aa7e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 10 Nov 2026 23:47:23 +0100 Subject: [PATCH 18/25] fix: record an avatar move before creating the pubky.app profile --- Bitkit/Managers/PubkyAppProfileStore.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 60f1bb26d..94b00a1c1 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -349,6 +349,11 @@ actor PubkyAppProfileStore { image: keptImage, links: fitted.links.map { Paykit.PubkyProfileLink(title: $0.label, url: $0.url) } ) + // Recorded before the profile is created without it, so an app stopped before the move still retries it. A failed + // create keeps it too: the create may have landed, and the next load or save handles either case. + if let uri = data.image, Self.isPaykitStorage(uri) { + Self.pubkyAppAvatarPending.insert(publicKey) + } let snapshot: Paykit.PubkyProfileSnapshot do { snapshot = try await admitted(publicKey, generation: generation) { From 40ef517ce07715a4fe8562baa82aa45c54722937 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 11:08:31 +0100 Subject: [PATCH 19/25] fix: keep a load from refreshing the copy during overlapping saves --- Bitkit/Managers/PubkyAppProfileStore.swift | 8 ++++++-- BitkitTests/PubkyAppProfileStoreTests.swift | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 94b00a1c1..b46c3c3a9 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -21,6 +21,7 @@ enum PubkyAppProfileError: LocalizedError { struct ProfileWriteGate { private var generations: [String: Int] = [:] private var copyEpochs: [String: Int] = [:] + private var savesRunning: [String: Int] = [:] private var deleting: Set = [] private var writesInFlight: [String: Int] = [:] private var writesSettled: [String: [CheckedContinuation]] = [:] @@ -61,22 +62,25 @@ struct ProfileWriteGate { deleting.remove(key) } - /// The copy epoch a load reads the Paykit copy at; odd while a save runs. + /// The copy epoch a load reads the Paykit copy at; it moves on when a save starts and when it ends. func copyEpoch(_ key: String) -> Int { copyEpochs[key, default: 0] } mutating func beginSave(_ key: String) { copyEpochs[key, default: 0] += 1 + savesRunning[key, default: 0] += 1 } mutating func endSave(_ key: String) { copyEpochs[key, default: 0] += 1 + let remaining = savesRunning[key, default: 1] - 1 + savesRunning[key] = remaining > 0 ? remaining : nil } /// Whether a load that read the Paykit copy at `epoch` may write it back: no save ran since or was running then. func mayRefreshCopy(_ key: String, epoch: Int) -> Bool { - epoch.isMultiple(of: 2) && copyEpochs[key, default: 0] == epoch + savesRunning[key, default: 0] == 0 && copyEpochs[key, default: 0] == epoch } func hasWritesInFlight(_ key: String) -> Bool { diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift index bbeb14df0..4ed599d29 100644 --- a/BitkitTests/PubkyAppProfileStoreTests.swift +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -156,6 +156,21 @@ final class ProfileWriteGateTests: XCTestCase { XCTAssertTrue(gate.mayRefreshCopy("b", epoch: gate.copyEpoch("b"))) } + func testNoCopyRefreshDuringOverlappingSaves() { + var gate = ProfileWriteGate() + + gate.beginSave("a") + gate.beginSave("a") + let readDuringSaves = gate.copyEpoch("a") + + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSaves)) + gate.endSave("a") + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSaves)) + gate.endSave("a") + XCTAssertFalse(gate.mayRefreshCopy("a", epoch: readDuringSaves)) + XCTAssertTrue(gate.mayRefreshCopy("a", epoch: gate.copyEpoch("a"))) + } + func testDeletionWaitsForWritesUnderWay() async throws { actor Box { var gate = ProfileWriteGate() From e016df03ce0b953229909320a9a2205612a6c6d9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 12:47:52 +0100 Subject: [PATCH 20/25] fix: keep the stored tags when an edit began without them The profile records when its tags could not be read, and a save from that profile merges the stored tags even after a later load reads them. --- Bitkit/Managers/PubkyAppProfileStore.swift | 17 ++++++++++------- Bitkit/Managers/PubkyProfileManager.swift | 13 ++++++++++++- Bitkit/Models/PubkyProfile.swift | 6 ++++++ Bitkit/Views/Profile/EditProfileView.swift | 5 ++++- Bitkit/Views/Profile/ProfileView.swift | 3 ++- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index b46c3c3a9..58cc73f90 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -183,6 +183,7 @@ actor PubkyAppProfileStore { let avatarPending = Self.pubkyAppAvatarPending.contains(key) pendingAvatarUris[key] = avatarPending ? paykitData?.image : nil var profile = Self.profile(publicKey: key, snapshot: snapshot, tags: paykitData?.tags ?? []) + profile.tagsUnread = paykitReadError != nil if avatarPending, let uri = paykitData?.image { profile = profile.withImageUrl(uri) } @@ -209,7 +210,8 @@ actor PubkyAppProfileStore { let generation = writeGate.generation(key) writeGate.beginSave(key) defer { writeGate.endSave(key) } - let copyUnread = tagsUnavailable.contains(key) + // Unread when the edit began or now: either way the form's tags are not the stored ones. + let copyUnread = data.baseTagsUnread || tagsUnavailable.contains(key) var base: PubkyProfile? if let baseRevision = data.baseRevision { guard let known = profilesByRevision[key]?[baseRevision] else { @@ -240,7 +242,7 @@ actor PubkyAppProfileStore { } Self.pubkyAppProfileOwners.insert(key) let previousTags = base?.tags ?? [] - let stored = await storedCopy(for: key) + let stored = await storedCopy(for: key, copyUnread: copyUnread) if let paykitAvatar, snapshot.profile.image == nil { Self.pubkyAppAvatarPending.insert(key) pendingAvatarUris[key] = paykitAvatar @@ -271,7 +273,8 @@ actor PubkyAppProfileStore { copyError = error } } - let storedProfile = tags != nil && copyError == nil ? profile : profile.withTags(previousTags) + var storedProfile = tags != nil && copyError == nil ? profile : profile.withTags(previousTags) + storedProfile.tagsUnread = copyUnread && (tags == nil || copyError != nil) remember(storedProfile, for: key) if let saveId = data.saveId { savedProfiles[saveId] = storedProfile @@ -299,10 +302,10 @@ actor PubkyAppProfileStore { revisionOrder[key] = order } - /// What a save keeps from the Paykit copy after the last load could not read it, as the form then lacked its tags - /// and the avatar still to move: empty when that load read it, nil while it still cannot be read. - private func storedCopy(for key: String) async -> (tags: [String]?, image: String?)? { - guard tagsUnavailable.contains(key) else { return (nil, nil) } + /// What a save keeps from the Paykit copy when its form was built without it, as the form then lacked its tags and + /// the avatar still to move: empty when the copy is read, nil while it still cannot be read. + private func storedCopy(for key: String, copyUnread: Bool) async -> (tags: [String]?, image: String?)? { + guard copyUnread else { return (nil, nil) } do { let data = try await PubkyService.fetchPaykitProfile(publicKey: key).map { PubkyProfileData.from(paykitProfile: $0.profile) } return (data?.tags ?? [], data?.image) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index c9f53fe1e..2bfc42129 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -908,7 +908,8 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String] = [], avatarImage: UIImage? = nil, - baseRevision: String? = nil + baseRevision: String? = nil, + baseTagsUnread: Bool = false ) async throws -> Bool { guard let session = currentSession else { return false } _ = try activeSessionSecret() @@ -919,6 +920,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, avatarImage: avatarImage, baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, session: session ) } @@ -937,6 +939,7 @@ class PubkyProfileManager: ObservableObject { tags: [String], avatarImage: UIImage?, baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { if profileReadAwaitingSaves?.session != session { @@ -952,6 +955,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, avatarImage: avatarImage, baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, session: session ) profileSavesInFlight -= 1 @@ -970,6 +974,7 @@ class PubkyProfileManager: ObservableObject { tags: [String], avatarImage: UIImage?, baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { var newImageUrl: String? @@ -995,6 +1000,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, newImageUrl: newImageUrl, baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, session: session ) } @@ -1009,6 +1015,7 @@ class PubkyProfileManager: ObservableObject { tags: [String], newImageUrl: String?, baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) @@ -1023,6 +1030,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, expectedIdentity: session.publicKey ) } catch PubkyServiceError.identityChanged { @@ -1125,6 +1133,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String] = [], baseRevision: String? = nil, + baseTagsUnread: Bool = false, expectedIdentity: String? = nil ) async throws -> (profile: PubkyProfile?, tagsNotSavedAt: String?) { var profileData = PubkyProfileData( @@ -1135,6 +1144,7 @@ class PubkyProfileManager: ObservableObject { tags: tags ) profileData.baseRevision = baseRevision + profileData.baseTagsUnread = baseTagsUnread let saveId = UUID() profileData.saveId = saveId @@ -1229,6 +1239,7 @@ class PubkyProfileManager: ObservableObject { tags: tags, avatarImage: avatarImage, baseRevision: profile?.revision, + baseTagsUnread: profile?.tagsUnread ?? false, session: session ) } diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift index ac946263a..7ac301090 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -11,6 +11,8 @@ struct PubkyProfileData: Codable, Equatable { var tags: [String] /// The pubky.app revision an edit started from; never part of the published copy. var baseRevision: String? + /// Whether the profile the edit started from lacked the stored tags, as the copy could not be read; never published. + var baseTagsUnread = false /// Identifies the save, so its caller can read back the profile it stored; never part of the published copy. var saveId: UUID? @@ -125,10 +127,13 @@ struct PubkyProfile { let status: String? /// The pubky.app revision this profile was read or saved at, for a save to check against. var revision: String? + /// Set when Bitkit's copy could not be read, so `tags` are not the stored ones and a save keeps those. + var tagsUnread = false func withImageUrl(_ imageUrl: String?) -> PubkyProfile { var profile = PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) profile.revision = revision + profile.tagsUnread = tagsUnread return profile } @@ -203,6 +208,7 @@ struct PubkyProfile { func withTags(_ tags: [String]) -> PubkyProfile { var profile = PubkyProfile(publicKey: publicKey, name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags, status: status) profile.revision = revision + profile.tagsUnread = tagsUnread return profile } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 5b871008d..431a43b8e 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -12,6 +12,7 @@ struct EditProfileView: View { @State private var links: [ProfileLinkInput] = [] @State private var tags: [String] = [] @State private var baseRevision: String? + @State private var baseTagsUnread = false @State private var loadedBio = "" @State private var isSaving = false @State private var isDeleting = false @@ -159,6 +160,7 @@ struct EditProfileView: View { links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } tags = profile.tags baseRevision = profile.revision + baseTagsUnread = profile.tagsUnread } // MARK: - Delete Profile @@ -244,7 +246,8 @@ struct EditProfileView: View { links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, tags: tags, avatarImage: avatarImage, - baseRevision: baseRevision + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread ) guard isSaved else { return } app.toast(type: .success, title: t("profile__edit_saved"), accessibilityIdentifier: "ProfileUpdatedToast") diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift index fca71b41f..e37280c61 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -280,7 +280,8 @@ struct ProfileView: View { bio: profile.bio, links: profile.links, tags: tags, - baseRevision: profile.revision + baseRevision: profile.revision, + baseTagsUnread: profile.tagsUnread ) } catch { Logger.error("Failed to update profile tags: \(error)", context: "ProfileView") From 718b2022199010498c58ad21e12e670db342f607 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 14:33:24 +0100 Subject: [PATCH 21/25] fix: save the profile without waiting for an unchanged copy A save with no tag change writes pubky.app and lets the Paykit copy follow as a tracked refresh, so Edit Profile Save takes one write again. --- Bitkit/Managers/PubkyAppProfileStore.swift | 27 +++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 58cc73f90..1abe31ba1 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -261,16 +261,31 @@ actor PubkyAppProfileStore { // Tags live only in the Paykit copy, so a tag change it does not take is reported, and the remembered base keeps // the tags it last stored so a retry is reported again. var copyError: Error? - if tags != nil { + if let tags { // A copy refresh begun before this save started would otherwise land after it with the old tags. for refresh in copyRefreshes[key, default: [:]].values { await refresh.value } - do { - try await publishCopy(profile, expectedIdentity: key, generation: generation) - tagsUnavailable.remove(key) - } catch { - copyError = error + if tags == previousTags { + // No tag change to report, so the save does not wait for the copy: it follows as a tracked refresh, + // which the next save waits for and a deletion refuses or waits for. + let id = UUID() + copyRefreshes[key, default: [:]][id] = Task { + do { + try await publishCopy(profile, expectedIdentity: key, generation: generation) + tagsUnavailable.remove(key) + } catch { + Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") + } + copyRefreshes[key]?[id] = nil + } + } else { + do { + try await publishCopy(profile, expectedIdentity: key, generation: generation) + tagsUnavailable.remove(key) + } catch { + copyError = error + } } } var storedProfile = tags != nil && copyError == nil ? profile : profile.withTags(previousTags) From b269c39aec0f22f0a6659ce103bf141cc4b926b4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 15:25:00 +0100 Subject: [PATCH 22/25] fix: keep a load's unread tags when a background copy write ends --- Bitkit/Managers/PubkyAppProfileStore.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 1abe31ba1..c2ee6eadb 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -268,12 +268,12 @@ actor PubkyAppProfileStore { } if tags == previousTags { // No tag change to report, so the save does not wait for the copy: it follows as a tracked refresh, - // which the next save waits for and a deletion refuses or waits for. + // which the next save waits for and a deletion refuses or waits for. It leaves the unread flag alone, + // which a load may have set since. let id = UUID() copyRefreshes[key, default: [:]][id] = Task { do { try await publishCopy(profile, expectedIdentity: key, generation: generation) - tagsUnavailable.remove(key) } catch { Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") } From 49f305a3f80e42fd06f44acadada24f29d873387 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 15:48:04 +0100 Subject: [PATCH 23/25] fix: clear the unread tags flag when a save reads the copy itself --- Bitkit/Managers/PubkyAppProfileStore.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index c2ee6eadb..5bc110133 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -128,6 +128,9 @@ actor PubkyAppProfileStore { private var savedProfiles: [UUID: PubkyProfile] = [:] private var pendingAvatarUris: [String: String] = [:] private var tagsUnavailable: Set = [] + /// Moved on each time a load records whether it read the copy, so a save clears `tagsUnavailable` only if no load + /// set it since the save began. + private var tagsReadEpochs: [String: Int] = [:] private var writeGate = ProfileWriteGate() private var copyRefreshes: [String: [UUID: Task]] = [:] private static let rememberedRevisions = 8 @@ -146,11 +149,13 @@ actor PubkyAppProfileStore { PubkyProfileData.from(paykitProfile: $0.profile) } tagsUnavailable.remove(key) + tagsReadEpochs[key, default: 0] += 1 } catch { Logger.warn("Failed to read Bitkit profile copy: \(error)", context: "PubkyAppProfileStore") paykitData = nil paykitReadError = error tagsUnavailable.insert(key) + tagsReadEpochs[key, default: 0] += 1 } let fetched = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) var snapshot = fetched @@ -212,6 +217,7 @@ actor PubkyAppProfileStore { defer { writeGate.endSave(key) } // Unread when the edit began or now: either way the form's tags are not the stored ones. let copyUnread = data.baseTagsUnread || tagsUnavailable.contains(key) + let readEpoch = tagsReadEpochs[key, default: 0] var base: PubkyProfile? if let baseRevision = data.baseRevision { guard let known = profilesByRevision[key]?[baseRevision] else { @@ -243,6 +249,10 @@ actor PubkyAppProfileStore { Self.pubkyAppProfileOwners.insert(key) let previousTags = base?.tags ?? [] let stored = await storedCopy(for: key, copyUnread: copyUnread) + // This save read the copy itself, so the tags are readable again, unless a load found otherwise meanwhile. + if copyUnread, stored?.tags != nil, tagsReadEpochs[key, default: 0] == readEpoch { + tagsUnavailable.remove(key) + } if let paykitAvatar, snapshot.profile.image == nil { Self.pubkyAppAvatarPending.insert(key) pendingAvatarUris[key] = paykitAvatar From 4b28bd5780662c88e8455e28abcf8ae39061a83b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:16:39 +0100 Subject: [PATCH 24/25] fix: sign in again for the pubky.app scope only while the same identity is signed in --- Bitkit/Managers/PubkyAppProfileStore.swift | 19 ++++++----- Bitkit/Services/PubkyService.swift | 22 +++++++++++++ BitkitTests/PaykitSdkIdentityCheckTests.swift | 33 +++++++++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 5bc110133..4013ad0b7 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -238,7 +238,7 @@ actor PubkyAppProfileStore { let snapshot: Paykit.PubkyProfileSnapshot do { snapshot = try await admitted(key, generation: generation) { - try await withPubkyAppScope { + try await withPubkyAppScope(key) { try await PubkyService.publishPubkyProfile(update, expectedRevision: data.baseRevision, expectedIdentity: key) } } @@ -348,7 +348,7 @@ actor PubkyAppProfileStore { /// Uploads a JPEG avatar as a pubky.app blob and file and returns the file URI for the profile image. func uploadAvatar(_ jpegData: Data, expectedIdentity: String?) async throws -> String { let key = try await Self.identity(expectedIdentity) - return try await withPubkyAppScope { + return try await withPubkyAppScope(key) { try await PubkyService.uploadPubkyAppAvatar(bytes: jpegData, contentType: "image/jpeg", expectedIdentity: key) } } @@ -389,7 +389,7 @@ actor PubkyAppProfileStore { let snapshot: Paykit.PubkyProfileSnapshot do { snapshot = try await admitted(publicKey, generation: generation) { - try await withPubkyAppScope { + try await withPubkyAppScope(publicKey) { try await PubkyService.publishPubkyProfile(update, expectedRevision: nil, expectedIdentity: publicKey) } } @@ -409,7 +409,7 @@ actor PubkyAppProfileStore { generation: Int ) async -> Paykit.PubkyProfileSnapshot? { do { - let moved = try await admitted(publicKey, generation: generation) { try await withPubkyAppScope { + let moved = try await admitted(publicKey, generation: generation) { try await withPubkyAppScope(publicKey) { let bytes = try await PubkyService.fetchFile(uri: uri, maxBytes: Self.avatarMaxBytes) let image = try await PubkyService.uploadPubkyAppAvatar( bytes: bytes, @@ -442,7 +442,7 @@ actor PubkyAppProfileStore { await withCheckedContinuation { writeGate.onWritesSettled(key, $0) } } if !keepPubkyAppProfile, let revision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision { - try await withPubkyAppScope { + try await withPubkyAppScope(key) { try await PubkyService.deletePubkyProfile(expectedRevision: revision, expectedIdentity: key) } profilesByRevision[key] = nil @@ -473,13 +473,14 @@ actor PubkyAppProfileStore { } } - /// Sessions from builds before pubky.app profile writes lack its scope; sign in again once to gain it. - private func withPubkyAppScope(_ operation: () async throws -> T) async throws -> T { + /// Sessions from builds before pubky.app profile writes lack its scope; sign in again once to gain it, only while + /// `identity` is still signed in, so a sign-out that lands meanwhile is never undone. + private func withPubkyAppScope(_ identity: String, _ operation: () async throws -> T) async throws -> T { do { return try await operation() } catch let error as PaykitError { - guard case .Identity = error, let secretKeyHex = PubkyProfileManager.activeSecretKeyHex() else { throw error } - _ = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + guard case .Identity = error else { throw error } + try await PubkyService.reauthenticate(expectedIdentity: identity) return try await operation() } } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 08fa13da9..03c3cdc79 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -321,6 +321,12 @@ enum PubkyService { return result.sessionAccess.exportSessionSecret() } + /// Signs in again with the stored key, to gain a scope an older session lacks, only while `expectedIdentity` is signed in + /// and the key is its own. See `PaykitSdkService.reauthenticate(expectedIdentity:)`. + static func reauthenticate(expectedIdentity: String) async throws { + _ = try await PaykitSdkService.shared.reauthenticate(expectedIdentity: expectedIdentity) + } + // MARK: - File Fetching /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution. @@ -751,6 +757,22 @@ actor PaykitSdkService { } } + /// Signs in again with the stored key only while `expectedIdentity` is signed in and the key is its own, both checked in + /// the same locked operation as the sign-in, so a sign-out or another identity's sign-in that lands first is never + /// undone and throws `identityChanged`. + func reauthenticate( + expectedIdentity: String, + secretKeyHex: () -> String? = { PubkyProfileManager.activeSecretKeyHex() } + ) async throws -> PubkySessionBootstrapResult { + try await operationLock.withLock { + try await Self.requireSignedInIdentity(expectedIdentity, in: handle()) + guard let secretKeyHex = secretKeyHex(), + PubkyPublicKeyFormat.matches(try? PubkyProfileManager.publicKeyFromSecretKey(secretKeyHex), expectedIdentity) + else { throw PubkyServiceError.identityChanged } + return try await signInLocked(secretKeyHex: secretKeyHex) + } + } + private func signInLocked(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { let previousPublicKey = try await cachedIdentityPublicKey() let result = try await bootstrap().signIn( diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index d4b3754be..585c24904 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -1036,6 +1036,39 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(oldSdk.mutations, alreadyStartedMutations, "Abandoned cleanup must not start another mutation") XCTAssertTrue(replacementSdk.mutations.isEmpty, "An old wallet cleanup must not mutate the replacement SDK") } + + func testReauthenticationSignsInOnlyWhileTheExpectedIdentityIsSignedInWithItsOwnKey() async throws { + let ownKey = String(repeating: "1", count: 64) + let ownIdentity = try PubkyProfileManager.publicKeyFromSecretKey(ownKey) + let otherKey = String(repeating: "2", count: 64) + let signInReached = PubkyServiceError.authFailed("sign-in reached") + let cases: [(signedIn: String?, key: String?, signsIn: Bool)] = [ + (nil, ownKey, false), + (identityB, ownKey, false), + (ownIdentity, otherKey, false), + (ownIdentity, nil, false), + (ownIdentity, ownKey, true), + ] + for testCase in cases { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = testCase.signedIn + var bootstraps = 0 + let service = PaykitSdkService(sdkFactory: { sdk }, bootstrapFactory: { _, _ in + bootstraps += 1 + throw signInReached + }) + + do { + _ = try await service.reauthenticate(expectedIdentity: ownIdentity, secretKeyHex: { testCase.key }) + XCTFail("Expected reauthentication to stop") + } catch PubkyServiceError.identityChanged { + XCTAssertFalse(testCase.signsIn, "\(testCase)") + } catch { + XCTAssertTrue(testCase.signsIn, "\(testCase): \(error)") + } + XCTAssertEqual(bootstraps, testCase.signsIn ? 1 : 0, "\(testCase)") + } + } } private let identityA = "pubky8qd4tbz3hyafi7h7hoqwd5hm7tsaz1n7txcyojmd4kxf9xp7mgro" From 16fd4d8f7944e2182e4d91f34713fd390567b2d0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 11 Nov 2026 16:30:04 +0100 Subject: [PATCH 25/25] fix: keep the unread tags flag when the copy write fails --- Bitkit/Managers/PubkyAppProfileStore.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/PubkyAppProfileStore.swift b/Bitkit/Managers/PubkyAppProfileStore.swift index 4013ad0b7..19c78e1ca 100644 --- a/Bitkit/Managers/PubkyAppProfileStore.swift +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -249,10 +249,6 @@ actor PubkyAppProfileStore { Self.pubkyAppProfileOwners.insert(key) let previousTags = base?.tags ?? [] let stored = await storedCopy(for: key, copyUnread: copyUnread) - // This save read the copy itself, so the tags are readable again, unless a load found otherwise meanwhile. - if copyUnread, stored?.tags != nil, tagsReadEpochs[key, default: 0] == readEpoch { - tagsUnavailable.remove(key) - } if let paykitAvatar, snapshot.profile.image == nil { Self.pubkyAppAvatarPending.insert(key) pendingAvatarUris[key] = paykitAvatar @@ -278,8 +274,8 @@ actor PubkyAppProfileStore { } if tags == previousTags { // No tag change to report, so the save does not wait for the copy: it follows as a tracked refresh, - // which the next save waits for and a deletion refuses or waits for. It leaves the unread flag alone, - // which a load may have set since. + // which the next save waits for and a deletion refuses or waits for. Its outcome leaves the unread flag + // alone, which a load may have set since. let id = UUID() copyRefreshes[key, default: [:]][id] = Task { do { @@ -289,6 +285,12 @@ actor PubkyAppProfileStore { } copyRefreshes[key]?[id] = nil } + // This save read the copy itself and keeps its tags in the profile it stores, so they are readable + // again, unless a load found otherwise meanwhile. A save that writes the copy at once clears the flag + // only once that write succeeds, since a failed one stores the profile without the copy's tags. + if copyUnread, stored?.tags != nil, tagsReadEpochs[key, default: 0] == readEpoch { + tagsUnavailable.remove(key) + } } else { do { try await publishCopy(profile, expectedIdentity: key, generation: generation)