diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 540a5961c..b042ced69 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-rc6"; + version = "0.1.1-rc7"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4facba89f..4fe5d9f88 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" : "9919198a8a810652945f7366defacae8c3135632", - "version" : "0.1.1-rc6" + "revision" : "8f1cd4e9fea8b99043f2437757dd27b0f96efb9e", + "version" : "0.1.1-rc7" } }, { diff --git a/Bitkit/Components/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift index e092283d1..77e1480eb 100644 --- a/Bitkit/Components/ProfileEditFormView.swift +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -17,6 +17,9 @@ 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? let deleteLabel: String? var deleteSectionCaption: String = t("profile__edit_delete_section") @@ -154,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 + } } } } @@ -294,7 +299,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 new file mode 100644 index 000000000..19c78e1ca --- /dev/null +++ b/Bitkit/Managers/PubkyAppProfileStore.swift @@ -0,0 +1,620 @@ +import Foundation +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? { + switch self { + case .changedElsewhere: t("profile__edit_changed_error") + case .tagsNotSaved: t("profile__edit_tags_error") + } + } +} + +/// 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. 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 savesRunning: [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) + } + + /// 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) + } + + /// 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 { + savesRunning[key, default: 0] == 0 && copyEpochs[key, default: 0] == epoch + } + + 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 +/// 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 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 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) } + } + + /// 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 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 + + /// 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 generation = writeGate.generation(key) + let copyEpoch = writeGate.copyEpoch(key) + let paykitData: PubkyProfileData? + var paykitReadError: Error? + do { + paykitData = try await PubkyService.fetchPaykitProfile(publicKey: key).map { + 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 + 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 fetched == nil, isSignedIn, !hadPubkyAppProfile, let paykitData { + 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, 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. + Self.pubkyAppAvatarPending.remove(key) + } + } + guard let snapshot else { + 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 ?? []) + profile.tagsUnread = paykitReadError != nil + if avatarPending, let uri = paykitData?.image { + profile = profile.withImageUrl(uri) + } + remember(profile, for: key) + 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 + } + + /// 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; 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) + let generation = writeGate.generation(key) + writeGate.beginSave(key) + 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 { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + base = known + } else if try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key) != nil { + _ = try? await load(publicKey: key) + throw PubkyAppProfileError.changedElsewhere + } + 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) { + update.image = nil + } + let snapshot: Paykit.PubkyProfileSnapshot + do { + snapshot = try await admitted(key, generation: generation) { + try await withPubkyAppScope(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) + let previousTags = base?.tags ?? [] + let stored = await storedCopy(for: key, copyUnread: copyUnread) + if let paykitAvatar, snapshot.profile.image == nil { + Self.pubkyAppAvatarPending.insert(key) + pendingAvatarUris[key] = paykitAvatar + } 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 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) + } + // 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 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 + } + 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. Its outcome 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) + } catch { + Logger.warn("Failed to update Bitkit profile copy: \(error)", context: "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) + tagsUnavailable.remove(key) + } catch { + copyError = error + } + } + } + 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 + } + 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 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) { + 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 + } + + /// 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) + } catch { + return nil + } + } + + /// 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) } + } + + /// 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(key) { + try await PubkyService.uploadPubkyAppAvatar(bytes: jpegData, contentType: "image/jpeg", expectedIdentity: key) + } + } + + /// 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, 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 } + let update = Paykit.PubkyProfileUpdate( + name: fitted.name, + bio: fitted.bio, + 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) { + try await withPubkyAppScope(publicKey) { + try await PubkyService.publishPubkyProfile(update, expectedRevision: nil, expectedIdentity: publicKey) + } + } + Logger.info("Moved Bitkit profile to pubky.app", context: "PubkyAppProfileStore") + } catch { + Logger.warn("Failed to move Bitkit profile to pubky.app: \(error)", context: "PubkyAppProfileStore") + return nil + } + guard let uri = data.image, Self.isPaykitStorage(uri) else { return snapshot } + return await moveAvatar(uri, onto: snapshot, publicKey: publicKey, generation: generation) ?? snapshot + } + + private func moveAvatar( + _ uri: String, + onto snapshot: Paykit.PubkyProfileSnapshot, + publicKey: String, + generation: Int + ) async -> Paykit.PubkyProfileSnapshot? { + do { + 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, + 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 + ) + } } + Self.pubkyAppAvatarPending.remove(publicKey) + return moved + } catch { + Logger.warn("Failed to move avatar to pubky.app: \(error)", context: "PubkyAppProfileStore") + Self.pubkyAppAvatarPending.insert(publicKey) + return nil + } + } + + /// 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) + writeGate.beginDeletion(key) + defer { writeGate.endDeletion(key) } + if writeGate.hasWritesInFlight(key) { + await withCheckedContinuation { writeGate.onWritesSettled(key, $0) } + } + if !keepPubkyAppProfile, let revision = try await PubkyService.fetchPubkyProfileWithRevision(publicKey: key)?.revision { + try await withPubkyAppScope(key) { + 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, generation: Int) async throws { + do { + 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 + } + } + + /// 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 else { throw error } + try await PubkyService.reauthenticate(expectedIdentity: identity) + 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) + var result = PubkyProfile( + publicKey: publicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl, + links: profile.links, + 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 + } + + static func singleLine(_ text: String) -> String { + String(String.UnicodeScalarView(text.unicodeScalars.map { $0.properties.generalCategory == .control ? " " : $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. + /// 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, !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 { + return nil + } + guard url.unicodeScalars.count <= linkUrlMaxLength, let parsed = URL(string: url), hasValidHost(parsed) else { return nil } + return url + } + + /// 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 ["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 { + 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 + && value.range(of: #"^\d{1,3}(\.\d{1,3}){3}$"#, options: .regularExpression) == nil + : false + } + + private static func isPaykitStorage(_ uri: String) -> Bool { + 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) + } + 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" + } + } +} diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 52d7be2f6..2bfc42129 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -160,15 +160,19 @@ 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) + // A Ring identity's profile belongs to Ring, so unlinking it leaves its pubky.app profile in place. + try await PubkyAppProfileStore.shared.deleteProfile( + expectedIdentity: $0, + keepPubkyAppProfile: PubkyProfileManager.isRingAdopted(publicKey: $0) + ) } ) { self.remoteProfileResolver = remoteProfileResolver @@ -447,6 +451,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) @@ -472,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 @@ -487,6 +500,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, existingImageUrl: existingImageUrl, + baseRevision: baseRevision, avatarImage: avatarImage ) return @@ -521,6 +535,7 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, existingImageUrl: existingImageUrl, + baseRevision: baseRevision, avatarImage: avatarImage ) }, @@ -739,6 +754,7 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], existingImageUrl: String?, + baseRevision: String?, avatarImage: UIImage? ) async throws { var avatarUri: String? @@ -747,10 +763,22 @@ class PubkyProfileManager: ObservableObject { } let imageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) - try await writeProfile(name: name, bio: bio, imageUrl: imageUrl, links: links, tags: tags) + // 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, + links: links, + tags: tags, + baseRevision: baseRevision, + expectedIdentity: publicKey + ) + if tagsNotSavedAt != nil { + Logger.warn("Created the profile without its tags", context: "PubkyProfileManager") + } Self.notifyAppStateBackupChanged() - let createdProfile = PubkyProfile( + let createdProfile = savedProfile ?? PubkyProfile( publicKey: publicKey, name: name, bio: bio, @@ -879,11 +907,22 @@ class PubkyProfileManager: ObservableObject { bio: String, links: [PubkyProfileLink], tags: [String] = [], - avatarImage: UIImage? = nil + avatarImage: UIImage? = nil, + baseRevision: String? = nil, + baseTagsUnread: Bool = false ) 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, + baseTagsUnread: baseTagsUnread, + session: session + ) } /// Starting the edit drops every profile read that started before it, ahead of the avatar upload, so a refresh that finds @@ -899,6 +938,8 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], avatarImage: UIImage?, + baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { if profileReadAwaitingSaves?.session != session { @@ -913,6 +954,8 @@ class PubkyProfileManager: ObservableObject { links: links, tags: tags, avatarImage: avatarImage, + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, session: session ) profileSavesInFlight -= 1 @@ -930,6 +973,8 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], avatarImage: UIImage?, + baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { var newImageUrl: String? @@ -948,7 +993,16 @@ 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, + baseTagsUnread: baseTagsUnread, + session: session + ) } /// A published profile ends any pending setup. The SDK publishes only while the session's identity is still signed in, @@ -960,17 +1014,23 @@ class PubkyProfileManager: ObservableObject { links: [PubkyProfileLink], tags: [String], newImageUrl: String?, + baseRevision: String?, + baseTagsUnread: Bool, session: SignedInSession ) async throws -> Bool { let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: profile?.imageUrl) + let savedProfile: PubkyProfile? + let tagsNotSavedAt: String? do { - try await writeProfile( + (savedProfile, tagsNotSavedAt) = try await writeProfile( name: name, bio: bio, imageUrl: resolvedImageUrl, links: links, tags: tags, + baseRevision: baseRevision, + baseTagsUnread: baseTagsUnread, expectedIdentity: session.publicKey ) } catch PubkyServiceError.identityChanged { @@ -985,7 +1045,7 @@ class PubkyProfileManager: ObservableObject { return false } - let updatedProfile = PubkyProfile( + let updatedProfile = savedProfile ?? PubkyProfile( publicKey: session.publicKey, name: name, bio: bio, @@ -996,6 +1056,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 } @@ -1068,20 +1132,32 @@ class PubkyProfileManager: ObservableObject { imageUrl: String?, links: [PubkyProfileLink], tags: [String] = [], + baseRevision: String? = nil, + baseTagsUnread: Bool = false, expectedIdentity: String? = nil - ) async throws { - let profileData = PubkyProfileData( + ) async throws -> (profile: PubkyProfile?, tagsNotSavedAt: String?) { + 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 + profileData.baseTagsUnread = baseTagsUnread + 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; the caller decides what that means for its flow. + return await (PubkyAppProfileStore.shared.savedProfile(saveId: saveId), revision) + } + return await (PubkyAppProfileStore.shared.savedProfile(saveId: saveId), nil) } private func discardAbandonedSession() async { @@ -1156,7 +1232,16 @@ 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, + baseTagsUnread: profile?.tagsUnread ?? false, + session: session + ) } func clearAuthenticatedStateForTesting() { @@ -1188,6 +1273,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") } @@ -1762,6 +1856,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..7ac301090 100644 --- a/Bitkit/Models/PubkyProfile.swift +++ b/Bitkit/Models/PubkyProfile.swift @@ -9,6 +9,16 @@ 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? + /// 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? + + private enum CodingKeys: String, CodingKey { + case name, bio, image, links, tags + } struct Link: Codable, Equatable { let label: String @@ -115,6 +125,17 @@ 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? + /// 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 + } var truncatedPublicKey: String { Self.truncate(publicKey) @@ -185,7 +206,10 @@ 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 + profile.tagsUnread = tagsUnread + return profile } func withNameFallback(_ fallbackName: String?) -> PubkyProfile { @@ -213,3 +237,23 @@ 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 let maxLinks = 5 + + /// pubky.app counts Unicode scalars, not characters. + static func isValidName(_ name: String) -> Bool { + 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 361e6ca6b..c9fd03f2e 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -957,7 +957,9 @@ "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__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 2515533ac..9f5b5f194 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1073,7 +1073,9 @@ "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__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 a17a829d8..df7eb8272 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1178,7 +1178,9 @@ "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__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 b9d971d7e..c9bd2a249 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1176,7 +1176,9 @@ "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__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 e2d85e3ea..a27c43aaf 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -974,7 +974,9 @@ "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__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 b836146a5..1d3129eae 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1191,7 +1191,9 @@ "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__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 984b032f9..28a3d93f3 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1181,7 +1181,9 @@ "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__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 95a81cf15..3d1a49374 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1091,7 +1091,9 @@ "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__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 80af76841..add3b5ca6 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1189,7 +1189,9 @@ "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__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 c65484054..2ea47d593 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1155,7 +1155,9 @@ "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__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 05af3c534..989bebf7a 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1184,7 +1184,9 @@ "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__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 17f8568a8..f586b19d3 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1188,7 +1188,9 @@ "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__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 4d94e061e..d735c9e5e 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1189,7 +1189,9 @@ "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__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 779a1e395..836ce1a0b 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -949,7 +949,9 @@ "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__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 077f03cc4..64ac5c454 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1174,7 +1174,9 @@ "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__edit_tags_error" = "Bitkit не удалось сохранить ваши теги. Остальные изменения сохранены, поэтому попробуйте сохранить теги ещё раз."; "profile__delete_title" = "Удалить Профиль?"; "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 9902711a2..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. @@ -360,6 +366,38 @@ 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 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, + contentType: contentType, + expectedIdentity: expectedIdentity + ) + } + // MARK: - Contacts static func getContacts(publicKey: String) async throws -> [String] { @@ -680,7 +718,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() @@ -699,7 +737,7 @@ actor PaykitSdkService { localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) return PubkyRegisteredIdentity(result: result, walletGeneration: generation) } @@ -719,11 +757,27 @@ 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( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), - requiredCapabilities: Self.requiredCapabilities() + requiredCapabilities: Self.sessionCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey) markWalletBackupDataChanged() @@ -831,6 +885,42 @@ 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) + } + } + + /// 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 + 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) } } @@ -1625,10 +1715,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/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 } diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift index 42119d49f..63ec3a939 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 let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.nameLength.upperBound) { + username = capped + } + } } // 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) @@ -169,6 +174,9 @@ struct CreateProfileView: View { username = remote.name existingProfile = remote isRestoring = true + } else { + existingProfile = nil + isRestoring = false } } catch is CancellationError { return @@ -187,7 +195,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 +221,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 } @@ -225,11 +233,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 183393e11..431a43b8e 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -11,6 +11,9 @@ 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 baseTagsUnread = false + @State private var loadedBio = "" @State private var isSaving = false @State private var isDeleting = false @State private var showDeleteConfirmation = false @@ -32,6 +35,8 @@ 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, deleteSectionCaption: copy.sectionCaption, @@ -60,6 +65,16 @@ struct EditProfileView: View { .task { loadProfileData() } + .onChange(of: username) { _, value in + if let capped = PubkyAppProfileLimits.capped(value, to: PubkyAppProfileLimits.nameLength.upperBound) { + username = capped + } + } + .onChange(of: bio) { _, value in + // 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) { Task { await deleteProfile() } @@ -140,9 +155,12 @@ 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 + baseRevision = profile.revision + baseTagsUnread = profile.tagsUnread } // MARK: - Delete Profile @@ -216,7 +234,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 } @@ -227,7 +245,9 @@ 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, + baseTagsUnread: baseTagsUnread ) guard isSaved else { return } app.toast(type: .success, title: t("profile__edit_saved"), accessibilityIdentifier: "ProfileUpdatedToast") @@ -235,6 +255,14 @@ 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() + } 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 9b3c4100b..e37280c61 100644 --- a/Bitkit/Views/Profile/ProfileView.swift +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -279,11 +279,16 @@ struct ProfileView: View { name: profile.name, bio: profile.bio, links: profile.links, - tags: tags + tags: tags, + baseRevision: profile.revision, + baseTagsUnread: profile.tagsUnread ) } 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() + } } } } @@ -291,7 +296,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/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" diff --git a/BitkitTests/PubkyAppProfileStoreTests.swift b/BitkitTests/PubkyAppProfileStoreTests.swift new file mode 100644 index 000000000..4ed599d29 --- /dev/null +++ b/BitkitTests/PubkyAppProfileStoreTests.swift @@ -0,0 +1,209 @@ +@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") + } + + 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(" ")) + } + + func testFreeTextIsNotMadeALink() { + XCTAssertNil(PubkyAppProfileStore.linkUrl("my site")) + 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("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") + 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")) + } + + 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" }) + } +} + +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") + let startedDuring = 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"))) + } + + 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 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() + 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) + } +} 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. 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" + +