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) +}