Skip to content

feat: add profile and contacts fetching from pubky#476

Open
ben-kaufman wants to merge 55 commits intomasterfrom
feat/pubky-profile
Open

feat: add profile and contacts fetching from pubky#476
ben-kaufman wants to merge 55 commits intomasterfrom
feat/pubky-profile

Conversation

@ben-kaufman
Copy link
Copy Markdown
Contributor

@ben-kaufman ben-kaufman commented Mar 5, 2026

Summary

Integrates Pubky decentralized identity into Bitkit, allowing users to create a Bitkit-managed profile or connect via Pubky Ring authentication. Once connected, the user's profile name and avatar appear on the home screen header, a full profile page shows their bio, links, tags, and shareable QR code, and a contacts section lists people they follow on the Pubky network.

What's included

  • Dual profile creation flow — create a new profile from BIP39 seed derivation, or import via Pubky Ring deep links (pubkyauth://)
  • Profile restore — when creating a profile, if one already exists on the network for the derived keys, it is detected and pre-populated (name, bio, links, tags preserved)
  • Ring auth import flow — after Ring authentication, discover existing profile and contacts from pubky.app, present import overview with "Import all" or "Select" options, then proceed to Pay Contacts onboarding
  • Pay Contacts onboarding — new step after both create and Ring import flows, with toggle for sharing payment data
  • Profile page displaying name, bio, links, tags, and a QR code with the profile picture overlaid
  • Profile editing — edit name, bio, links, and tags with suggestion sheets for links and tags
  • Contacts list with alphabetically grouped sections, search, add, edit, and delete
  • Contact detail page showing name, truncated public key, bio, links, tags, and copy/share/edit/delete actions
  • Contact import — overview screen showing found profile & contacts count with avatar stack, selection screen with select all/none, batch import
  • Home screen integration showing the authenticated user's name and avatar in the header
  • Session restoration — automatic re-sign-in using stored secret key when session import fails (Bitkit-managed); session preserved on failure for retry on next launch (Ring-managed); toast notification when restoration fails
  • PubkyService — service layer wrapping paykit-ffi (profile/contacts/payments) and bitkit-core (auth relay, PKDNS file fetching, key derivation)
  • PubkyProfileManager — manages auth state, session lifecycle, key derivation, profile creation/editing, and remote profile fetching
  • ContactsManager — fetches contacts in parallel via withTaskGroup, groups alphabetically, supports add/edit/delete/import, discovers remote contacts from pubky.app
  • PubkyImage component for loading pubky:// URIs with two-tier (memory + disk) caching
  • PubkyProfileData backward-compatible decoding — tags field defaults to empty array when missing from JSON (older profiles, other clients)
  • Suggestion card auto-dismiss when user is already authenticated
  • QR component — moved QR code generation off the main thread

New dependencies

  • paykit-rs (SPM, pinned revision) — Pubky SDK for profile, contacts, and payment operations
  • CoreBluetooth framework (linker flag, required by paykit-rs)

Key new files

File Purpose
Services/PubkyService.swift FFI bridge to paykit-ffi and bitkit-core
Managers/PubkyProfileManager.swift Auth state, session persistence, key derivation, profile CRUD
Managers/ContactsManager.swift Contact fetching, import, add/edit/delete, remote discovery
Models/PubkyProfile.swift Profile/contact data models with backward-compatible decoding
Models/PubkyAuthRequest.swift Auth request capability parsing
Components/PubkyImage.swift pubky:// image loader with disk+memory cache
Views/Profile/ProfileView.swift Full profile page (name, bio, links, tags, QR)
Views/Profile/CreateProfileView.swift Profile creation with restore detection
Views/Profile/EditProfileView.swift Profile editing
Views/Profile/PayContactsView.swift Pay Contacts onboarding step
Views/Profile/PubkyChoiceView.swift Create vs Ring import choice
Views/Profile/PubkyRingAuthView.swift Pubky Ring auth flow UI
Views/Profile/AddLinkSheet.swift Add link with suggestions
Views/Profile/AddProfileTagSheet.swift Add tag with suggestions
Views/Contacts/ContactsListView.swift Grouped, searchable contacts list
Views/Contacts/ContactDetailView.swift Contact detail with actions
Views/Contacts/ContactImportOverviewView.swift Import overview (profile + contacts summary)
Views/Contacts/ContactImportSelectView.swift Selectable contact list for import
Views/Contacts/AddContactView.swift Add contact by public key
Views/Contacts/EditContactView.swift Edit contact details
Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift Auth approval as authenticator
BitkitTests/PubkyModelTests.swift Tests for profile/contact models and data decoding
BitkitTests/PubkyProfileManagerTests.swift Tests for HomegateResponse decoding
BitkitTests/PubkyAuthRequestTests.swift Tests for capability parsing

Test plan

  • Fresh install: tap Profile in drawer → shows intro → continue → choice screen (Create / Import with Ring)
  • Create flow: Create Profile → enter name, pick avatar → Continue → Pay Contacts → Profile page
  • Create restore: If profile already exists for derived keys, name is pre-populated and title shows "Restore Profile"
  • Ring flow: Import with Ring → opens Pubky Ring → approve → returns to Bitkit → Import Overview (found profile & contacts) → Import All or Select → Pay Contacts → Profile page
  • Ring flow (no contacts): If no remote contacts found, skip import and go to Pay Contacts
  • Import select: Can select all, select none, or pick individual contacts; Continue with none selected skips import
  • Profile page shows name, bio, links, tags, QR code with profile picture overlay
  • Edit profile: tap edit → modify name/bio/links/tags → save → updated
  • Home header shows profile name and avatar after authentication
  • Kill and relaunch app → session restored, profile/contacts shown without re-auth
  • Kill and relaunch with no network → session preserved, toast shown, next launch with network restores
  • Sign out from profile page → returns to unauthenticated state, contacts cleared
  • Contacts list shows "My Profile" row, alphabetical sections, search, and add button
  • Add contact by public key → fetches profile → saves
  • Edit contact → modify details → save
  • Delete contact → confirmation → removed
  • Suggestion sheets for links and tags show lightbulb icon inside input field

🤖 Generated with Claude Code

@ovitrif ovitrif added this to the 2.2.0 milestone Mar 9, 2026
@ovitrif ovitrif changed the title Feat: add profile fetching from pubky feat: add profile fetching from pubky Mar 10, 2026
ovitrif
ovitrif previously approved these changes Mar 11, 2026
Copy link
Copy Markdown
Collaborator

@ovitrif ovitrif left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with some nit comments.

all tests worked great, tested:

  • open profile from topbar + drawer
  • 2x auth successes
  • button to download ring
  • share pk button(s)
  • signout button

@claude

This comment has been minimized.

image.draw(in: CGRect(origin: .zero, size: newSize))
}
// Compress as JPEG
return resized.jpegData(compressionQuality: 0.8) ?? Data()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: compressAvatar silently uploads empty data

