Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fccfd61
feat: use the pubky.app profile directly
ovitrif Nov 6, 2026
27f8cb7
chore: pin paykit 0.1.1-rc7
ovitrif Nov 6, 2026
23ff2c6
fix: keep the newest profile revision and move profiles text first
ovitrif Nov 6, 2026
1b1448f
chore: add changelog fragment
ovitrif Nov 6, 2026
5003641
fix: keep contact avatars on paykit and a deleted pubky.app profile d…
ovitrif Nov 6, 2026
08f6ce8
fix: save a multi-line bio as one line
ovitrif Nov 6, 2026
5d805c4
fix: delete the pubky.app profile and keep a pending avatar
ovitrif Nov 6, 2026
1eaabe4
fix: carry the edit's revision and move every bitkit profile to pubky…
ovitrif Nov 6, 2026
cd310ab
fix: keep pubky.app edits on create, avatar moves and tag saves
ovitrif Nov 6, 2026
b516b7e
fix: fit restored profiles and keep tags and avatars across failed co…
ovitrif Nov 6, 2026
d792e85
fix: reject numeric link hosts paykit refuses
ovitrif Nov 6, 2026
5c476dc
fix: check numeric link hosts only for web urls
ovitrif Nov 6, 2026
bf59596
chore: resolve paykit 0.1.1-rc7
ovitrif Nov 10, 2026
afb5a63
fix: keep a deleted profile from being written back
ovitrif Nov 10, 2026
849b5fa
style: format the profile write gate tests
ovitrif Nov 10, 2026
b9e747f
Merge remote-tracking branch 'origin/master' into feat/pubky-app-profile
ovitrif Nov 10, 2026
c81b045
fix: refuse profile writes begun during a deletion
ovitrif Nov 10, 2026
63d31a1
fix: keep a load under way from writing back replaced tags
ovitrif Nov 10, 2026
308c7c8
fix: record an avatar move before creating the pubky.app profile
ovitrif Nov 10, 2026
40ef517
fix: keep a load from refreshing the copy during overlapping saves
ovitrif Nov 11, 2026
e016df0
fix: keep the stored tags when an edit began without them
ovitrif Nov 11, 2026
718b202
fix: save the profile without waiting for an unchanged copy
ovitrif Nov 11, 2026
a8b623d
Merge remote-tracking branch 'origin/master' into feat/pubky-app-profile
ovitrif Nov 11, 2026
b269c39
fix: keep a load's unread tags when a background copy write ends
ovitrif Nov 11, 2026
49f305a
fix: clear the unread tags flag when a save reads the copy itself
ovitrif Nov 11, 2026
4b28bd5
fix: sign in again for the pubky.app scope only while the same identi…
ovitrif Nov 11, 2026
16fd4d8
fix: keep the unread tags flag when the copy write fails
ovitrif Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1209,7 +1209,7 @@
repositoryURL = "https://github.com/pubky/paykit-rs";
requirement = {
kind = exactVersion;
version = "0.1.1-rc6";
version = "0.1.1-rc7";
Comment thread
ovitrif marked this conversation as resolved.
Resolved
};
};
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 14 additions & 9 deletions Bitkit/Components/ProfileEditFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ struct ProfileEditFormView<Avatar: View>: 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")
Expand Down Expand Up @@ -154,14 +157,16 @@ struct ProfileEditFormView<Avatar: View>: 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
}
}
}
}
Expand Down Expand Up @@ -294,7 +299,7 @@ struct ProfileEditFormView<Avatar: View>: View {
await onSave()
}
.accessibilityIdentifier("ProfileEditSave")
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.disabled(!isNameValid(name))
}
.padding(.horizontal, 16)
.padding(.top, 16)
Expand Down
620 changes: 620 additions & 0 deletions Bitkit/Managers/PubkyAppProfileStore.swift

Large diffs are not rendered by default.

132 changes: 115 additions & 17 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
ovitrif marked this conversation as resolved.
Resolved
},
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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -487,6 +500,7 @@ class PubkyProfileManager: ObservableObject {
links: links,
tags: tags,
existingImageUrl: existingImageUrl,
baseRevision: baseRevision,
avatarImage: avatarImage
)
return
Expand Down Expand Up @@ -521,6 +535,7 @@ class PubkyProfileManager: ObservableObject {
links: links,
tags: tags,
existingImageUrl: existingImageUrl,
baseRevision: baseRevision,
avatarImage: avatarImage
)
},
Expand Down Expand Up @@ -739,6 +754,7 @@ class PubkyProfileManager: ObservableObject {
links: [PubkyProfileLink],
tags: [String],
existingImageUrl: String?,
baseRevision: String?,
avatarImage: UIImage?
) async throws {
var avatarUri: String?
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -913,6 +954,8 @@ class PubkyProfileManager: ObservableObject {
links: links,
tags: tags,
avatarImage: avatarImage,
baseRevision: baseRevision,
baseTagsUnread: baseTagsUnread,
session: session
)
profileSavesInFlight -= 1
Expand All @@ -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?
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -985,7 +1045,7 @@ class PubkyProfileManager: ObservableObject {
return false
}

let updatedProfile = PubkyProfile(
let updatedProfile = savedProfile ?? PubkyProfile(
publicKey: session.publicKey,
name: name,
bio: bio,
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading