diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj
index 8635c470b..2373b8b10 100644
--- a/Bitkit.xcodeproj/project.pbxproj
+++ b/Bitkit.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
+ 182817C12F59A7F10055A441 /* Paykit in Frameworks */ = {isa = PBXBuildFile; productRef = 182817C02F59A7F10055A441 /* Paykit */; };
18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; };
18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; };
4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; };
@@ -161,6 +162,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 182817C12F59A7F10055A441 /* Paykit in Frameworks */,
4AFCA3702E05933800205CAE /* Zip in Frameworks */,
968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */,
18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */,
@@ -273,6 +275,7 @@
4AFCA36F2E05933800205CAE /* Zip */,
4AAB08C92E1FE77600BA63DF /* Lottie */,
18D65DFF2EB964B500252335 /* VssRustClientFfi */,
+ 182817C02F59A7F10055A441 /* Paykit */,
);
productName = Bitkit;
productReference = 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */;
@@ -380,6 +383,7 @@
968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */,
4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */,
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */,
+ 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */,
);
productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */;
projectDirPath = "";
@@ -512,7 +516,11 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.1.0;
+MARKETING_VERSION = 2.1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ CoreBluetooth,
+ );
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -540,7 +548,11 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.1.0;
+MARKETING_VERSION = 2.1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ CoreBluetooth,
+ );
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -699,7 +711,11 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 2.1.0;
+MARKETING_VERSION = 2.1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ CoreBluetooth,
+ );
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -742,7 +758,11 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 2.1.0;
+MARKETING_VERSION = 2.1.0;
+ OTHER_LDFLAGS = (
+ "-framework",
+ CoreBluetooth,
+ );
PRODUCT_BUNDLE_IDENTIFIER = to.bitkit;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -891,6 +911,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
+ 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/pubky/paykit-rs";
+ requirement = {
+ kind = revision;
+ revision = cd1253291b1582759d569372d5942b8871527ea1;
+ };
+ };
18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi";
@@ -935,8 +963,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
- branch = master;
- kind = branch;
+ kind = revision;
+ revision = ea770caabdaf07f5fd196b6e1df3997ac497f25a;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
@@ -958,6 +986,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
+ 182817C02F59A7F10055A441 /* Paykit */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */;
+ productName = Paykit;
+ };
18D65DFF2EB964B500252335 /* VssRustClientFfi */ = {
isa = XCSwiftPackageProductDependency;
package = 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 e14c1eae2..c0c804dd6 100644
--- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,13 +1,12 @@
{
- "originHash" : "17302aed9b1f94b6f928fd7e06b45ebec95883553cab0849836d3e292cab01d7",
+ "originHash" : "d158db056599c21ce7702af0c74aa95296da8e9b08fcbc00728f449ce4872dde",
"pins" : [
{
"identity" : "bitkit-core",
"kind" : "remoteSourceControl",
"location" : "https://github.com/synonymdev/bitkit-core",
"state" : {
- "branch" : "master",
- "revision" : "76a63a2654f717accde5268905897b73e4f7d3c4"
+ "revision" : "ea770caabdaf07f5fd196b6e1df3997ac497f25a"
}
},
{
@@ -36,6 +35,14 @@
"version" : "4.5.2"
}
},
+ {
+ "identity" : "paykit-rs",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pubky/paykit-rs",
+ "state" : {
+ "revision" : "cd1253291b1582759d569372d5942b8871527ea1"
+ }
+ },
{
"identity" : "swift-secp256k1",
"kind" : "remoteSourceControl",
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 4b0268ce6..b14e1db1c 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -26,6 +26,8 @@ struct AppScene: View {
@StateObject private var transferTracking: TransferTrackingManager
@StateObject private var channelDetails = ChannelDetailsViewModel.shared
@StateObject private var migrations = MigrationsService.shared
+ @StateObject private var pubkyProfile = PubkyProfileManager()
+ @StateObject private var contactsManager = ContactsManager()
@State private var hideSplash = false
@State private var removeSplash = false
@@ -131,6 +133,15 @@ struct AppScene: View {
.environmentObject(tagManager)
.environmentObject(transferTracking)
.environmentObject(channelDetails)
+ .environmentObject(pubkyProfile)
+ .environmentObject(contactsManager)
+ .onChange(of: pubkyProfile.authState) { _, authState in
+ if authState == .authenticated, let pk = pubkyProfile.publicKey {
+ Task { try? await contactsManager.loadContacts(for: pk) }
+ } else if authState == .idle {
+ contactsManager.reset()
+ }
+ }
.onAppear {
if !settings.pinEnabled {
isPinVerified = true
@@ -388,6 +399,10 @@ struct AppScene: View {
@Sendable
private func setupTask() async {
+ // Start Pubky/Paykit initialization early so PKDNS bootstrapping
+ // runs concurrently with wallet setup instead of sequentially after it.
+ Task { await pubkyProfile.initialize() }
+
do {
// Handle orphaned keychain before anything else
handleOrphanedKeychain()
diff --git a/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/Contents.json
new file mode 100644
index 000000000..177edf340
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "pubky-ring-logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "pubky-ring-logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "pubky-ring-logo@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo.png b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo.png
new file mode 100644
index 000000000..eff5dddc8
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo@2x.png b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo@2x.png
new file mode 100644
index 000000000..135508e94
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo@2x.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo@3x.png b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo@3x.png
new file mode 100644
index 000000000..86109b892
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/pubky-ring-logo.imageset/pubky-ring-logo@3x.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/Contents.json
new file mode 100644
index 000000000..cd5fb6f60
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "tag-pubky.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "tag-pubky@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "tag-pubky@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky.png b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky.png
new file mode 100644
index 000000000..187c8d435
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky@2x.png b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky@2x.png
new file mode 100644
index 000000000..67ff0c029
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky@2x.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky@3x.png b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky@3x.png
new file mode 100644
index 000000000..4003777bf
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/tag-pubky.imageset/tag-pubky@3x.png differ
diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift
index ed534c602..91e550bfd 100644
--- a/Bitkit/Components/Header.swift
+++ b/Bitkit/Components/Header.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct Header: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
/// When true, shows the widget edit button (only on the widgets tab).
var showWidgetEditButton: Bool = false
@@ -16,23 +17,7 @@ struct Header: View {
var body: some View {
HStack(alignment: .center, spacing: 0) {
- // Button {
- // if app.hasSeenProfileIntro {
- // navigation.navigate(.profile)
- // } else {
- // navigation.navigate(.profileIntro)
- // }
- // } label: {
- // HStack(alignment: .center, spacing: 16) {
- // Image(systemName: "person.circle.fill")
- // .resizable()
- // .font(.title2)
- // .foregroundColor(.gray1)
- // .frame(width: 32, height: 32)
-
- // TitleText(t("slashtags__your_name_capital"))
- // }
- // }
+ profileButton
Spacer()
@@ -79,4 +64,47 @@ struct Header: View {
.padding(.leading, 16)
.padding(.trailing, 10)
}
+
+ @ViewBuilder
+ private var profileButton: some View {
+ Button {
+ if pubkyProfile.isAuthenticated {
+ navigation.navigate(.profile)
+ } else if app.hasSeenProfileIntro {
+ navigation.navigate(.pubkyRingAuth)
+ } else {
+ navigation.navigate(.profileIntro)
+ }
+ } label: {
+ HStack(alignment: .center, spacing: 16) {
+ profileAvatar
+
+ if let name = pubkyProfile.displayName {
+ TitleText(name)
+ } else {
+ TitleText(t("slashtags__your_name_capital"))
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .accessibilityLabel(pubkyProfile.displayName ?? t("profile__nav_title"))
+ }
+
+ @ViewBuilder
+ private var profileAvatar: some View {
+ if let imageUri = pubkyProfile.displayImageUri {
+ PubkyImage(uri: imageUri, size: 32)
+ } else {
+ Circle()
+ .fill(Color.gray4)
+ .frame(width: 32, height: 32)
+ .overlay {
+ Image("user-square")
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.white32)
+ .frame(width: 16, height: 16)
+ }
+ }
+ }
}
diff --git a/Bitkit/Components/Home/Suggestions.swift b/Bitkit/Components/Home/Suggestions.swift
index ece52f8ad..9fa6e5a76 100644
--- a/Bitkit/Components/Home/Suggestions.swift
+++ b/Bitkit/Components/Home/Suggestions.swift
@@ -170,6 +170,7 @@ struct Suggestions: View {
@EnvironmentObject var settings: SettingsViewModel
@EnvironmentObject var suggestionsManager: SuggestionsManager
@EnvironmentObject var wallet: WalletViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
@State private var showShareSheet = false
@@ -181,6 +182,7 @@ struct Suggestions: View {
app: AppViewModel,
settings: SettingsViewModel,
suggestionsManager: SuggestionsManager,
+ pubkyProfile: PubkyProfileManager? = nil,
isPreview: Bool = false
) -> [SuggestionCardData] {
if isPreview {
@@ -197,7 +199,7 @@ struct Suggestions: View {
var result: [SuggestionCardData] = []
for id in orderedIds {
guard let card = cardsById[id] else { continue }
- if isCardCompleted(card, app: app, settings: settings) { continue }
+ if isCardCompleted(card, app: app, settings: settings, pubkyProfile: pubkyProfile) { continue }
if suggestionsManager.isDismissed(card.id) { continue }
result.append(card)
if result.count >= 4 { break }
@@ -206,10 +208,13 @@ struct Suggestions: View {
}
/// Whether the user has completed this suggestion (e.g. backup verified, pin enabled, notifications on).
- private static func isCardCompleted(_ card: SuggestionCardData, app: AppViewModel, settings: SettingsViewModel) -> Bool {
+ private static func isCardCompleted(_ card: SuggestionCardData, app: AppViewModel, settings: SettingsViewModel,
+ pubkyProfile: PubkyProfileManager? = nil) -> Bool
+ {
switch card.action {
case .backup: return app.backupVerified
case .notifications: return settings.enableNotifications
+ case .profile: return pubkyProfile?.isAuthenticated ?? false
case .quickpay: return settings.enableQuickpay
case .secure: return settings.pinEnabled
default: return false
@@ -218,7 +223,14 @@ struct Suggestions: View {
/// Cards to display in this view; delegates to the static visibleCards (same logic as the widget list filter).
private var visibleCards: [SuggestionCardData] {
- Self.visibleCards(wallet: wallet, app: app, settings: settings, suggestionsManager: suggestionsManager, isPreview: isPreview)
+ Self.visibleCards(
+ wallet: wallet,
+ app: app,
+ settings: settings,
+ suggestionsManager: suggestionsManager,
+ pubkyProfile: pubkyProfile,
+ isPreview: isPreview
+ )
}
var body: some View {
@@ -265,7 +277,13 @@ struct Suggestions: View {
case .invite:
showShareSheet = true
case .profile:
- route = app.hasSeenProfileIntro ? .profile : .profileIntro
+ if pubkyProfile.isAuthenticated {
+ route = .profile
+ } else if app.hasSeenProfileIntro {
+ route = .pubkyRingAuth
+ } else {
+ route = .profileIntro
+ }
case .quickpay:
route = app.hasSeenQuickpayIntro ? .quickpay : .quickpayIntro
case .notifications:
@@ -293,3 +311,17 @@ struct Suggestions: View {
}
}
}
+
+#Preview {
+ VStack {
+ Suggestions()
+ }
+ .environmentObject(AppViewModel())
+ .environmentObject(NavigationViewModel())
+ .environmentObject(SheetViewModel())
+ .environmentObject(SettingsViewModel.shared)
+ .environmentObject(SuggestionsManager())
+ .environmentObject(WalletViewModel())
+ .environmentObject(PubkyProfileManager())
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift
new file mode 100644
index 000000000..214590882
--- /dev/null
+++ b/Bitkit/Components/PubkyImage.swift
@@ -0,0 +1,200 @@
+import CryptoKit
+import SwiftUI
+
+/// Loads and displays an image from a `pubky://` URI using BitkitCore's PKDNS resolver.
+/// Handles the Pubky file indirection: the URI may point to a JSON metadata object
+/// with a `src` field containing the actual blob URI.
+struct PubkyImage: View {
+ let uri: String
+ let size: CGFloat
+
+ @State private var uiImage: UIImage?
+ @State private var hasFailed = false
+
+ var body: some View {
+ Group {
+ if let uiImage {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ } else if hasFailed {
+ placeholder
+ } else {
+ ProgressView()
+ }
+ }
+ .frame(width: size, height: size)
+ .clipShape(Circle())
+ .accessibilityLabel(Text("Profile photo"))
+ .task(id: uri) {
+ await loadImage()
+ }
+ }
+
+ @ViewBuilder
+ private var placeholder: some View {
+ Circle()
+ .fill(Color.gray5)
+ .overlay {
+ Image("user-square")
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.white32)
+ .frame(width: size / 2, height: size / 2)
+ }
+ }
+
+ private func loadImage() async {
+ hasFailed = false
+
+ if let memoryHit = PubkyImageCache.shared.memoryImage(for: uri) {
+ uiImage = memoryHit
+ return
+ }
+
+ uiImage = nil
+
+ do {
+ let image = try await Task.detached {
+ try await Self.loadImageOffMain(uri: uri)
+ }.value
+ uiImage = image
+ } catch {
+ Logger.error("Failed to load pubky image: \(error)", context: "PubkyImage")
+ hasFailed = true
+ }
+ }
+
+ /// All heavy work (disk cache, network/FFI) runs off the main actor.
+ private nonisolated static func loadImageOffMain(uri: String) async throws -> UIImage {
+ if let cached = await PubkyImageCache.shared.image(for: uri) {
+ return cached
+ }
+
+ let data = try await PubkyService.fetchFile(uri: uri)
+ let blobData = try await resolveImageData(data, originalUri: uri)
+
+ guard let image = UIImage(data: blobData) else {
+ throw PubkyImageError.decodingFailed(blobData.count)
+ }
+
+ PubkyImageCache.shared.store(image, data: blobData, for: uri)
+ return image
+ }
+
+ private nonisolated static func resolveImageData(_ data: Data, originalUri: String) async throws -> Data {
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let src = json["src"] as? String,
+ src.hasPrefix("pubky://")
+ else {
+ return data
+ }
+
+ let originalPubkey = originalUri.dropFirst("pubky://".count).prefix(while: { $0 != "/" })
+ let srcPubkey = src.dropFirst("pubky://".count).prefix(while: { $0 != "/" })
+ guard !originalPubkey.isEmpty, originalPubkey == srcPubkey else {
+ Logger.warn("Rejected cross-user src redirect: \(src)", context: "PubkyImage")
+ throw PubkyImageError.crossUserRedirect
+ }
+
+ Logger.debug("File descriptor found, fetching blob from: \(src)", context: "PubkyImage")
+ return try await PubkyService.fetchFile(uri: src)
+ }
+}
+
+private enum PubkyImageError: LocalizedError {
+ case decodingFailed(Int)
+ case crossUserRedirect
+
+ var errorDescription: String? {
+ switch self {
+ case let .decodingFailed(bytes):
+ return "Could not decode image blob (\(bytes) bytes)"
+ case .crossUserRedirect:
+ return "Image descriptor references a different user's namespace"
+ }
+ }
+}
+
+/// Two-tier cache (memory + disk) so profile images persist across app launches
+/// and multiple PubkyImage views with the same URI don't re-fetch.
+final class PubkyImageCache: @unchecked Sendable {
+ static let shared = PubkyImageCache()
+
+ private var memoryCache: [String: UIImage] = [:]
+ private let lock = NSLock()
+ private let diskQueue = DispatchQueue(label: "pubky-image-cache-disk", qos: .utility)
+ private let diskDirectory: URL
+
+ private init() {
+ let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
+ diskDirectory = caches.appendingPathComponent("pubky-images", isDirectory: true)
+ try? FileManager.default.createDirectory(at: diskDirectory, withIntermediateDirectories: true)
+ }
+
+ /// Fast memory-only check — never blocks behind disk I/O, safe from the main thread.
+ func memoryImage(for uri: String) -> UIImage? {
+ lock.lock()
+ defer { lock.unlock() }
+ return memoryCache[uri]
+ }
+
+ /// Full lookup (memory + disk). Disk I/O runs on a dedicated queue to avoid blocking cooperative threads.
+ func image(for uri: String) async -> UIImage? {
+ lock.lock()
+ if let memoryHit = memoryCache[uri] {
+ lock.unlock()
+ return memoryHit
+ }
+ lock.unlock()
+
+ return await withCheckedContinuation { continuation in
+ diskQueue.async { [self] in
+ let path = diskPath(for: uri)
+ guard let diskData = try? Data(contentsOf: path),
+ let diskImage = UIImage(data: diskData)
+ else {
+ continuation.resume(returning: nil)
+ return
+ }
+
+ lock.lock()
+ memoryCache[uri] = diskImage
+ lock.unlock()
+ continuation.resume(returning: diskImage)
+ }
+ }
+ }
+
+ func store(_ image: UIImage, data: Data, for uri: String) {
+ lock.lock()
+ memoryCache[uri] = image
+ lock.unlock()
+
+ diskQueue.async { [diskDirectory] in
+ let hash = Self.diskHash(for: uri)
+ let path = diskDirectory.appendingPathComponent(hash)
+ try? data.write(to: path, options: .atomic)
+ }
+ }
+
+ func clear() {
+ lock.lock()
+ memoryCache.removeAll()
+ lock.unlock()
+
+ diskQueue.async { [diskDirectory] in
+ try? FileManager.default.removeItem(at: diskDirectory)
+ try? FileManager.default.createDirectory(at: diskDirectory, withIntermediateDirectories: true)
+ }
+ }
+
+ private static func diskHash(for uri: String) -> String {
+ let data = Data(uri.utf8)
+ return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
+ }
+
+ private func diskPath(for uri: String) -> URL {
+ diskDirectory.appendingPathComponent(Self.diskHash(for: uri))
+ }
+}
diff --git a/Bitkit/Components/QR.swift b/Bitkit/Components/QR.swift
index 7e99ac481..77e5727eb 100644
--- a/Bitkit/Components/QR.swift
+++ b/Bitkit/Components/QR.swift
@@ -6,7 +6,6 @@ struct QR: View {
var imageAsset: String?
@State private var cachedImage: UIImage?
- @State private var cachedContent: String = ""
var onPressed: (() -> Void)?
private let context = CIContext()
@@ -14,14 +13,21 @@ struct QR: View {
var body: some View {
ZStack {
- Image(uiImage: cachedImage ?? generateQRCode(from: content))
- .interpolation(.none)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: .infinity)
- .padding(8)
- .background(Color.white)
- .cornerRadius(8)
+ if let cachedImage {
+ Image(uiImage: cachedImage)
+ .interpolation(.none)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ .padding(8)
+ .background(Color.white)
+ .cornerRadius(8)
+ } else {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.white)
+ .aspectRatio(1, contentMode: .fit)
+ .frame(maxWidth: .infinity)
+ }
if let imageAsset {
ZStack {
@@ -41,17 +47,8 @@ struct QR: View {
onPressed()
}
}
- .onAppear {
- // Generate initial QR code
- if cachedImage == nil {
- cachedContent = content
- cachedImage = generateQRCode(from: content)
- }
- }
- .onChange(of: content) { newContent in
- // Regenerate when content changes
- cachedContent = newContent
- cachedImage = generateQRCode(from: newContent)
+ .task(id: content) {
+ cachedImage = generateQRCode(from: content)
}
.accessibilityElement(children: .ignore)
.accessibilityIdentifier("QRCode")
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
index 61354937d..d6e8be274 100644
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -258,6 +258,16 @@ enum Env {
}
}
+ /// Pubky/Paykit capabilities — production for mainnet, staging for regtest/testnet/signet.
+ static var pubkyCapabilities: String {
+ switch network {
+ case .bitcoin:
+ return "/pub/paykit.app/v0/:rw,/pub/pubky.app/profile.json:rw,/pub/pubky.app/follows/:rw"
+ default:
+ return "/pub/staging.paykit.app/v0/:rw,/pub/staging.pubky.app/profile.json:rw,/pub/staging.pubky.app/follows/:rw"
+ }
+ }
+
static var blockExplorerUrl: String {
switch network {
case .bitcoin: "https://mempool.space"
diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist
index 235c8d561..2010f2804 100644
--- a/Bitkit/Info.plist
+++ b/Bitkit/Info.plist
@@ -23,6 +23,17 @@
$(E2E_BACKEND)
E2E_NETWORK
$(E2E_NETWORK)
+ LSApplicationQueriesSchemes
+
+ pubkyauth
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsLocalNetworking
+
+
NSFaceIDUsageDescription
Bitkit uses Face ID to securely authenticate access to your wallet and protect your Bitcoin.
UIAppFonts
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index f3f5725e6..c6eb58c64 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -2,11 +2,13 @@ import SwiftUI
struct MainNavView: View {
@EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
@EnvironmentObject private var currency: CurrencyViewModel
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var notificationManager: PushNotificationManager
- @EnvironmentObject private var sheets: SheetViewModel
+ @EnvironmentObject private var pubkyProfile: PubkyProfileManager
@EnvironmentObject private var settings: SettingsViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
@EnvironmentObject private var wallet: WalletViewModel
@Environment(\.scenePhase) var scenePhase
@@ -266,7 +268,16 @@ struct MainNavView: View {
}
}
- // MARK: - Computed Properties for Better Organization
+ // MARK: - Loading View
+
+ private var pubkyLoadingView: some View {
+ VStack {
+ Spacer()
+ ActivityIndicator()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
private var navigationContent: some View {
HomeScreen()
@@ -302,10 +313,34 @@ struct MainNavView: View {
case .savingsProgress: SavingsProgressView()
// Profile & Contacts
- case .contacts: ComingSoonScreen()
- case .contactsIntro: ComingSoonScreen()
- case .profile: ComingSoonScreen()
- case .profileIntro: ComingSoonScreen()
+ case .contacts:
+ if app.hasSeenContactsIntro {
+ if !pubkyProfile.isInitialized {
+ pubkyLoadingView
+ } else if pubkyProfile.isAuthenticated {
+ ContactsListView()
+ } else if app.hasSeenProfileIntro {
+ PubkyRingAuthView()
+ } else {
+ ProfileIntroView()
+ }
+ } else {
+ ContactsIntroView()
+ }
+ case .contactsIntro: ContactsIntroView()
+ case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey)
+ case .profile:
+ if !pubkyProfile.isInitialized {
+ pubkyLoadingView
+ } else if pubkyProfile.isAuthenticated {
+ ProfileView()
+ } else if app.hasSeenProfileIntro {
+ PubkyRingAuthView()
+ } else {
+ ProfileIntroView()
+ }
+ case .profileIntro: ProfileIntroView()
+ case .pubkyRingAuth: PubkyRingAuthView()
// Shop
case .shopIntro: ShopIntro()
diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift
new file mode 100644
index 000000000..d13a6bccb
--- /dev/null
+++ b/Bitkit/Managers/ContactsManager.swift
@@ -0,0 +1,121 @@
+import Foundation
+import SwiftUI
+
+private let pubkyPrefix = "pubky"
+
+private func ensurePubkyPrefix(_ key: String) -> String {
+ key.hasPrefix(pubkyPrefix) ? key : "\(pubkyPrefix)\(key)"
+}
+
+struct PubkyContact: Identifiable, Hashable, Sendable {
+ let id: String
+ let publicKey: String
+ let profile: PubkyProfile
+
+ static func == (lhs: PubkyContact, rhs: PubkyContact) -> Bool {
+ lhs.publicKey == rhs.publicKey
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(publicKey)
+ }
+
+ var displayName: String {
+ profile.name
+ }
+
+ var sortLetter: String {
+ let firstChar = displayName.first.map { String($0).uppercased() } ?? "#"
+ return firstChar.first?.isLetter == true ? firstChar : "#"
+ }
+
+ init(publicKey: String, profile: PubkyProfile) {
+ id = publicKey
+ self.publicKey = publicKey
+ self.profile = profile
+ }
+}
+
+struct ContactSection: Identifiable {
+ let id: String
+ let letter: String
+ let contacts: [PubkyContact]
+}
+
+@MainActor
+class ContactsManager: ObservableObject {
+ @Published var contacts: [PubkyContact] = []
+ @Published var isLoading = false
+ @Published var hasLoaded = false
+
+ var groupedContacts: [ContactSection] {
+ let grouped = Dictionary(grouping: contacts) { $0.sortLetter }
+ return grouped.keys.sorted().map { letter in
+ ContactSection(id: letter, letter: letter, contacts: grouped[letter] ?? [])
+ }
+ }
+
+ func reset() {
+ contacts = []
+ isLoading = false
+ hasLoaded = false
+ }
+
+ func loadContacts(for publicKey: String) async throws {
+ guard !isLoading else { return }
+
+ isLoading = true
+ defer {
+ isLoading = false
+ hasLoaded = true
+ }
+
+ do {
+ let contactKeys = try await Task.detached {
+ try await PubkyService.getContacts(publicKey: publicKey)
+ }.value
+
+ Logger.debug("Fetched \(contactKeys.count) contact keys", context: "ContactsManager")
+
+ let loaded: [PubkyContact] = await withTaskGroup(of: PubkyContact.self) { group in
+ for key in contactKeys {
+ let prefixedKey = ensurePubkyPrefix(key)
+ group.addTask {
+ let profile: PubkyProfile
+ do {
+ let dto = try await PubkyService.getProfile(publicKey: prefixedKey)
+ profile = PubkyProfile(publicKey: prefixedKey, ffiProfile: dto)
+ } catch {
+ Logger.warn("Failed to load contact profile '\(prefixedKey)': \(error)", context: "ContactsManager")
+ profile = PubkyProfile.placeholder(publicKey: prefixedKey)
+ }
+ return PubkyContact(publicKey: prefixedKey, profile: profile)
+ }
+ }
+ var results: [PubkyContact] = []
+ for await contact in group {
+ results.append(contact)
+ }
+ return results
+ }
+
+ contacts = loaded.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
+ } catch {
+ Logger.error("Failed to load contacts: \(error)", context: "ContactsManager")
+ throw error
+ }
+ }
+
+ func fetchContactProfile(publicKey: String) async -> PubkyProfile? {
+ let prefixedKey = ensurePubkyPrefix(publicKey)
+ do {
+ let dto = try await Task.detached {
+ try await PubkyService.getProfile(publicKey: prefixedKey)
+ }.value
+ return PubkyProfile(publicKey: prefixedKey, ffiProfile: dto)
+ } catch {
+ Logger.error("Failed to fetch contact profile: \(error)", context: "ContactsManager")
+ return nil
+ }
+ }
+}
diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift
new file mode 100644
index 000000000..c0f6a1c8e
--- /dev/null
+++ b/Bitkit/Managers/PubkyProfileManager.swift
@@ -0,0 +1,231 @@
+import Foundation
+import SwiftUI
+
+enum PubkyAuthState: Equatable {
+ case idle
+ case authenticating
+ case authenticated
+ case error(String)
+}
+
+@MainActor
+class PubkyProfileManager: ObservableObject {
+ @Published var authState: PubkyAuthState = .idle
+ @Published var profile: PubkyProfile?
+ @Published var publicKey: String?
+ @Published var isLoadingProfile = false
+ @Published var isInitialized = false
+ @Published private(set) var cachedName: String?
+ @Published private(set) var cachedImageUri: String?
+
+ init() {
+ cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey)
+ cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey)
+ }
+
+ // MARK: - Initialization & Session Restoration
+
+ private enum InitResult: Sendable {
+ case noSession
+ case restored(publicKey: String)
+ case restorationFailed
+ }
+
+ /// Initializes Paykit and restores any persisted session in a single off-main-actor pass.
+ func initialize() async {
+ let result: InitResult
+ do {
+ result = try await Task.detached {
+ try await PubkyService.initialize()
+
+ guard let savedSecret = try? Keychain.loadString(key: .paykitSession),
+ !savedSecret.isEmpty
+ else {
+ return InitResult.noSession
+ }
+
+ do {
+ let pk = try await PubkyService.importSession(secret: savedSecret)
+ return InitResult.restored(publicKey: pk)
+ } catch {
+ Logger.warn("Failed to restore paykit session: \(error)", context: "PubkyProfileManager")
+ try? Keychain.delete(key: .paykitSession)
+ return InitResult.restorationFailed
+ }
+ }.value
+ } catch {
+ Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager")
+ return
+ }
+
+ isInitialized = true
+
+ switch result {
+ case .noSession:
+ Logger.debug("No saved paykit session found", context: "PubkyProfileManager")
+ case let .restored(pk):
+ publicKey = pk
+ authState = .authenticated
+ Logger.info("Paykit session restored for \(pk)", context: "PubkyProfileManager")
+ Task { await loadProfile() }
+ case .restorationFailed:
+ clearCachedProfileMetadata()
+ }
+ }
+
+ // MARK: - Auth Flow
+
+ func cancelAuthentication() async {
+ do {
+ try await Task.detached {
+ try await PubkyService.cancelAuth()
+ }.value
+ authState = .idle
+ } catch {
+ authState = .idle
+ Logger.warn("Cancel auth failed: \(error)", context: "PubkyProfileManager")
+ }
+ }
+
+ func startAuthentication() async throws {
+ authState = .authenticating
+
+ let authUrl: String
+ do {
+ authUrl = try await Task.detached {
+ try await PubkyService.startAuth()
+ }.value
+ } catch {
+ authState = .idle
+ throw error
+ }
+
+ guard let url = URL(string: authUrl) else {
+ authState = .idle
+ throw PubkyServiceError.invalidAuthUrl
+ }
+
+ let canOpen = UIApplication.shared.canOpenURL(url)
+ guard canOpen else {
+ authState = .idle
+ throw PubkyServiceError.ringNotInstalled
+ }
+
+ await UIApplication.shared.open(url)
+ }
+
+ /// Long-polls the relay, persists + imports the session, and loads the profile in a single off-main-actor pass.
+ func completeAuthentication() async throws {
+ do {
+ let pk = try await Task.detached {
+ let sessionSecret = try await PubkyService.completeAuth()
+ let pk = try await PubkyService.importSession(secret: sessionSecret)
+
+ guard let data = sessionSecret.data(using: .utf8) else {
+ await PubkyService.forceSignOut()
+ throw PubkyServiceError.authFailed("Failed to encode session secret")
+ }
+
+ do {
+ try Keychain.upsert(key: .paykitSession, data: data)
+ } catch {
+ await PubkyService.forceSignOut()
+ throw error
+ }
+
+ return pk
+ }.value
+
+ publicKey = pk
+ authState = .authenticated
+ Logger.info("Pubky auth completed for \(pk)", context: "PubkyProfileManager")
+ Task { await loadProfile() }
+ } catch let serviceError as PubkyServiceError {
+ authState = .idle
+ throw serviceError
+ } catch {
+ authState = .error(error.localizedDescription)
+ throw error
+ }
+ }
+
+ // MARK: - Profile
+
+ func loadProfile() async {
+ guard let pk = publicKey, !isLoadingProfile else { return }
+
+ isLoadingProfile = true
+
+ do {
+ let loadedProfile = try await Task.detached {
+ let profileDto = try await PubkyService.getProfile(publicKey: pk)
+ Logger.debug("Profile loaded — name: \(profileDto.name), image: \(profileDto.image ?? "nil")", context: "PubkyProfileManager")
+ return PubkyProfile(publicKey: pk, ffiProfile: profileDto)
+ }.value
+ profile = loadedProfile
+ cacheProfileMetadata(loadedProfile)
+ } catch {
+ Logger.error("Failed to load profile: \(error)", context: "PubkyProfileManager")
+ }
+
+ isLoadingProfile = false
+ }
+
+ // MARK: - Sign Out
+
+ func signOut() async {
+ let nameKey = Self.cachedNameKey
+ let imageKey = Self.cachedImageUriKey
+ await Task.detached {
+ do {
+ try await PubkyService.signOut()
+ } catch {
+ Logger.warn("Server sign out failed, forcing local sign out: \(error)", context: "PubkyProfileManager")
+ await PubkyService.forceSignOut()
+ }
+ try? Keychain.delete(key: .paykitSession)
+ PubkyImageCache.shared.clear()
+ UserDefaults.standard.removeObject(forKey: nameKey)
+ UserDefaults.standard.removeObject(forKey: imageKey)
+ }.value
+
+ cachedName = nil
+ cachedImageUri = nil
+ publicKey = nil
+ profile = nil
+ authState = .idle
+ }
+
+ // MARK: - Cached Profile Metadata
+
+ private static let cachedNameKey = "pubky_profile_name"
+ private static let cachedImageUriKey = "pubky_profile_image_uri"
+
+ var displayName: String? {
+ profile?.name ?? cachedName
+ }
+
+ var displayImageUri: String? {
+ profile?.imageUrl ?? cachedImageUri
+ }
+
+ private func cacheProfileMetadata(_ profile: PubkyProfile) {
+ cachedName = profile.name
+ cachedImageUri = profile.imageUrl
+ UserDefaults.standard.set(profile.name, forKey: Self.cachedNameKey)
+ UserDefaults.standard.set(profile.imageUrl, forKey: Self.cachedImageUriKey)
+ }
+
+ private func clearCachedProfileMetadata() {
+ cachedName = nil
+ cachedImageUri = nil
+ UserDefaults.standard.removeObject(forKey: Self.cachedNameKey)
+ UserDefaults.standard.removeObject(forKey: Self.cachedImageUriKey)
+ }
+
+ // MARK: - Helpers
+
+ var isAuthenticated: Bool {
+ authState == .authenticated
+ }
+}
diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift
new file mode 100644
index 000000000..2f97afd60
--- /dev/null
+++ b/Bitkit/Models/PubkyProfile.swift
@@ -0,0 +1,63 @@
+import BitkitCore
+import Foundation
+
+struct PubkyProfileLink: Identifiable, Sendable {
+ let id = UUID()
+ let label: String
+ let url: String
+}
+
+struct PubkyProfile: Sendable {
+ let publicKey: String
+ let name: String
+ let bio: String
+ let imageUrl: String?
+ let links: [PubkyProfileLink]
+ let status: String?
+
+ var truncatedPublicKey: String {
+ Self.truncate(publicKey)
+ }
+
+ init(publicKey: String, ffiProfile: BitkitCore.PubkyProfile) {
+ self.publicKey = publicKey
+ name = ffiProfile.name
+ bio = ffiProfile.bio ?? ""
+ status = ffiProfile.status
+
+ imageUrl = ffiProfile.image
+
+ if let ffiLinks = ffiProfile.links {
+ links = ffiLinks.map { link in
+ PubkyProfileLink(label: link.title, url: link.url)
+ }
+ } else {
+ links = []
+ }
+ }
+
+ init(publicKey: String, name: String, bio: String, imageUrl: String?, links: [PubkyProfileLink], status: String?) {
+ self.publicKey = publicKey
+ self.name = name
+ self.bio = bio
+ self.imageUrl = imageUrl
+ self.links = links
+ self.status = status
+ }
+
+ static func placeholder(publicKey: String) -> PubkyProfile {
+ PubkyProfile(
+ publicKey: publicKey,
+ name: PubkyProfile.truncate(publicKey),
+ bio: "",
+ imageUrl: nil,
+ links: [],
+ status: nil
+ )
+ }
+
+ private static func truncate(_ key: String) -> String {
+ guard key.count > 10 else { return key }
+ return "\(key.prefix(4))...\(key.suffix(4))"
+ }
+}
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index a508858dc..ce7299a27 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -925,6 +925,15 @@
"slashtags__onboarding_header" = "Dynamic\ncontacts";
"slashtags__onboarding_text" = "Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles.";
"slashtags__onboarding_button" = "Add First Contact";
+"contacts__detail_title" = "Contact";
+"contacts__detail_empty_state" = "Unable to load contact.";
+"contacts__empty_state" = "You don't have any contacts yet.";
+"contacts__intro_description" = "Get automatic updates from contacts, pay them, and follow their public profiles.";
+"contacts__intro_title" = "Dynamic\ncontacts";
+"contacts__my_profile" = "MY PROFILE";
+"contacts__error_loading" = "Failed to load contacts";
+"contacts__nav_title" = "Contacts";
+"contacts__qr_scan_label" = "Scan to add {name}";
"slashtags__onboarding_profile1_header" = "Own your\nprofile";
"slashtags__onboarding_profile1_text" = "Set up your public profile and links, so your Bitkit contacts can reach you or pay you anytime, anywhere.";
"slashtags__onboarding_profile2_header" = "Pay Bitkit\ncontacts";
@@ -960,6 +969,23 @@
"slashtags__error_pay_empty_msg" = "The contact you're trying to send to hasn't enabled payments.";
"slashtags__auth_depricated_title" = "Deprecated";
"slashtags__auth_depricated_msg" = "Slashauth is deprecated. Please use Bitkit Beta.";
+"profile__nav_title" = "Profile";
+"profile__intro_title" = "Portable\npubky\nprofile";
+"profile__intro_description" = "Set up your portable pubky profile, so your contacts can reach you or pay you anytime, anywhere in the ecosystem.";
+"profile__ring_auth_title" = "Join the\npubky web";
+"profile__ring_auth_description" = "Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web.";
+"profile__ring_download" = "Download";
+"profile__ring_authorize" = "Authorize";
+"profile__ring_not_installed_title" = "Pubky Ring Not Installed";
+"profile__ring_not_installed_description" = "Pubky Ring is required to authorize your profile. Would you like to download it?";
+"profile__auth_error_title" = "Authorization Failed";
+"profile__qr_scan_label" = "Scan to add {name}";
+"profile__empty_state" = "Unable to load your profile.";
+"profile__retry_load" = "Try Again";
+"profile__sign_out" = "Disconnect";
+"profile__sign_out_title" = "Disconnect Profile";
+"profile__sign_out_description" = "This will disconnect your Pubky profile from Bitkit. You can reconnect at any time.";
+"profile__ring_waiting" = "Waiting for authorization from Pubky Ring…";
"wallet__drawer__wallet" = "Wallet";
"wallet__drawer__activity" = "Activity";
"wallet__drawer__contacts" = "Contacts";
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
new file mode 100644
index 000000000..deadcd8ab
--- /dev/null
+++ b/Bitkit/Services/PubkyService.swift
@@ -0,0 +1,151 @@
+import BitkitCore
+import Foundation
+import Paykit
+
+enum PubkyServiceError: LocalizedError {
+ case invalidAuthUrl
+ case ringNotInstalled
+ case sessionNotActive
+ case authFailed(String)
+ case profileNotFound
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidAuthUrl:
+ return "Failed to generate auth URL"
+ case .ringNotInstalled:
+ return "Pubky Ring is not installed"
+ case .sessionNotActive:
+ return "No active Pubky session"
+ case let .authFailed(reason):
+ return "Authentication failed: \(reason)"
+ case .profileNotFound:
+ return "Profile not found"
+ }
+ }
+}
+
+/// Service layer wrapping BitkitCore (auth) and PaykitFFI (profile/contacts/payments).
+enum PubkyService {
+ static func initialize() async throws {
+ try await ServiceQueue.background(.core) {
+ try await paykitInitialize()
+ }
+ }
+
+ // MARK: - Session Management
+
+ /// Import a session secret into paykit and return the public key.
+ static func importSession(secret: String) async throws -> String {
+ try await ServiceQueue.background(.core) {
+ try await paykitImportSession(sessionSecret: secret)
+ }
+ }
+
+ static func exportSession() async throws -> String {
+ try await ServiceQueue.background(.core) {
+ try await paykitExportSession()
+ }
+ }
+
+ static func isAuthenticated() async -> Bool {
+ await (try? ServiceQueue.background(.core) {
+ await paykitIsAuthenticated()
+ }) ?? false
+ }
+
+ static func currentPublicKey() async -> String? {
+ try? await ServiceQueue.background(.core) {
+ await paykitGetCurrentPublicKey()
+ }
+ }
+
+ // MARK: - Auth Flow (BitkitCore)
+
+ /// Step 1: Generate the pubkyauth:// URL to open in Pubky Ring.
+ static func startAuth() async throws -> String {
+ try await ServiceQueue.background(.core) {
+ try await startPubkyAuth(caps: Env.pubkyCapabilities)
+ }
+ }
+
+ /// Step 2: Long-poll until Ring approves. Returns the raw session secret.
+ static func completeAuth() async throws -> String {
+ try await ServiceQueue.background(.core) {
+ try await completePubkyAuth()
+ }
+ }
+
+ /// Cancel an in-progress auth relay poll started by `startAuth`.
+ static func cancelAuth() async throws {
+ try await ServiceQueue.background(.core) {
+ try await cancelPubkyAuth()
+ }
+ }
+
+ // MARK: - File Fetching
+
+ /// Fetch raw bytes from a `pubky://` URI via PKDNS resolution.
+ static func fetchFile(uri: String) async throws -> Data {
+ try await ServiceQueue.background(.core) {
+ let bytes = try await fetchPubkyFile(uri: uri)
+ return Data(bytes)
+ }
+ }
+
+ // MARK: - Profile
+
+ static func getProfile(publicKey: String) async throws -> BitkitCore.PubkyProfile {
+ try await ServiceQueue.background(.core) {
+ try await fetchPubkyProfile(publicKey: publicKey)
+ }
+ }
+
+ // MARK: - Contacts
+
+ static func getContacts(publicKey: String) async throws -> [String] {
+ try await ServiceQueue.background(.core) {
+ try await fetchPubkyContacts(publicKey: publicKey)
+ }
+ }
+
+ // MARK: - Payments
+
+ static func getPaymentList(publicKey: String) async throws -> [FfiPaymentEntry] {
+ try await ServiceQueue.background(.core) {
+ try await paykitGetPaymentList(publicKey: publicKey)
+ }
+ }
+
+ static func getPaymentEndpoint(publicKey: String, methodId: String) async throws -> String? {
+ try await ServiceQueue.background(.core) {
+ try await paykitGetPaymentEndpoint(publicKey: publicKey, methodId: methodId)
+ }
+ }
+
+ static func setPaymentEndpoint(methodId: String, endpointData: String) async throws {
+ try await ServiceQueue.background(.core) {
+ try await paykitSetPaymentEndpoint(methodId: methodId, endpointData: endpointData)
+ }
+ }
+
+ static func removePaymentEndpoint(methodId: String) async throws {
+ try await ServiceQueue.background(.core) {
+ try await paykitRemovePaymentEndpoint(methodId: methodId)
+ }
+ }
+
+ // MARK: - Sign Out
+
+ static func signOut() async throws {
+ try await ServiceQueue.background(.core) {
+ try await paykitSignOut()
+ }
+ }
+
+ static func forceSignOut() async {
+ _ = try? await ServiceQueue.background(.core) {
+ await paykitForceSignOut()
+ }
+ }
+}
diff --git a/Bitkit/Styles/Colors.swift b/Bitkit/Styles/Colors.swift
index 44d149968..e3b0533b0 100644
--- a/Bitkit/Styles/Colors.swift
+++ b/Bitkit/Styles/Colors.swift
@@ -9,6 +9,7 @@ extension Color {
static let purpleAccent = Color(hex: 0xB95CE8)
static let redAccent = Color(hex: 0xE95164)
static let yellowAccent = Color(hex: 0xFFD200)
+ static let pubkyGreen = Color(hex: 0xBEFF00)
// MARK: - Base
diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift
index a48f6c370..418db4170 100644
--- a/Bitkit/Utilities/Keychain.swift
+++ b/Bitkit/Utilities/Keychain.swift
@@ -6,6 +6,7 @@ enum KeychainEntryType {
case bip39Passphrase(index: Int)
case pushNotificationPrivateKey // For secp256k1 shared secret when decrypting push payload
case securityPin
+ case paykitSession
var storageKey: String {
switch self {
@@ -17,6 +18,8 @@ enum KeychainEntryType {
return "push_notification_private_key"
case .securityPin:
return "security_pin"
+ case .paykitSession:
+ return "paykit_session"
}
}
}
@@ -83,6 +86,11 @@ class Keychain {
let status = SecItemDelete(query as CFDictionary)
+ if status == errSecItemNotFound {
+ Logger.debug("\(key.storageKey) not found in keychain, nothing to delete", context: "Keychain")
+ return
+ }
+
if status != noErr {
Logger.error("Failed to delete \(key.storageKey) from keychain. \(status.description)", context: "Keychain")
throw KeychainError.failedToDelete
@@ -91,6 +99,34 @@ class Keychain {
Logger.debug("Deleted \(key.storageKey)", context: "Keychain")
}
+ /// Atomically inserts or updates a keychain entry, avoiding the delete-then-save race window.
+ class func upsert(key: KeychainEntryType, data: Data) throws {
+ Logger.debug("Upserting \(key.storageKey)", context: "Keychain")
+
+ let existingData = try load(key: key)
+
+ if existingData != nil {
+ let searchQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: key.storageKey,
+ kSecAttrAccessGroup as String: Env.keychainGroup,
+ ]
+ let updateAttributes: [String: Any] = [
+ kSecValueData as String: data,
+ ]
+
+ let status = SecItemUpdate(searchQuery as CFDictionary, updateAttributes as CFDictionary)
+ if status != noErr {
+ Logger.error("Failed to update \(key.storageKey) in keychain. \(status.description)", context: "Keychain")
+ throw KeychainError.failedToSave
+ }
+
+ Logger.info("Updated \(key.storageKey)", context: "Keychain")
+ } else {
+ try save(key: key, data: data)
+ }
+ }
+
class func exists(key: KeychainEntryType) throws -> Bool {
var value = try load(key: key)
let exists = value != nil
diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift
index b43a79bc0..aa9097666 100644
--- a/Bitkit/ViewModels/NavigationViewModel.swift
+++ b/Bitkit/ViewModels/NavigationViewModel.swift
@@ -11,8 +11,10 @@ enum Route: Hashable {
case buyBitcoin
case contacts
case contactsIntro
+ case contactDetail(publicKey: String)
case profile
case profileIntro
+ case pubkyRingAuth
case transferIntro
case fundingOptions
case spendingIntro
diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift
new file mode 100644
index 000000000..5ed689d8d
--- /dev/null
+++ b/Bitkit/Views/Contacts/ContactDetailView.swift
@@ -0,0 +1,240 @@
+import SwiftUI
+
+struct ContactDetailView: View {
+ @EnvironmentObject var app: AppViewModel
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var contactsManager: ContactsManager
+
+ let publicKey: String
+
+ @State private var profile: PubkyProfile?
+ @State private var isLoading = true
+
+ var body: some View {
+ VStack(spacing: 0) {
+ NavigationBar(title: t("contacts__detail_title"))
+ .padding(.horizontal, 16)
+
+ if isLoading && profile == nil {
+ loadingContent
+ } else if let profile {
+ contactBody(profile)
+ } else {
+ emptyContent
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.customBlack)
+ .navigationBarHidden(true)
+ .task {
+ if let cached = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) {
+ profile = cached.profile
+ }
+ await loadContact()
+ }
+ }
+
+ // MARK: - Contact Body
+
+ @ViewBuilder
+ private func contactBody(_ profile: PubkyProfile) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ contactHeader(profile)
+ .padding(.top, 24)
+ .padding(.bottom, 16)
+
+ contactBio(profile)
+ .padding(.bottom, 24)
+
+ contactActions
+ .padding(.bottom, 32)
+
+ if !profile.links.isEmpty {
+ linksSection(profile)
+ }
+ }
+ .padding(.horizontal, 32)
+ }
+ }
+
+ // MARK: - Header (name, key, avatar)
+
+ @ViewBuilder
+ private func contactHeader(_ profile: PubkyProfile) -> some View {
+ HStack(alignment: .top, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ HeadlineText(profile.name)
+ .fixedSize(horizontal: false, vertical: true)
+
+ BodySSBText(profile.truncatedPublicKey, textColor: .white)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Group {
+ if let imageUrl = profile.imageUrl {
+ PubkyImage(uri: imageUrl, size: 64)
+ } else {
+ Circle()
+ .fill(Color.pubkyGreen)
+ .frame(width: 64, height: 64)
+ .overlay {
+ Image("user-square")
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.white32)
+ .frame(width: 32, height: 32)
+ }
+ }
+ }
+ .accessibilityHidden(true)
+ }
+ }
+
+ // MARK: - Bio
+
+ @ViewBuilder
+ private func contactBio(_ profile: PubkyProfile) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if !profile.bio.isEmpty {
+ Text(profile.bio)
+ .font(Fonts.regular(size: 17))
+ .foregroundColor(.white64)
+ .lineSpacing(4)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ CustomDivider()
+ }
+ }
+
+ // MARK: - Action Buttons
+
+ @ViewBuilder
+ private var contactActions: some View {
+ HStack(spacing: 16) {
+ actionButton(icon: "copy", accessibilityLabel: t("common__copy")) {
+ UIPasteboard.general.string = publicKey
+ app.toast(type: .success, title: t("common__copied"))
+ }
+ .accessibilityIdentifier("ContactCopy")
+
+ actionButton(icon: "share", accessibilityLabel: t("common__share")) {
+ shareContact()
+ }
+ .accessibilityIdentifier("ContactShare")
+ }
+ }
+
+ @ViewBuilder
+ private func actionButton(icon: String, accessibilityLabel: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [.gray5, .gray6],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+ .overlay(
+ Circle()
+ .stroke(Color.white10, lineWidth: 1)
+ .padding(0.5)
+ )
+
+ Image(icon)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.textPrimary)
+ .frame(width: 24, height: 24)
+ }
+ .frame(width: 48, height: 48)
+ }
+ .accessibilityLabel(accessibilityLabel)
+ }
+
+ // MARK: - Links / Metadata
+
+ @ViewBuilder
+ private func linksSection(_ profile: PubkyProfile) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(profile.links) { link in
+ ProfileLinkRow(label: link.label, value: link.url)
+ }
+ }
+ }
+
+ // MARK: - Loading & Empty States
+
+ @ViewBuilder
+ private var loadingContent: some View {
+ VStack {
+ Spacer()
+ ActivityIndicator(size: 32)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ @ViewBuilder
+ private var emptyContent: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ BodyMText(t("contacts__detail_empty_state"))
+ CustomButton(title: t("profile__retry_load"), variant: .secondary) {
+ await loadContact()
+ }
+ .accessibilityIdentifier("ContactRetry")
+ Spacer()
+ }
+ .padding(.horizontal, 32)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ // MARK: - Data Loading
+
+ private func loadContact() async {
+ isLoading = true
+ if let freshProfile = await contactsManager.fetchContactProfile(publicKey: publicKey) {
+ profile = freshProfile
+ } else {
+ if profile == nil {
+ profile = PubkyProfile.placeholder(publicKey: publicKey)
+ }
+ app.toast(type: .error, title: t("contacts__error_loading"))
+ }
+ isLoading = false
+ }
+
+ // MARK: - Share
+
+ private func shareContact() {
+ let activityVC = UIActivityViewController(
+ activityItems: [publicKey],
+ applicationActivities: nil
+ )
+
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootViewController = windowScene.windows.first?.rootViewController
+ {
+ var presentingVC = rootViewController
+ while let presented = presentingVC.presentedViewController {
+ presentingVC = presented
+ }
+ activityVC.popoverPresentationController?.sourceView = presentingVC.view
+ presentingVC.present(activityVC, animated: true)
+ }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ ContactDetailView(publicKey: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK")
+ .environmentObject(AppViewModel())
+ .environmentObject(NavigationViewModel())
+ .environmentObject(ContactsManager())
+ }
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Contacts/ContactsIntroView.swift b/Bitkit/Views/Contacts/ContactsIntroView.swift
index 4a9669ae9..7a10e2709 100644
--- a/Bitkit/Views/Contacts/ContactsIntroView.swift
+++ b/Bitkit/Views/Contacts/ContactsIntroView.swift
@@ -3,18 +3,26 @@ import SwiftUI
struct ContactsIntroView: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
var body: some View {
OnboardingView(
- navTitle: t("slashtags__contacts"),
- title: t("slashtags__onboarding_header"),
- description: t("slashtags__onboarding_text"),
+ navTitle: t("contacts__nav_title"),
+ title: t("contacts__intro_title"),
+ description: t("contacts__intro_description"),
imageName: "group",
- buttonText: t("slashtags__onboarding_button"),
+ buttonText: t("common__continue"),
onButtonPress: {
app.hasSeenContactsIntro = true
- navigation.navigate(.contacts)
+ if pubkyProfile.isAuthenticated {
+ navigation.navigate(.contacts)
+ } else if app.hasSeenProfileIntro {
+ navigation.navigate(.pubkyRingAuth)
+ } else {
+ navigation.navigate(.profileIntro)
+ }
},
+ accentColor: .pubkyGreen,
imagePosition: .center,
testID: "ContactsIntro"
)
@@ -27,6 +35,7 @@ struct ContactsIntroView: View {
ContactsIntroView()
.environmentObject(AppViewModel())
.environmentObject(NavigationViewModel())
+ .environmentObject(PubkyProfileManager())
.preferredColorScheme(.dark)
}
}
diff --git a/Bitkit/Views/Contacts/ContactsListView.swift b/Bitkit/Views/Contacts/ContactsListView.swift
new file mode 100644
index 000000000..bdf04b78c
--- /dev/null
+++ b/Bitkit/Views/Contacts/ContactsListView.swift
@@ -0,0 +1,230 @@
+import SwiftUI
+
+struct ContactsListView: View {
+ @EnvironmentObject var app: AppViewModel
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
+ @EnvironmentObject var contactsManager: ContactsManager
+
+ @State private var searchText = ""
+
+ private var isSearching: Bool {
+ !searchText.trimmingCharacters(in: .whitespaces).isEmpty
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ NavigationBar(title: t("contacts__nav_title"))
+ .padding(.horizontal, 16)
+
+ contactsSearchBar
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ .padding(.bottom, 8)
+
+ Group {
+ if contactsManager.isLoading && contactsManager.contacts.isEmpty {
+ loadingContent
+ } else if contactsManager.contacts.isEmpty && !contactsManager.isLoading && !isSearching {
+ emptyContent
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ if !isSearching, pubkyProfile.isAuthenticated, let profile = pubkyProfile.profile {
+ myProfileSection(profile)
+ }
+
+ contactsList
+ }
+ .padding(.horizontal, 16)
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.customBlack)
+ .navigationBarHidden(true)
+ .task {
+ guard let pk = pubkyProfile.publicKey else { return }
+ do {
+ try await contactsManager.loadContacts(for: pk)
+ } catch {
+ app.toast(type: .error, title: t("contacts__error_loading"))
+ }
+ }
+ }
+
+ // MARK: - Search Bar
+
+ @ViewBuilder
+ private var contactsSearchBar: some View {
+ HStack(spacing: 12) {
+ Image("magnifying-glass")
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.white50)
+ .frame(width: 24, height: 24)
+ .accessibilityHidden(true)
+
+ TextField(t("common__search"), text: $searchText, backgroundColor: .clear, font: Fonts.regular(size: 17))
+ .foregroundColor(.textPrimary)
+ .accessibilityLabel(t("common__search"))
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 48)
+ .background(Color.gray6)
+ .clipShape(Capsule())
+ }
+
+ // MARK: - My Profile Section
+
+ @ViewBuilder
+ private func myProfileSection(_ profile: PubkyProfile) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ sectionHeader(t("contacts__my_profile"))
+
+ contactRow(
+ name: profile.name,
+ truncatedKey: profile.truncatedPublicKey,
+ imageUrl: profile.imageUrl
+ ) {
+ navigation.navigate(.contactDetail(publicKey: profile.publicKey))
+ }
+ .accessibilityIdentifier("ContactsMyProfile")
+
+ CustomDivider()
+ }
+ }
+
+ // MARK: - Contacts List
+
+ @ViewBuilder
+ private var contactsList: some View {
+ ForEach(filteredSections) { section in
+ VStack(alignment: .leading, spacing: 0) {
+ sectionHeader(section.letter)
+ CustomDivider()
+
+ ForEach(section.contacts) { contact in
+ contactRow(
+ name: contact.displayName,
+ truncatedKey: contact.profile.truncatedPublicKey,
+ imageUrl: contact.profile.imageUrl
+ ) {
+ navigation.navigate(.contactDetail(publicKey: contact.publicKey))
+ }
+ .accessibilityIdentifier("Contact_\(contact.publicKey)")
+
+ CustomDivider()
+ }
+ }
+ }
+ }
+
+ // MARK: - Section Header
+
+ @ViewBuilder
+ private func sectionHeader(_ title: String) -> some View {
+ Text(title.uppercased())
+ .font(Fonts.medium(size: 13))
+ .foregroundColor(.white64)
+ .tracking(0.8)
+ .padding(.vertical, 16)
+ }
+
+ // MARK: - Contact Row
+
+ @ViewBuilder
+ private func contactRow(name: String, truncatedKey: String, imageUrl: String?, onTap: @escaping () -> Void) -> some View {
+ Button(action: onTap) {
+ HStack(spacing: 16) {
+ contactAvatar(name: name, imageUrl: imageUrl)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(name)
+ .font(Fonts.semiBold(size: 17))
+ .foregroundColor(.textPrimary)
+ .lineLimit(1)
+
+ Text(truncatedKey)
+ .font(Fonts.regular(size: 13))
+ .foregroundColor(.white64)
+ }
+
+ Spacer()
+ }
+ .padding(.vertical, 12)
+ }
+ .accessibilityLabel(name)
+ }
+
+ @ViewBuilder
+ private func contactAvatar(name: String, imageUrl: String?) -> some View {
+ Group {
+ if let imageUrl {
+ PubkyImage(uri: imageUrl, size: 48)
+ } else {
+ Circle()
+ .fill(Color.white.opacity(0.1))
+ .frame(width: 48, height: 48)
+ .overlay {
+ Text(String(name.prefix(1)).uppercased())
+ .font(Fonts.bold(size: 17))
+ .foregroundColor(.textPrimary)
+ }
+ }
+ }
+ .accessibilityHidden(true)
+ }
+
+ // MARK: - Filtered Sections
+
+ private var filteredSections: [ContactSection] {
+ let trimmed = searchText.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return contactsManager.groupedContacts }
+
+ let query = trimmed.lowercased()
+ return contactsManager.groupedContacts.compactMap { section in
+ let filtered = section.contacts.filter {
+ $0.displayName.lowercased().contains(query) ||
+ $0.publicKey.lowercased().contains(query)
+ }
+ guard !filtered.isEmpty else { return nil }
+ return ContactSection(id: section.id, letter: section.letter, contacts: filtered)
+ }
+ }
+
+ // MARK: - Loading & Empty States
+
+ @ViewBuilder
+ private var loadingContent: some View {
+ VStack {
+ Spacer()
+ ActivityIndicator(size: 32)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ @ViewBuilder
+ private var emptyContent: some View {
+ VStack {
+ Spacer()
+ BodyMText(t("contacts__empty_state"))
+ Spacer()
+ }
+ .padding(.horizontal, 32)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+#Preview {
+ NavigationStack {
+ ContactsListView()
+ .environmentObject(AppViewModel())
+ .environmentObject(NavigationViewModel())
+ .environmentObject(PubkyProfileManager())
+ .environmentObject(ContactsManager())
+ }
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Profile/ProfileIntro.swift b/Bitkit/Views/Profile/ProfileIntro.swift
index e4db0d38f..193dc45ca 100644
--- a/Bitkit/Views/Profile/ProfileIntro.swift
+++ b/Bitkit/Views/Profile/ProfileIntro.swift
@@ -6,15 +6,16 @@ struct ProfileIntroView: View {
var body: some View {
OnboardingView(
- navTitle: t("slashtags__profile"),
- title: t("slashtags__onboarding_profile1_header"),
- description: t("slashtags__onboarding_profile1_text"),
+ navTitle: t("profile__nav_title"),
+ title: t("profile__intro_title"),
+ description: t("profile__intro_description"),
imageName: "crown",
buttonText: t("common__continue"),
onButtonPress: {
app.hasSeenProfileIntro = true
- navigation.navigate(.profile)
+ navigation.navigate(.pubkyRingAuth)
},
+ accentColor: .pubkyGreen,
imagePosition: .center,
testID: "ProfileIntro"
)
@@ -27,6 +28,6 @@ struct ProfileIntroView: View {
ProfileIntroView()
.environmentObject(AppViewModel())
.environmentObject(NavigationViewModel())
- .preferredColorScheme(.dark)
}
+ .preferredColorScheme(.dark)
}
diff --git a/Bitkit/Views/Profile/ProfileView.swift b/Bitkit/Views/Profile/ProfileView.swift
new file mode 100644
index 000000000..63eb08ded
--- /dev/null
+++ b/Bitkit/Views/Profile/ProfileView.swift
@@ -0,0 +1,321 @@
+import SwiftUI
+
+struct ProfileView: View {
+ @EnvironmentObject var app: AppViewModel
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
+
+ @State private var showSignOutConfirmation = false
+ @State private var isSigningOut = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ NavigationBar(
+ title: t("profile__nav_title")
+ )
+ .padding(.horizontal, 16)
+
+ if pubkyProfile.isLoadingProfile && pubkyProfile.profile == nil {
+ loadingContent
+ } else if let profile = pubkyProfile.profile {
+ profileContent(profile)
+ } else {
+ emptyContent
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .bottomSafeAreaPadding()
+ .background(Color.customBlack)
+ .navigationBarHidden(true)
+ .task {
+ guard pubkyProfile.profile == nil else { return }
+ await pubkyProfile.loadProfile()
+ }
+ .alert(
+ t("profile__sign_out_title"),
+ isPresented: $showSignOutConfirmation
+ ) {
+ Button(t("profile__sign_out"), role: .destructive) {
+ Task { await performSignOut() }
+ }
+ Button(t("common__dialog_cancel"), role: .cancel) {}
+ } message: {
+ Text(t("profile__sign_out_description"))
+ }
+ }
+
+ // MARK: - Profile Content
+
+ @ViewBuilder
+ private func profileContent(_ profile: PubkyProfile) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ profileHeader(profile)
+ .padding(.top, 24)
+ .padding(.bottom, 16)
+
+ profileBio(profile)
+ .padding(.bottom, 24)
+
+ profileActions
+ .padding(.bottom, 24)
+
+ profileQRCode(profile)
+ .padding(.bottom, 32)
+
+ if !profile.links.isEmpty {
+ profileLinks(profile)
+ }
+ }
+ .padding(.horizontal, 32)
+ }
+ }
+
+ // MARK: - Actions (copy, share, edit)
+
+ @ViewBuilder
+ private var profileActions: some View {
+ HStack(spacing: 16) {
+ profileActionButton(icon: "copy", accessibilityLabel: t("common__copy")) {
+ if let pk = pubkyProfile.publicKey {
+ UIPasteboard.general.string = pk
+ app.toast(type: .success, title: t("common__copied"))
+ }
+ }
+ .accessibilityIdentifier("ProfileCopy")
+
+ profileActionButton(icon: "share", accessibilityLabel: t("common__share")) {
+ shareProfile()
+ }
+ .accessibilityIdentifier("ProfileShare")
+
+ profileActionButton(systemIcon: "rectangle.portrait.and.arrow.right", accessibilityLabel: t("profile__sign_out")) {
+ showSignOutConfirmation = true
+ }
+ .disabled(isSigningOut)
+ .opacity(isSigningOut ? 0.5 : 1)
+ .accessibilityIdentifier("ProfileSignOut")
+ }
+ }
+
+ @ViewBuilder
+ private func profileActionButton(icon: String? = nil, systemIcon: String? = nil, accessibilityLabel: String,
+ action: @escaping () -> Void) -> some View
+ {
+ Button(action: action) {
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [.gray5, .gray6],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+ .overlay(
+ Circle()
+ .stroke(Color.white10, lineWidth: 1)
+ .padding(0.5)
+ )
+
+ if let icon {
+ Image(icon)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.textPrimary)
+ .frame(width: 24, height: 24)
+ } else if let systemIcon {
+ Image(systemName: systemIcon)
+ .font(.system(size: 18, weight: .medium))
+ .foregroundColor(.textPrimary)
+ }
+ }
+ .frame(width: 48, height: 48)
+ }
+ .accessibilityLabel(accessibilityLabel)
+ }
+
+ // MARK: - Header (name, key, avatar)
+
+ @ViewBuilder
+ private func profileHeader(_ profile: PubkyProfile) -> some View {
+ HStack(alignment: .top, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ HeadlineText(profile.name)
+ .fixedSize(horizontal: false, vertical: true)
+
+ BodySSBText(profile.truncatedPublicKey, textColor: .white)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if let imageUrl = profile.imageUrl {
+ PubkyImage(uri: imageUrl, size: 64)
+ } else {
+ profilePlaceholder
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var profilePlaceholder: some View {
+ Circle()
+ .fill(Color.pubkyGreen)
+ .frame(width: 64, height: 64)
+ .overlay {
+ Image("user-square")
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(.white32)
+ .frame(width: 32, height: 32)
+ }
+ }
+
+ // MARK: - Bio
+
+ @ViewBuilder
+ private func profileBio(_ profile: PubkyProfile) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if !profile.bio.isEmpty {
+ Text(profile.bio)
+ .font(Fonts.regular(size: 22))
+ .foregroundColor(.white64)
+ .kerning(0.4)
+ .lineSpacing(4)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ CustomDivider()
+ }
+ }
+
+ // MARK: - QR Code
+
+ @ViewBuilder
+ private func profileQRCode(_ profile: PubkyProfile) -> some View {
+ VStack(spacing: 12) {
+ ZStack {
+ QR(content: profile.publicKey)
+
+ if let imageUrl = profile.imageUrl {
+ ZStack {
+ Circle()
+ .fill(Color.white)
+ .frame(width: 68, height: 68)
+
+ PubkyImage(uri: imageUrl, size: 50)
+ }
+ }
+ }
+
+ BodySText(t("profile__qr_scan_label", variables: ["name": profile.name]), textColor: .white)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ // MARK: - Links / Metadata
+
+ @ViewBuilder
+ private func profileLinks(_ profile: PubkyProfile) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(profile.links) { link in
+ ProfileLinkRow(label: link.label, value: link.url)
+ }
+ }
+ }
+
+ // MARK: - Loading / Empty States
+
+ @ViewBuilder
+ private var loadingContent: some View {
+ VStack {
+ Spacer()
+ ActivityIndicator(size: 32)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ @ViewBuilder
+ private var emptyContent: some View {
+ VStack(spacing: 16) {
+ Spacer()
+ BodyMText(t("profile__empty_state"))
+ CustomButton(title: t("profile__retry_load"), variant: .secondary) {
+ await pubkyProfile.loadProfile()
+ }
+ .accessibilityIdentifier("ProfileRetry")
+ Button(t("profile__sign_out")) {
+ showSignOutConfirmation = true
+ }
+ .font(Fonts.regular(size: 17))
+ .foregroundColor(.white64)
+ .accessibilityLabel(t("profile__sign_out"))
+ .accessibilityIdentifier("ProfileEmptySignOut")
+ Spacer()
+ }
+ .padding(.horizontal, 32)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ // MARK: - Sign Out & Share
+
+ private func performSignOut() async {
+ isSigningOut = true
+ await pubkyProfile.signOut()
+ isSigningOut = false
+ }
+
+ private func shareProfile() {
+ guard let pk = pubkyProfile.publicKey else { return }
+ let activityVC = UIActivityViewController(
+ activityItems: [pk],
+ applicationActivities: nil
+ )
+
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootViewController = windowScene.windows.first?.rootViewController
+ {
+ var presentingVC = rootViewController
+ while let presented = presentingVC.presentedViewController {
+ presentingVC = presented
+ }
+ activityVC.popoverPresentationController?.sourceView = presentingVC.view
+ presentingVC.present(activityVC, animated: true)
+ }
+ }
+}
+
+// MARK: - Profile Link Row
+
+struct ProfileLinkRow: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(label, textColor: .white64)
+
+ BodySSBText(value, textColor: .white)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.vertical, 16)
+
+ CustomDivider()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityLabel(Text("\(label): \(value)"))
+ }
+}
+
+#Preview {
+ let manager = PubkyProfileManager()
+ NavigationStack {
+ ProfileView()
+ .environmentObject(AppViewModel())
+ .environmentObject(NavigationViewModel())
+ .environmentObject(manager)
+ }
+ .preferredColorScheme(.dark)
+}
diff --git a/Bitkit/Views/Profile/PubkyRingAuthView.swift b/Bitkit/Views/Profile/PubkyRingAuthView.swift
new file mode 100644
index 000000000..6f8fa87a5
--- /dev/null
+++ b/Bitkit/Views/Profile/PubkyRingAuthView.swift
@@ -0,0 +1,170 @@
+import SwiftUI
+
+struct PubkyRingAuthView: View {
+ @EnvironmentObject var app: AppViewModel
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var pubkyProfile: PubkyProfileManager
+ @Environment(\.scenePhase) var scenePhase
+
+ @State private var isAuthenticating = false
+ @State private var isWaitingForRing = false
+ @State private var isRingInstalled = false
+ @State private var showRingNotInstalledDialog = false
+
+ private let pubkyRingAppStoreUrl = "https://apps.apple.com/app/pubky-ring/id6739356756"
+
+ var body: some View {
+ ZStack {
+ GeometryReader { geo in
+ Image("tag-pubky")
+ .resizable()
+ .scaledToFit()
+ .frame(width: geo.size.width * 0.83)
+ .position(
+ x: geo.size.width * 0.321,
+ y: geo.size.height * 0.376
+ )
+
+ Image("keyring")
+ .resizable()
+ .scaledToFit()
+ .frame(width: geo.size.width * 0.83)
+ .opacity(0.9)
+ .position(
+ x: geo.size.width * 0.841,
+ y: geo.size.height * 0.305
+ )
+ }
+ .ignoresSafeArea()
+
+ VStack(spacing: 0) {
+ NavigationBar(title: t("profile__nav_title"))
+ .padding(.horizontal, 16)
+
+ Spacer()
+
+ VStack(alignment: .leading, spacing: 0) {
+ Image("pubky-ring-logo")
+ .resizable()
+ .scaledToFit()
+ .frame(height: 36)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.bottom, 24)
+
+ VStack(alignment: .leading, spacing: 8) {
+ DisplayText(
+ t("profile__ring_auth_title"),
+ accentColor: .pubkyGreen
+ )
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+
+ BodyMText(isWaitingForRing ? t("profile__ring_waiting") : t("profile__ring_auth_description"))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer()
+ .frame(height: 24)
+
+ if isRingInstalled {
+ CustomButton(
+ title: t("profile__ring_authorize"),
+ isLoading: isAuthenticating
+ ) {
+ await authenticate()
+ }
+ .accessibilityIdentifier("PubkyRingAuthorize")
+ } else {
+ CustomButton(title: t("profile__ring_download")) {
+ if let url = URL(string: pubkyRingAppStoreUrl) {
+ await UIApplication.shared.open(url)
+ }
+ }
+ .accessibilityIdentifier("PubkyRingDownload")
+ }
+ }
+ .padding(.horizontal, 32)
+ }
+ }
+ .clipped()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .bottomSafeAreaPadding()
+ .background(Color.customBlack)
+ .navigationBarHidden(true)
+ .task {
+ checkRingInstalled()
+ }
+ .task(id: isWaitingForRing) {
+ guard isWaitingForRing else { return }
+ await waitForApproval()
+ }
+ .onChange(of: scenePhase) { _, newPhase in
+ if newPhase == .active {
+ checkRingInstalled()
+ }
+ }
+ .alert(t("profile__ring_not_installed_title"), isPresented: $showRingNotInstalledDialog) {
+ Button(t("profile__ring_download")) {
+ if let url = URL(string: pubkyRingAppStoreUrl) {
+ Task { await UIApplication.shared.open(url) }
+ }
+ }
+ Button(t("common__dialog_cancel"), role: .cancel) {}
+ } message: {
+ Text(t("profile__ring_not_installed_description"))
+ }
+ }
+
+ private func checkRingInstalled() {
+ if let url = URL(string: "pubkyauth://check") {
+ isRingInstalled = UIApplication.shared.canOpenURL(url)
+ }
+ }
+
+ private func authenticate() async {
+ if isWaitingForRing {
+ isWaitingForRing = false
+ await pubkyProfile.cancelAuthentication()
+ }
+
+ isAuthenticating = true
+
+ do {
+ try await pubkyProfile.startAuthentication()
+ isAuthenticating = false
+ isWaitingForRing = true
+ } catch PubkyServiceError.ringNotInstalled {
+ isAuthenticating = false
+ isRingInstalled = false
+ showRingNotInstalledDialog = true
+ } catch {
+ isAuthenticating = false
+ app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription)
+ }
+ }
+
+ private func waitForApproval() async {
+ do {
+ try await pubkyProfile.completeAuthentication()
+ isWaitingForRing = false
+ navigation.path = [.profile]
+ } catch is CancellationError {
+ isWaitingForRing = false
+ await pubkyProfile.cancelAuthentication()
+ } catch {
+ isWaitingForRing = false
+ app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription)
+ }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ PubkyRingAuthView()
+ .environmentObject(AppViewModel())
+ .environmentObject(NavigationViewModel())
+ .environmentObject(PubkyProfileManager())
+ }
+ .preferredColorScheme(.dark)
+}