If jpegData(compressionQuality:) returns nil (which can happen for certain image types or edge cases), the method falls back to Data() — an empty byte array — which is then uploaded to the homeserver with no error thrown.

image.draw(in: CGRect(origin: .zero, size: newSize))
}
// Compress as JPEG
return resized.jpegData(compressionQuality: 0.8) ?? Data()
}
private func avatarBlobPath() -> String {
let timestamp = Int(Date().timeIntervalSince1970 * 1000)

The empty blob URI is persisted in profile.json via writeProfile, and cached locally via cacheProfileMetadata. Every subsequent attempt to load the avatar will fail silently with a decoding error, leaving the user with a permanently broken avatar and no indication of what went wrong during upload.

Suggested fix — propagate the failure rather than swallowing it:

Suggested change
return resized.jpegData(compressionQuality: 0.8) ?? Data()
guard let jpegData = resized.jpegData(compressionQuality: 0.8) else {
throw NSError(domain: "PubkyProfileManager", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to encode avatar as JPEG"])
}
return jpegData

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

let contact = PubkyContact(publicKey: prefixedKey, profile: profile)
contacts.append(contact)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: addContact does not deduplicate before appending

The contact is appended unconditionally, so calling addContact twice with the same public key results in two entries in the contacts array. By contrast, importContacts at line 236–238 correctly guards against this with a Set-based filter.

Logger.info("Added contact \(prefixedKey)", context: "ContactsManager")
let contact = PubkyContact(publicKey: prefixedKey, profile: profile)
contacts.append(contact)
contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
// MARK: - Import Contacts (batch fetch from pubky.app, store to bitkit.to)

Downstream, updateContact (which uses firstIndex) would silently update only the first copy, leaving stale duplicates in the list.

Suggested fix:

Suggested change
contacts.append(contact)
guard !contacts.contains(where: { $0.publicKey == prefixedKey }) else { return }
let contact = PubkyContact(publicKey: prefixedKey, profile: profile)
contacts.append(contact)

}

@MainActor
class PubkyProfileManager: ObservableObject {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: Use @Observable class, not ObservableObject

Per CLAUDE.md:

"Use @Observable class for shared business logic instead of ViewModels… This project follows modern SwiftUI patterns and explicitly AVOIDS traditional MVVM with ViewModels."

Both PubkyProfileManager and ContactsManager use ObservableObject + @Published, and are injected in AppScene.swift via @StateObject / .environmentObject — all three are the legacy pattern this project is migrating away from.

The correct approach:

// Manager
@Observable
@MainActor
class PubkyProfileManager {
    var authState: PubkyAuthState = .idle
    var profile: PubkyProfile?
    // ... (no @Published needed)
}

// AppScene.swift
@State private var pubkyProfile = PubkyProfileManager()
// ...
.environment(pubkyProfile)

// Consuming views
@Environment(PubkyProfileManager.self) private var pubkyProfile

The same applies to ContactsManager.

// MARK: - ContactsManager

@MainActor
class ContactsManager: ObservableObject {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: Use @Observable class, not ObservableObject

Same issue as PubkyProfileManager — see the comment there for the full context and the prescribed migration pattern. ContactsManager should be converted to @Observable class with its properties losing the @Published wrapper, and its injection in AppScene.swift changed from @StateObject/.environmentObject to @State/.environment.

ben-kaufman and others added 2 commits April 6, 2026 14:09
- Guard against adding a contact that already exists in the list
- compressAvatar now throws instead of returning empty Data on failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ovitrif ovitrif modified the milestones: 2.2.0, 2.3.0 Apr 7, 2026
@piotr-iohk
Copy link
Copy Markdown
Collaborator

Some observations from intial testing:

  1. Cannot create profile: feat: add profile and contacts fetching from pubky bitkit-android#824 (comment)

  2. App Err - when no pubky ring (after Canceling on "Download").

Screen.Recording.2026-04-08.at.16.57.05.mov
  1. When importing profile from pubky-ring you can miss import contacts. Then, when you go to contacts you have "Failed to load contacts" Try again. Perhaps "Try again" could re-start the import flow?
Screen.Recording.2026-04-08.at.18.06.43.mov

Managed to import pubky with Ring:

  1. As far as I see we can edit profile (and contacts). Are those changes local, or are supposed to propagate to pubky.app? (guessing local)

  2. It feels weird that we can edit contact details (bio, link etc.) - what is the purpose? 🤔

  3. Delete contact from edit page redirects to this deleted contact profile page - and I can edit it. Probably should not be the case... I just deleted it. - whole operation lead to disconnection of the profile...

Screen.Recording.2026-04-08.at.17.29.53.mov

Copy link
Copy Markdown
Collaborator

@ovitrif ovitrif left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Pass 1: Testing profile & contacts

1. Getting inconsistent number of contacts imported

vs. Android and also vs. previous attempts (via disconnect + reimport)


2. Disconnect Profile instead of Delete Profile

Android shows Delete, Figma also.

Dialog texts are also different on Android, but neither of the 2 uses the texts from Figma.

2.1 Missing footnote
iOS doesn't have the 2-line text about profile info being public.

Image

3. Delete Button UI not updated to latest design

Added remarks on the image:

  • Contact Detail: Remove Contact icon button was dropped
  • Contact Edit: Button style mismatch

4. Profile/ Contacts Links should be editable

Currently they look like like a disabled input field, should behave like editable inputs.

Should update android PR with this as well:


5. Nit: Missing bottom gradient overlay above scrollview

In Edit Profile/ Contact


6. Delete Profile should navigate back to profile onboarding

Currently we have Disconnect, that should be changed, once we have Delete instead, we should navigate to profile onboarding instead of wallet home after profile removal completes.


7. Contacts Counter in avatar group needs polish

It doesn't render border and text is not fully visible.


Overall solid work, looks a bit more stable than Android at this point.
My biggest concern is the variation in number of imported contacts.

Android keeps importing 100, but even that 100 is probably capped incorrectly. This can be addressed afterwards too.

I will have a look at the code too, aiming to approve, based on agreements in Slack: we can address polishing and fixes in new PRs.

@piotr-iohk
Copy link
Copy Markdown
Collaborator

piotr-iohk commented Apr 10, 2026

Create Profile flow testing

  • Create Profile
  • Edit / Delete Profile
  • Add / Edit / Delete Contacts

1. Create profile

  • on creating screen android says "Deriving keys" - iOS doesn't (I think this screen is not in Figma, perhaps should be added - but deriving keys looks more informative)
  • "Continue" button is active on iOS - not active in Android (until you add name) - should be not active I believe.
Screen.Recording.2026-04-10.at.10.13.53.mov

2. Edit/Delete Profile

  • "Disconnect" - should not be there (by Figma)
Screenshot 2026-04-10 at 10 48 27
  • "Please note..." sentence is misplaced (by Figma) - also needs to be changed, per newest updates
Screen.Recording.2026-04-10.at.11.16.21.mov
  • "Delete profile" - says profile will be deleted permanently - but when you create profile again - it is the same pubky (not sure if that is expected).
Screen.Recording.2026-04-10.at.10.50.21.mov

3. Add contact

  • Empty contact list should show profile and "Add contact" button (Figma)
  • you can add yourself as contact - should not be possible. (both iOS and Android)
Screen.Recording.2026-04-10.at.11.34.19.mov
  • Adding new contact:
    • "Paste" pubky icon changes the text for me to some Chinese 🤔
    • also "Try again" does not do anything.
    • note: Input length looks unbounded; worth defining and enforcing a sensible maximum.
Screen.Recording.2026-04-10.at.11.48.28.mov

4. Edit / Delete contact

  • edit contact shows "Your Pubky" - should show "Pubky" (both iOS and Android)
Screenshot 2026-04-10 at 11 41 55
  • "Please note..." sentence needs to be updated.
  • Cannot edit / update avatar - probably should be possible.

ben-kaufman and others added 2 commits April 10, 2026 14:03
…iting

Adds PubkyPublicKeyFormat for consistent key validation/normalization,
prevents adding yourself as a contact, improves error states in add
contact flow, enables photo picker for contact avatars, and cleans up
profile view sign-out button placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mirrors the Android implementation (bitkit-android#824) to ensure
contacts are cleaned up on the homeserver before the profile is deleted,
preventing orphaned contact data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@piotr-iohk
Copy link
Copy Markdown
Collaborator

Additional observations (on 04d06fc):

1.

  • create wallet -> create profile -> make backup -> reset wallet -> restore wallet.

Expected:

  • profile should be there after restoration

Actual:

  • profile is "disconnected"

2. It would be great if we could have same behavior on pubky validation when adding contacts (currently differs on iOS - validation on pubky input vs Android - validation after taping add, on Android also subsequent Retry button does not work). It would make things consistent + easier for e2e tests. 🙏

Screen.Recording.2026-04-10.at.15.59.16.mov

3. Seems not able to add other contact that was created in Android. Was able to add contact using pubky from pubky-ring.

bitkit_logs_2026-04-10_14-05-50.zip

Screen.Recording.2026-04-10.at.16.00.30.mov

4. Contacts - shows "C" instead of CONTACTS

Screenshot 2026-04-10 at 15 34 32

@ben-kaufman
Copy link
Copy Markdown
Contributor Author

About last point, C is for the letter of the contact name, ie like in phone contacts list the subtitle is the first letter of all contacts in that name

@piotr-iohk
Copy link
Copy Markdown
Collaborator

piotr-iohk commented Apr 10, 2026

About last point, C is for the letter of the contact name, ie like in phone contacts list the subtitle is the first letter of all contacts in that name

Got it, still it is missing "Contacts" as per Figma.

Edit: I guess also the alphabetical section labels (C here) do not match Figma. 🤷‍♂️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants