diff --git a/AGENTS.md b/AGENTS.md index 027becdfe..34dc0fc91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,8 @@ This project follows **modern SwiftUI patterns** and explicitly **AVOIDS traditi - Async operations should delegate to `@Observable` business logic objects 4. **Component Design** + - **Always reuse existing components** before creating new ones — check `Components/` for buttons, text styles, layouts, and other shared UI. If identical or near-identical UI exists elsewhere in the codebase, extract it into a shared component rather than duplicating it. + - Use the project's text components (`DisplayText`, `HeadlineText`, `TitleText`, `SubtitleText`, `BodyMText`, `BodyMSBText`, `BodySSBText`, `BodySText`, `CaptionMText`, `CaptionText`) instead of raw `Text().font().foregroundColor()` chains. - Decompose views into small, focused, single-purpose components - Use descriptive names (e.g., `UserProfileCard` not `Card`) - Prefer composition over deep view hierarchies diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 05748e0dd..2c0c5b0a7 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 = ""; @@ -515,6 +519,10 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -543,6 +551,10 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit.notification; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -675,6 +687,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; @@ -702,8 +715,13 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -718,6 +736,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 184; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; @@ -745,8 +764,13 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 2.1.2; + OTHER_LDFLAGS = ( + "-framework", + CoreBluetooth, + ); PRODUCT_BUNDLE_IDENTIFIER = to.bitkit; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -893,6 +917,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"; @@ -937,8 +969,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { - branch = master; - kind = branch; + kind = revision; + revision = 6dc2e685d271d78ed18384d713c04081c6b1157c; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { @@ -960,6 +992,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 b1ca35741..f24d6f34d 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" : "6dc2e685d271d78ed18384d713c04081c6b1157c" } }, { @@ -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 6498a3b27..726c6f719 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -27,6 +27,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 @@ -133,6 +135,21 @@ struct AppScene: View { .environmentObject(tagManager) .environmentObject(transferTracking) .environmentObject(channelDetails) + .environmentObject(pubkyProfile) + .environmentObject(contactsManager) + .onChange(of: pubkyProfile.authState, initial: true) { _, authState in + if authState == .authenticated, let pk = pubkyProfile.publicKey { + Task { try? await contactsManager.loadContacts(for: pk) } + } else if authState == .idle { + contactsManager.reset() + } + } + .onChange(of: pubkyProfile.sessionRestorationFailed) { _, failed in + if failed { + pubkyProfile.sessionRestorationFailed = false + app.toast(type: .error, title: t("profile__session_expired_title"), description: t("profile__session_expired_description")) + } + } .onAppear { if !settings.pinEnabled { isPinVerified = true @@ -390,6 +407,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/contact-card.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/contact-card.imageset/Contents.json new file mode 100644 index 000000000..342a38ff1 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/contact-card.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "contact-card.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/contact-card.imageset/contact-card.png b/Bitkit/Assets.xcassets/Illustrations/contact-card.imageset/contact-card.png new file mode 100644 index 000000000..0a879487d Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/contact-card.imageset/contact-card.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/ellipse-inner-green.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/ellipse-inner-green.imageset/Contents.json new file mode 100644 index 000000000..b0b298621 --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/ellipse-inner-green.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ellipse-inner-green.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/ellipse-inner-green.imageset/ellipse-inner-green.png b/Bitkit/Assets.xcassets/Illustrations/ellipse-inner-green.imageset/ellipse-inner-green.png new file mode 100644 index 000000000..ec793efe9 Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/ellipse-inner-green.imageset/ellipse-inner-green.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/ellipse-outer-green.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/ellipse-outer-green.imageset/Contents.json new file mode 100644 index 000000000..49c8e5daa --- /dev/null +++ b/Bitkit/Assets.xcassets/Illustrations/ellipse-outer-green.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "ellipse-outer-green.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Bitkit/Assets.xcassets/Illustrations/ellipse-outer-green.imageset/ellipse-outer-green.png b/Bitkit/Assets.xcassets/Illustrations/ellipse-outer-green.imageset/ellipse-outer-green.png new file mode 100644 index 000000000..9323c39bd Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/ellipse-outer-green.imageset/ellipse-outer-green.png differ diff --git a/Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/lightbulb.png b/Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/lightbulb.png index 6f5838659..ba5e4fd67 100644 Binary files a/Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/lightbulb.png and b/Bitkit/Assets.xcassets/Illustrations/lightbulb.imageset/lightbulb.png differ 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/Button/GradientCircleButton.swift b/Bitkit/Components/Button/GradientCircleButton.swift new file mode 100644 index 000000000..8426f7b18 --- /dev/null +++ b/Bitkit/Components/Button/GradientCircleButton.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// A circular button with a gradient background, used for action icons (copy, share, edit, delete). +struct GradientCircleButton: View { + let icon: String? + let systemIcon: String? + let accessibilityLabel: String + let action: () -> Void + + init(icon: String, accessibilityLabel: String, action: @escaping () -> Void) { + self.icon = icon + systemIcon = nil + self.accessibilityLabel = accessibilityLabel + self.action = action + } + + init(systemIcon: String, accessibilityLabel: String, action: @escaping () -> Void) { + icon = nil + self.systemIcon = systemIcon + self.accessibilityLabel = accessibilityLabel + self.action = action + } + + var body: 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) + } +} diff --git a/Bitkit/Components/Button/IconActionButton.swift b/Bitkit/Components/Button/IconActionButton.swift new file mode 100644 index 000000000..b2c1f1ae2 --- /dev/null +++ b/Bitkit/Components/Button/IconActionButton.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// A pill-shaped button with an icon and label, used for "Add Link", "Add Tag" actions. +struct IconActionButton: View { + let icon: String + let isSystemIcon: Bool + let title: String + let accessibilityId: String + let action: () -> Void + + init( + icon: String, + isSystemIcon: Bool = false, + title: String, + accessibilityId: String, + action: @escaping () -> Void + ) { + self.icon = icon + self.isSystemIcon = isSystemIcon + self.title = title + self.accessibilityId = accessibilityId + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if isSystemIcon { + Image(systemName: icon) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + } else { + Image(icon) + .resizable() + .scaledToFit() + .foregroundColor(.white) + .frame(width: 14, height: 14) + } + + BodySSBText(title) + .lineLimit(1) + } + .padding(.horizontal, 16) + .frame(height: 40) + .background( + LinearGradient( + colors: [Color(hex: 0x2A2A2A), Color(hex: 0x1C1C1C)], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 64) + .stroke(Color.white10, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.32), radius: 2, x: 0, y: 2) + .cornerRadius(64) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityId) + } +} diff --git a/Bitkit/Components/CenteredProfileHeader.swift b/Bitkit/Components/CenteredProfileHeader.swift new file mode 100644 index 000000000..fb4dbe214 --- /dev/null +++ b/Bitkit/Components/CenteredProfileHeader.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct CenteredProfileHeader: View { + let truncatedKey: String + let name: String + let bio: String + let imageUrl: String? + var avatarSize: CGFloat = 100 + var showBio: Bool = true + var showDivider: Bool = true + + var body: some View { + VStack(spacing: 0) { + CaptionMText(truncatedKey, textColor: .white64) + .padding(.bottom, 16) + + avatarView + .padding(.bottom, 16) + + Text(name.uppercased()) + .font(Fonts.black(size: 44)) + .kerning(-1) + .foregroundColor(.textPrimary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, showBio && !bio.isEmpty ? 8 : 0) + + if showBio, !bio.isEmpty { + BodyMText(bio, textColor: .white64) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 16) + } + + if showDivider { + CustomDivider() + } + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var avatarView: some View { + if let imageUrl { + PubkyImage(uri: imageUrl, size: avatarSize) + } else { + Circle() + .fill(Color.gray5) + .frame(width: avatarSize, height: avatarSize) + .overlay { + Image("user-square") + .resizable() + .scaledToFit() + .foregroundColor(.white32) + .frame(width: avatarSize * 0.5, height: avatarSize * 0.5) + } + } + } +} + +#Preview { + VStack { + CenteredProfileHeader( + truncatedKey: "3RSD...W5XG", + name: "Satoshi Nakamoto", + bio: "Authored the Bitcoin white paper, developed Bitcoin, mined first block.", + imageUrl: nil + ) + + Spacer() + } + .padding(.horizontal, 16) + .background(Color.customBlack) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Components/Header.swift b/Bitkit/Components/Header.swift index ed534c602..c54f1d5e9 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(.pubkyChoice) + } 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..996f4f6de 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 = .pubkyChoice + } 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/ProfileEditFormView.swift b/Bitkit/Components/ProfileEditFormView.swift new file mode 100644 index 000000000..ae464a3e9 --- /dev/null +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -0,0 +1,205 @@ +import SwiftUI + +struct ProfileEditFormView: View { + @Binding var name: String + @Binding var bio: String + @Binding var links: [ProfileLinkInput] + @Binding var tags: [String] + + let publicKey: String + let isSaving: Bool + let onSave: () async -> Void + let onCancel: () -> Void + @ViewBuilder let avatar: () -> Avatar + + @State private var showAddLinkSheet = false + @State private var showAddTagSheet = false + + var body: some View { + ScrollView { + VStack(spacing: 0) { + avatar() + .padding(.top, 24) + .padding(.bottom, 16) + + SwiftUI.TextField( + t("profile__create_name_placeholder"), + text: $name + ) + .font(Fonts.black(size: 44)) + .kerning(-1) + .textCase(.uppercase) + .multilineTextAlignment(.center) + .foregroundColor(.textPrimary) + .padding(.horizontal, 16) + .padding(.bottom, 16) + .accessibilityIdentifier("ProfileEditName") + + CustomDivider() + .padding(.bottom, 16) + + pubkyKeySection + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 0) { + bioSection + .padding(.bottom, 16) + + linksSection + .padding(.bottom, 16) + + if !links.isEmpty { + CustomDivider(color: .white16) + .padding(.bottom, 16) + } + + tagsSection + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + } + .scrollDismissesKeyboard(.interactively) + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + .sheet(isPresented: $showAddLinkSheet) { + AddLinkSheet { label, url in + links.append(ProfileLinkInput(label: label, url: url)) + } + } + .sheet(isPresented: $showAddTagSheet) { + AddProfileTagSheet { tag in + tags.append(tag) + } + } + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + onCancel() + } + + CustomButton( + title: t("common__save"), + isLoading: isSaving + ) { + await onSave() + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.top, 16) + .padding(.horizontal, 16) + } + + // MARK: - Pubky Key Section + + @ViewBuilder + private var pubkyKeySection: some View { + VStack(spacing: 8) { + CaptionMText(t("profile__create_pubky_display_label"), textColor: .white64) + + BodySText( + publicKey, + textColor: .white + ) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + // MARK: - Bio Section + + @ViewBuilder + private var bioSection: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__create_bio_label"), textColor: .white64) + + TextField( + t("profile__create_bio_placeholder"), + text: $bio, + backgroundColor: .gray6, + font: .custom(Fonts.regular, size: 17), + axis: .vertical, + testIdentifier: "ProfileEditBio" + ) + } + } + + // MARK: - Links Section + + @ViewBuilder + private var linksSection: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(links.indices, id: \.self) { index in + linkRow(link: links[index], index: index) + } + + IconActionButton( + icon: "link", + isSystemIcon: true, + title: t("profile__create_add_link"), + accessibilityId: "ProfileEditAddLink" + ) { + showAddLinkSheet = true + } + } + } + + @ViewBuilder + private func linkRow(link: ProfileLinkInput, index: Int) -> some View { + VStack(alignment: .leading, spacing: 4) { + CaptionMText(link.label, textColor: .white64) + + HStack { + BodySText(link.url, textColor: .white) + .lineLimit(1) + + Spacer() + + Button { + links.remove(at: index) + } label: { + Image("trash") + .resizable() + .scaledToFit() + .foregroundColor(.white50) + .frame(width: 18, height: 18) + } + .accessibilityLabel(t("common__delete")) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.gray6) + .cornerRadius(8) + } + } + + // MARK: - Tags Section + + @ViewBuilder + private var tagsSection: some View { + VStack(alignment: .leading, spacing: 8) { + if !tags.isEmpty { + CaptionMText(t("profile__create_tags_label"), textColor: .white64) + + WrappingHStack(spacing: 8) { + ForEach(tags, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { + tags.removeAll { $0 == tag } + }) + } + } + } + + IconActionButton( + icon: "tag", + title: t("profile__create_add_tag"), + accessibilityId: "ProfileEditAddTag" + ) { + showAddTagSheet = true + } + } + } +} 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 e0748a038..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 a46d7cd11..deb3287e8 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -258,6 +258,22 @@ enum Env { } } + /// Pubky/Paykit capabilities — production for mainnet, staging for regtest/testnet/signet. + static var pubkyCapabilities: String { + switch network { + case .bitcoin: + return "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw" + default: + return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/staging.paykit/v0/:rw" + } + } + + /// Homegate URL for auto-provisioned identity signup via IP verification. + /// TODO: Switch `.bitcoin` to production Homegate URL once available. + static var homegateUrl: String { + return "https://homegate.staging.pubky.app" + } + 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 ccc66b6eb..5337881a9 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -3,11 +3,13 @@ import SwiftUI struct MainNavView: View { @EnvironmentObject private var app: AppViewModel @Environment(CameraManager.self) private var cameraManager + @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 @@ -89,6 +91,14 @@ struct MainNavView: View { ) { config in LnurlAuthSheet(config: config) } + .sheet( + item: $sheets.pubkyAuthApprovalSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in PubkyAuthApprovalSheet(config: config) + } .sheet( item: $sheets.lnurlWithdrawSheetItem, onDismiss: { @@ -269,7 +279,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() @@ -305,10 +324,46 @@ 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 { + PubkyChoiceView() + } else { + ProfileIntroView() + } + } else { + ContactsIntroView() + } + case .contactsIntro: ContactsIntroView() + case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey) + case .contactImportOverview: + ContactImportOverviewView( + profile: contactsManager.pendingImportProfile ?? PubkyProfile.placeholder(publicKey: ""), + contacts: contactsManager.pendingImportContacts + ) + case .contactImportSelect: + ContactImportSelectView(contacts: contactsManager.pendingImportContacts) + case let .addContact(publicKey): AddContactView(publicKey: publicKey) + case let .editContact(publicKey): EditContactView(publicKey: publicKey) + case .profile: + if !pubkyProfile.isInitialized { + pubkyLoadingView + } else if pubkyProfile.isAuthenticated { + ProfileView() + } else if app.hasSeenProfileIntro { + PubkyChoiceView() + } else { + ProfileIntroView() + } + case .profileIntro: ProfileIntroView() + case .pubkyChoice: PubkyChoiceView() + case .createProfile: CreateProfileView() + case .editProfile: EditProfileView() + case .payContacts: PayContactsView() // Shop case .shopIntro: ShopIntro() diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift new file mode 100644 index 000000000..2b5ae0680 --- /dev/null +++ b/Bitkit/Managers/ContactsManager.swift @@ -0,0 +1,361 @@ +import Foundation +import SwiftUI + +private let pubkyPrefix = "pubky" + +private func ensurePubkyPrefix(_ key: String) -> String { + key.hasPrefix(pubkyPrefix) ? key : "\(pubkyPrefix)\(key)" +} + +private func stripPubkyPrefix(_ key: String) -> String { + key.hasPrefix(pubkyPrefix) ? String(key.dropFirst(pubkyPrefix.count)) : key +} + +// MARK: - PubkyContact + +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] +} + +// MARK: - ContactsManager + +@MainActor +class ContactsManager: ObservableObject { + @Published var contacts: [PubkyContact] = [] + @Published var isLoading = false + @Published var hasLoaded = false + + /// Temporarily holds contacts discovered during import (e.g., from pubky.app after Ring auth). + /// Cleared after import is completed or discarded. + @Published var pendingImportProfile: PubkyProfile? + @Published var pendingImportContacts: [PubkyContact] = [] + + 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 + } + + // MARK: - Load Contacts (from bitkit.to homeserver) + + func loadContacts(for publicKey: String) async throws { + guard !isLoading else { + Logger.debug("loadContacts skipped — already loading", context: "ContactsManager") + return + } + + isLoading = true + defer { + isLoading = false + hasLoaded = true + } + + let basePath = contactsBasePath + Logger.info("Loading contacts from \(basePath) for \(publicKey)", context: "ContactsManager") + + do { + let sessionSecret = try getSessionSecret() + + let contactPaths: [String] + do { + contactPaths = try await Task.detached { + try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: basePath) + }.value + } catch { + Logger.warn("sessionList failed for \(basePath): \(error)", context: "ContactsManager") + if !hasLoaded { + contacts = [] + } + return + } + + Logger.debug("Listed \(contactPaths.count) contacts from homeserver", context: "ContactsManager") + + let strippedKey = stripPubkyPrefix(publicKey) + + let loaded: [PubkyContact] = await withTaskGroup(of: PubkyContact?.self) { group in + for path in contactPaths { + let contactKey = extractPublicKey(from: path) + guard !contactKey.isEmpty else { continue } + + let prefixedKey = ensurePubkyPrefix(contactKey) + let uri = "pubky://\(strippedKey)\(basePath)\(prefixedKey)" + + group.addTask { + do { + let json = try await PubkyService.fetchFileString(uri: uri) + let profileData = try PubkyProfileData.decode(from: json) + let profile = profileData.toProfile(publicKey: prefixedKey) + return PubkyContact(publicKey: prefixedKey, profile: profile) + } catch { + Logger.warn("Failed to load contact data for '\(prefixedKey)': \(error)", context: "ContactsManager") + return PubkyContact(publicKey: prefixedKey, profile: PubkyProfile.placeholder(publicKey: prefixedKey)) + } + } + } + var results: [PubkyContact] = [] + for await contact in group { + if let contact { results.append(contact) } + } + return results + } + + contacts = loaded.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager") + } catch { + Logger.error("Failed to load contacts: \(error)", context: "ContactsManager") + throw error + } + } + + // MARK: - Add Contact (fetch from pubky.app once, then store to bitkit.to) + + func addContact(publicKey: String, existingProfile: PubkyProfile? = nil) async throws { + let prefixedKey = ensurePubkyPrefix(publicKey) + + // Use existing profile if provided (e.g., already fetched during preview), otherwise fetch from pubky.app + let profile: PubkyProfile = if let existingProfile { + existingProfile + } else { + await fetchProfileFromPubkyApp(publicKey: prefixedKey) + } + + // Build PubkyProfileData and write to bitkit.to + let contactData = PubkyProfileData.from(profile: profile) + try await savePubkyProfileData(publicKey: prefixedKey, data: contactData) + + Logger.info("Added contact \(prefixedKey)", context: "ContactsManager") + + let contact = PubkyContact(publicKey: prefixedKey, profile: profile) + contacts.append(contact) + contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } + + // MARK: - Import Contacts (batch fetch from pubky.app, store to bitkit.to) + + func importContacts(publicKeys: [String]) async throws { + let prefixedKeys = publicKeys.map { ensurePubkyPrefix($0) } + + // Fetch profiles from pubky.app and write each to bitkit.to + let loaded: [PubkyContact] = await withTaskGroup(of: PubkyContact?.self) { group in + for key in prefixedKeys { + group.addTask { [self] in + let profile = await fetchProfileFromPubkyApp(publicKey: key) + let contactData = PubkyProfileData.from(profile: profile) + do { + try await savePubkyProfileData(publicKey: key, data: contactData) + } catch { + Logger.warn("Failed to save imported contact '\(key)': \(error)", context: "ContactsManager") + } + return PubkyContact(publicKey: key, profile: profile) + } + } + var results: [PubkyContact] = [] + for await contact in group { + if let contact { results.append(contact) } + } + return results + } + + // Merge with existing contacts, avoiding duplicates + let existingKeys = Set(contacts.map(\.publicKey)) + let newContacts = loaded.filter { !existingKeys.contains($0.publicKey) } + contacts.append(contentsOf: newContacts) + contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + + Logger.info("Imported \(newContacts.count) new contacts", context: "ContactsManager") + } + + // MARK: - Update Contact (edit and save back to bitkit.to) + + func updateContact(publicKey: String, name: String, bio: String, imageUrl: String?, links: [PubkyProfileLink], tags: [String]) async throws { + let prefixedKey = ensurePubkyPrefix(publicKey) + + let contactData = PubkyProfileData( + name: name, + bio: bio, + image: imageUrl, + links: links.map { PubkyProfileData.Link(label: $0.label, url: $0.url) }, + tags: tags + ) + + try await savePubkyProfileData(publicKey: prefixedKey, data: contactData) + + // Update local array + let updatedProfile = contactData.toProfile(publicKey: prefixedKey) + if let index = contacts.firstIndex(where: { $0.publicKey == prefixedKey }) { + contacts[index] = PubkyContact(publicKey: prefixedKey, profile: updatedProfile) + contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } + + Logger.info("Updated contact \(prefixedKey)", context: "ContactsManager") + } + + // MARK: - Delete Contact + + func removeContact(publicKey: String) async throws { + let prefixedKey = ensurePubkyPrefix(publicKey) + let path = "\(contactsBasePath)\(prefixedKey)" + + let sessionSecret = try getSessionSecret() + + try await Task.detached { + try await PubkyService.sessionDelete( + sessionSecret: sessionSecret, + path: path + ) + }.value + + Logger.info("Removed contact \(prefixedKey)", context: "ContactsManager") + + contacts.removeAll { $0.publicKey == prefixedKey } + } + + // MARK: - Discover Remote Contacts (from pubky.app — for Ring auth import flow) + + /// Discover profile and contacts from pubky.app, store as pending imports. + /// Returns true if any import data was found. + @discardableResult + func prepareImport(profile: PubkyProfile?, publicKey: String) async -> Bool { + pendingImportProfile = profile + await discoverRemoteContacts(publicKey: publicKey) + return pendingImportProfile != nil || !pendingImportContacts.isEmpty + } + + /// Fetch the user's contacts from pubky.app and store as pending imports. + func discoverRemoteContacts(publicKey: String) async { + let prefixedKey = ensurePubkyPrefix(publicKey) + + do { + let contactKeys = try await Task.detached { + try await PubkyService.getContacts(publicKey: prefixedKey) + }.value + + Logger.info("Discovered \(contactKeys.count) contacts from pubky.app", context: "ContactsManager") + + let discovered: [PubkyContact] = await withTaskGroup(of: PubkyContact?.self) { group in + for key in contactKeys { + let pk = ensurePubkyPrefix(key) + group.addTask { [self] in + let profile = await fetchProfileFromPubkyApp(publicKey: pk) + return PubkyContact(publicKey: pk, profile: profile) + } + } + var results: [PubkyContact] = [] + for await contact in group { + if let contact { results.append(contact) } + } + return results + } + + pendingImportContacts = discovered.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } catch { + Logger.warn("Failed to discover remote contacts: \(error)", context: "ContactsManager") + pendingImportContacts = [] + } + } + + // MARK: - Fetch Contact Profile (from pubky.app — used only during add/import) + + func fetchContactProfile(publicKey: String) async -> PubkyProfile? { + await fetchProfileFromPubkyApp(publicKey: ensurePubkyPrefix(publicKey)) + } + + // MARK: - Helpers + + private var contactsBasePath: String { + switch Env.network { + case .bitcoin: + return "/pub/bitkit.to/contacts/" + default: + return "/pub/staging.bitkit.to/contacts/" + } + } + + private func getSessionSecret() throws -> String { + guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), + !sessionSecret.isEmpty + else { + throw PubkyServiceError.sessionNotActive + } + return sessionSecret + } + + /// Write PubkyProfileData JSON to homeserver at /pub/bitkit.to/contacts/ + private func savePubkyProfileData(publicKey: String, data: PubkyProfileData) async throws { + let path = "\(contactsBasePath)\(publicKey)" + let sessionSecret = try getSessionSecret() + + let jsonData = try data.encoded() + + try await Task.detached { + try await PubkyService.sessionPut( + sessionSecret: sessionSecret, + path: path, + content: jsonData + ) + }.value + } + + /// Fetch a profile from pubky.app (external, one-time read) + private func fetchProfileFromPubkyApp(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.warn("Failed to fetch profile from pubky.app for '\(prefixedKey)': \(error)", context: "ContactsManager") + return PubkyProfile.placeholder(publicKey: prefixedKey) + } + } + + /// Extract the public key from a path returned by sessionList + private func extractPublicKey(from path: String) -> String { + // sessionList returns paths like "/pub/bitkit.to/contacts/pubkyXYZ" — extract last component + let components = path.split(separator: "/") + guard let last = components.last else { return "" } + return String(last) + } +} diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift new file mode 100644 index 000000000..ac78cf865 --- /dev/null +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -0,0 +1,544 @@ +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 var sessionRestorationFailed = 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 + } + + // Try to import the saved session + do { + let pk = try await PubkyService.importSession(secret: savedSecret) + return InitResult.restored(publicKey: pk) + } catch { + Logger.warn("Failed to import saved session, attempting re-sign-in: \(error)", context: "PubkyProfileManager") + } + + // Session import failed — try to recover using stored secret key + if let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), + !secretKeyHex.isEmpty + { + do { + let newSession = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + try Keychain.upsert(key: .paykitSession, data: Data(newSession.utf8)) + let pk = try await PubkyService.importSession(secret: newSession) + Logger.info("Re-signed in and restored session for \(pk)", context: "PubkyProfileManager") + return InitResult.restored(publicKey: pk) + } catch { + Logger.error("Re-sign-in failed: \(error)", context: "PubkyProfileManager") + return InitResult.restorationFailed + } + } + + // No secret key available (Ring-managed) — cannot re-sign-in + Logger.warn("No secret key to recover session", context: "PubkyProfileManager") + 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: + authState = .idle + sessionRestorationFailed = true + } + } + + // MARK: - Key Derivation & Identity Creation + + /// Derive the Pubky keypair from the wallet's BIP39 seed. + /// Returns (publicKeyZ32, secretKeyHex). + func deriveKeys() async throws -> (String, String) { + return try await Task.detached { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { + throw PubkyServiceError.authFailed("Mnemonic not found") + } + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: 0)) + + let seed = try PubkyService.mnemonicToSeed(mnemonic: mnemonic, passphrase: passphrase) + let secretKeyHex = try PubkyService.derivePubkySecretKey(seed: seed) + let rawKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + let publicKeyZ32 = rawKey.hasPrefix("pubky") ? rawKey : "pubky\(rawKey)" + return (publicKeyZ32, secretKeyHex) + }.value + } + + /// Fetch a signup code and homeserver public key from Homegate's IP verification endpoint. + struct HomegateResponse: Decodable { + let signupCode: String + let homeserverPubky: String + } + + private static func fetchHomegateSignupCode() async throws -> HomegateResponse { + let url = URL(string: "\(Env.homegateUrl)/ip_verification")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw PubkyServiceError.authFailed("Homegate returned status \(statusCode)") + } + + let decoder = JSONDecoder() + return try decoder.decode(HomegateResponse.self, from: data) + } + + /// Upload an avatar image to the user's homeserver blob storage. Returns the `pubky://` URI. + func uploadAvatar(image: UIImage) async throws -> String { + guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), + !sessionSecret.isEmpty + else { + // If no session yet (creating identity), use secret key to upload + guard let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), + !secretKeyHex.isEmpty + else { + throw PubkyServiceError.sessionNotActive + } + + let rawKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + let imageData = compressAvatar(image) + let blobPath = avatarBlobPath() + let blobUri = "pubky://\(Self.stripPubkyPrefix(rawKey))\(blobPath)" + + try await PubkyService.putWithSecretKey(secretKeyHex: secretKeyHex, path: blobPath, content: imageData) + return blobUri + } + + let pk = Self.stripPubkyPrefix(publicKey ?? "") + let imageData = compressAvatar(image) + let blobPath = avatarBlobPath() + let blobUri = "pubky://\(pk)\(blobPath)" + + try await PubkyService.sessionPut(sessionSecret: sessionSecret, path: blobPath, content: imageData) + return blobUri + } + + /// Strip the `pubky` prefix from a public key for use in `pubky://` URIs. + private static func stripPubkyPrefix(_ key: String) -> String { + key.hasPrefix("pubky") ? String(key.dropFirst(5)) : key + } + + private func compressAvatar(_ image: UIImage, maxSize: CGFloat = 400) -> Data { + // Resize to max dimensions + let scale = min(maxSize / image.size.width, maxSize / image.size.height, 1.0) + let newSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + let resized = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + // Compress as JPEG + return resized.jpegData(compressionQuality: 0.8) ?? Data() + } + + private func avatarBlobPath() -> String { + let timestamp = Int(Date().timeIntervalSince1970 * 1000) + switch Env.network { + case .bitcoin: + return "/pub/bitkit.to/blobs/\(timestamp).jpg" + default: + return "/pub/staging.bitkit.to/blobs/\(timestamp).jpg" + } + } + + /// Create a new Pubky identity: fetch signup code from Homegate, signup on homeserver, + /// persist keys + session, upload avatar, write profile. Falls back to signIn if already registered. + func createIdentity( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String] = [], + avatarImage: UIImage? = nil + ) async throws { + let (publicKeyZ32, secretKeyHex) = try await deriveKeys() + + // Sign up on homeserver via Homegate + let sessionSecret = try await Task.detached { + // 1. Get signup code from Homegate + let homegate = try await Self.fetchHomegateSignupCode() + + // 2. Sign up — if already registered, fall back to signIn + var session: String + do { + session = try await PubkyService.signUp( + secretKeyHex: secretKeyHex, + homeserverZ32: homegate.homeserverPubky, + signupCode: homegate.signupCode + ) + } catch { + Logger.info("signUp failed (likely already registered), trying signIn: \(error)", context: "PubkyProfileManager") + session = try await PubkyService.signIn(secretKeyHex: secretKeyHex) + } + + // 3. Persist secret key and session + try Keychain.upsert(key: .pubkySecretKey, data: Data(secretKeyHex.utf8)) + try Keychain.upsert(key: .paykitSession, data: Data(session.utf8)) + + // 4. Import session into Paykit + _ = try await PubkyService.importSession(secret: session) + + return session + }.value + + publicKey = publicKeyZ32 + authState = .authenticated + + // Upload avatar after session is established + var avatarUri: String? + if let avatarImage { + avatarUri = try await uploadAvatar(image: avatarImage) + } + + // Write profile data to homeserver + try await writeProfile( + sessionSecret: sessionSecret, + name: name, + bio: bio, + imageUrl: avatarUri, + links: links, + tags: tags + ) + + // Set profile locally from the data we just wrote (avoids re-fetching from a different namespace) + let createdProfile = PubkyProfile( + publicKey: publicKeyZ32, + name: name, + bio: bio, + imageUrl: avatarUri, + links: links, + tags: tags, + status: nil + ) + profile = createdProfile + cacheProfileMetadata(createdProfile) + + Logger.info("Pubky identity created for \(publicKeyZ32)", context: "PubkyProfileManager") + } + + /// Update profile data on the homeserver (for edit mode). + func saveProfile( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String] = [], + newImageUrl: String? = nil + ) async throws { + guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), + !sessionSecret.isEmpty + else { + throw PubkyServiceError.sessionNotActive + } + + let resolvedImageUrl = newImageUrl ?? profile?.imageUrl + + try await writeProfile( + sessionSecret: sessionSecret, + name: name, + bio: bio, + imageUrl: resolvedImageUrl, + links: links, + tags: tags + ) + + // Update profile locally from the data we just wrote + let pk = publicKey ?? "" + let updatedProfile = PubkyProfile( + publicKey: pk, + name: name, + bio: bio, + imageUrl: resolvedImageUrl, + links: links, + tags: tags, + status: profile?.status + ) + profile = updatedProfile + cacheProfileMetadata(updatedProfile) + } + + /// Serialize profile JSON and PUT to homeserver. + private func writeProfile( + sessionSecret: String, + name: String, + bio: String, + imageUrl: String?, + links: [PubkyProfileLink], + tags: [String] = [] + ) async throws { + let profileData = PubkyProfileData( + name: name, + bio: bio, + image: imageUrl, + links: links.map { PubkyProfileData.Link(label: $0.label, url: $0.url) }, + tags: tags + ) + + let jsonData = try profileData.encoded() + let path = Self.profilePath + + try await Task.detached { + try await PubkyService.sessionPut( + sessionSecret: sessionSecret, + path: path, + content: jsonData + ) + }.value + } + + private static var profilePath: String { + switch Env.network { + case .bitcoin: + return "/pub/bitkit.to/profile.json" + default: + return "/pub/staging.bitkit.to/profile.json" + } + } + + // MARK: - Auth Flow (Ring) + + 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 { + // Prefer our bitkit profile.json — it has the complete data we wrote + if let bitkitProfile = await Self.fetchBitkitProfile(publicKey: pk) { + Logger.debug("Profile loaded from bitkit profile.json — name: \(bitkitProfile.name)", context: "PubkyProfileManager") + return bitkitProfile + } + + // Fall back to the generic pubky profile via FFI + let profileDto = try await PubkyService.getProfile(publicKey: pk) + Logger.debug( + "Profile loaded from pubky FFI — 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 + } + + /// Fetch a remote profile by public key. Returns nil if no profile exists. + func fetchRemoteProfile(publicKey: String) async -> PubkyProfile? { + // Try bitkit profile.json first, then fall back to FFI + if let bitkitProfile = await Self.fetchBitkitProfile(publicKey: publicKey) { + return bitkitProfile + } + do { + let profileDto = try await PubkyService.getProfile(publicKey: publicKey) + return PubkyProfile(publicKey: publicKey, ffiProfile: profileDto) + } catch { + Logger.debug("No remote profile found for \(publicKey): \(error)", context: "PubkyProfileManager") + return nil + } + } + + /// Read the user's bitkit profile.json which contains the complete profile data we wrote. + private static func fetchBitkitProfile(publicKey: String) async -> PubkyProfile? { + let strippedKey = stripPubkyPrefix(publicKey) + let uri = "pubky://\(strippedKey)\(profilePath)" + + do { + let jsonString = try await PubkyService.fetchFileString(uri: uri) + let profileData = try PubkyProfileData.decode(from: jsonString) + Logger.debug("Fetched bitkit profile.json for \(publicKey)", context: "PubkyProfileManager") + return profileData.toProfile(publicKey: publicKey) + } catch { + Logger.debug("Could not fetch bitkit profile.json: \(error)", context: "PubkyProfileManager") + return nil + } + } + + // 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) + try? Keychain.delete(key: .pubkySecretKey) + 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/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift new file mode 100644 index 000000000..fc661db37 --- /dev/null +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -0,0 +1,76 @@ +import BitkitCore +import Foundation + +// MARK: - PubkyAuth Permission + +struct PubkyAuthPermission { + let path: String + let accessLevel: String + + var displayAccess: String { + var levels: [String] = [] + if accessLevel.contains("r") { levels.append("READ") } + if accessLevel.contains("w") { levels.append("WRITE") } + return levels.joined(separator: ", ") + } +} + +// MARK: - PubkyAuth Request (parsed from pubkyauth:// URL) + +struct PubkyAuthRequest { + let rawUrl: String + let kind: PubkyAuthKind + let relay: String + let permissions: [PubkyAuthPermission] + let serviceNames: [String] + + /// Parse a `pubkyauth://` URL into a display-ready request. + /// Uses BitkitCore FFI `parsePubkyAuthUrl` for URL parsing, then extracts permissions from caps. + static func parse(url: String) throws -> PubkyAuthRequest { + let details = try parsePubkyAuthUrl(authUrl: url) + let permissions = parseCapabilities(details.capabilities) + let serviceNames = permissions.compactMap { extractServiceName($0.path) } + return PubkyAuthRequest( + rawUrl: url, + kind: details.kind, + relay: details.relay, + permissions: permissions, + serviceNames: serviceNames + ) + } + + /// Parse a capabilities string like `/pub/pubky.app/:rw,/pub/paykit/v0/:rw` + /// into individual permission entries. + static func parseCapabilities(_ caps: String) -> [PubkyAuthPermission] { + caps + .split(separator: ",") + .compactMap { segment -> PubkyAuthPermission? in + let trimmed = segment.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + + // Find the last `:` that separates path from access flags + // e.g., "/pub/pubky.app/:rw" → path="/pub/pubky.app/", access="rw" + guard let lastColon = trimmed.lastIndex(of: ":") else { return nil } + + let path = String(trimmed[trimmed.startIndex ..< lastColon]) + let access = String(trimmed[trimmed.index(after: lastColon)...]) + + guard !path.isEmpty, !access.isEmpty else { return nil } + + return PubkyAuthPermission(path: path, accessLevel: access) + } + } + + /// Extract a human-readable service name from a permission path. + /// e.g., "/pub/pubky.app/" → "pubky.app", "/pub/paykit/v0/" → "paykit" + static func extractServiceName(_ path: String) -> String? { + let components = path + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .split(separator: "/") + + // Skip "pub" prefix, take the next meaningful component + guard components.count >= 2 else { return nil } + let name = String(components[1]) + return name.isEmpty ? nil : name + } +} diff --git a/Bitkit/Models/PubkyProfile.swift b/Bitkit/Models/PubkyProfile.swift new file mode 100644 index 000000000..9ac1f97fc --- /dev/null +++ b/Bitkit/Models/PubkyProfile.swift @@ -0,0 +1,135 @@ +import BitkitCore +import Foundation + +// MARK: - PubkyProfileData (shared Codable format for profile & contact JSON on homeserver) + +struct PubkyProfileData: Codable { + var name: String + var bio: String + var image: String? + var links: [Link] + var tags: [String] + + struct Link: Codable { + let label: String + let url: String + } + + init(name: String, bio: String, image: String?, links: [Link], tags: [String]) { + self.name = name + self.bio = bio + self.image = image + self.links = links + self.tags = tags + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + bio = try container.decode(String.self, forKey: .bio) + image = try container.decodeIfPresent(String.self, forKey: .image) + links = try container.decode([Link].self, forKey: .links) + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + } + + func toProfile(publicKey: String, status: String? = nil) -> PubkyProfile { + PubkyProfile( + publicKey: publicKey, + name: name, + bio: bio, + imageUrl: image, + links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, + tags: tags, + status: status + ) + } + + static func from(profile: PubkyProfile) -> PubkyProfileData { + PubkyProfileData( + name: profile.name, + bio: profile.bio, + image: profile.imageUrl, + links: profile.links.map { Link(label: $0.label, url: $0.url) }, + tags: profile.tags + ) + } + + func encoded() throws -> Data { + try JSONEncoder().encode(self) + } + + static func decode(from jsonString: String) throws -> PubkyProfileData { + guard let data = jsonString.data(using: .utf8) else { + throw NSError(domain: "PubkyProfileData", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid UTF-8"]) + } + return try JSONDecoder().decode(PubkyProfileData.self, from: data) + } +} + +// MARK: - PubkyProfileLink + +struct PubkyProfileLink: Identifiable, Sendable { + let id = UUID() + let label: String + let url: String +} + +// MARK: - PubkyProfile + +struct PubkyProfile: Sendable { + let publicKey: String + let name: String + let bio: String + let imageUrl: String? + let links: [PubkyProfileLink] + let tags: [String] + 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 + tags = [] + + 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], tags: [String] = [], status: String?) { + self.publicKey = publicKey + self.name = name + self.bio = bio + self.imageUrl = imageUrl + self.links = links + self.tags = tags + 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 264e98eb7..f0bbd7dbb 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -47,7 +47,7 @@ "common__are_you_sure" = "Are You Sure?"; "common__yes_proceed" = "Yes, Proceed"; "common__try_again" = "Try Again"; -"common__dialog_cancel" = "No, Cancel"; +"common__dialog_cancel" = "Cancel"; "common__sat_vbyte" = "₿ / vbyte"; "common__sat_vbyte_compact" = "₿/vbyte"; "common__copy" = "Copy"; @@ -60,6 +60,7 @@ "common__done" = "Done"; "common__delete" = "Delete"; "common__delete_yes" = "Yes, Delete"; +"common__paste" = "Paste"; "common__off" = "Off"; "common__ok" = "OK"; "common__on" = "On"; @@ -602,6 +603,23 @@ "security__authorization__pubky_secret_error_description" = "Unable to retrieve Pubky key"; "security__authorization__pubky_auth_error_title" = "Pubky Auth Error"; "security__authorization__pubky_auth_error_description" = "Unable to auth with Pubky service"; +"pubky_auth__title" = "Authorize"; +"pubky_auth__description_prefix" = "A service is requesting permission to access and edit your "; +"pubky_auth__description_suffix" = " data."; +"pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS"; +"pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky."; +"pubky_auth__authorizing" = "Authorizing..."; +"pubky_auth__success_title" = "Authorization Successful"; +"pubky_auth__success_prefix" = "You authorized with pubky "; +"pubky_auth__success_middle" = " and gave the service permission to access and edit your "; +"pubky_auth__success_suffix" = " data."; +"pubky_auth__biometric_failed" = "Authentication Failed"; +"pubky_auth__no_identity" = "Pubky Identity Required"; +"pubky_auth__no_identity_desc" = "Create a Pubky identity in your profile to approve auth requests."; +"pubky_auth__use_ring" = "Use Pubky Ring"; +"pubky_auth__use_ring_desc" = "Your identity was created with Pubky Ring. Open Ring to approve this request."; +"pubky_auth__invalid_request" = "Invalid auth request"; +"pubky_auth__approval_failed" = "Authorization Failed"; "settings__settings" = "Settings"; "settings__dev_enabled_title" = "Dev Options Enabled"; "settings__dev_enabled_message" = "Developer options are now enabled throughout the app."; @@ -921,6 +939,46 @@ "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}"; +"contacts__add_title" = "Add Contact"; +"contacts__add_description" = "Add a new contact by scanning their QR or pasting their pubky below."; +"contacts__add_pubky_label" = "PUBKY"; +"contacts__add_scan_qr" = "Scan QR"; +"contacts__add_button" = "Add"; +"contacts__add_retrieving" = "Retrieving\ncontact info"; +"contacts__add_success" = "Contact added"; +"contacts__add_error" = "Failed to add contact"; +"contacts__add_disclaimer" = "Please note that you and {name} must add each other as contacts to pay each other privately. Otherwise, the payment will be visible publicly."; +"contacts__import_nav_title" = "Import"; +"contacts__import_found_title" = "Found\nprofile & contacts"; +"contacts__import_found_description" = "Bitkit found profile and contacts data connected to pubky {key}"; +"contacts__import_select" = "Select"; +"contacts__import_all" = "Import All"; +"contacts__import_error" = "Failed to import contacts"; +"contacts__import_select_title" = "Select\ncontacts"; +"contacts__import_select_description" = "Please select which friends you want to import."; +"contacts__import_select_all" = "Select all"; +"contacts__import_select_none" = "Select none"; +"contacts__import_friends_count" = "{count} friends"; +"contacts__import_selected_count" = "{count} selected"; +"contacts__delete_title" = "Delete {name}?"; +"contacts__delete_description" = "This contact will be removed from your list."; +"contacts__delete_confirm" = "Yes, Delete"; +"contacts__delete_success" = "Contact deleted"; +"contacts__delete_error" = "Failed to delete contact"; +"contacts__edit_title" = "Edit Contact"; +"contacts__edit_saved" = "Contact saved"; +"contacts__edit_error" = "Failed to save contact"; +"contacts__error_saving" = "Failed to save changes"; +"contacts__error_loading" = "Failed to load contact"; "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"; @@ -956,6 +1014,62 @@ "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…"; +"profile__ring_loading" = "Loading your profile…"; +"profile__choice_title" = "Join the\npubky web"; +"profile__choice_description" = "Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring."; +"profile__choice_create" = "Create profile with Bitkit"; +"profile__choice_import" = "Import with Pubky Ring"; +"profile__create_nav_title" = "Create Profile"; +"profile__restore_nav_title" = "Restore Profile"; +"profile__create_name_placeholder" = "YOUR NAME"; +"profile__create_pubky_display_label" = "YOUR PUBKY"; +"profile__create_pubky_label" = "PUBKY"; +"profile__create_username_label" = "USERNAME"; +"profile__create_avatar_label" = "AVATAR"; +"profile__create_bio_label" = "BIO"; +"profile__create_bio_placeholder" = "Short bio. Tell a bit about yourself."; +"profile__create_add_link" = "Add Link"; +"profile__create_add_tag" = "Add Tag"; +"profile__create_tags_label" = "TAGS"; +"profile__create_disclaimer" = "Please note that all your profile information will be publicly available and visible."; +"profile__create_error_title" = "Profile Error"; +"profile__edit_error_title" = "Profile Error"; +"profile__session_expired_title" = "Profile Disconnected"; +"profile__session_expired_description" = "Your profile session has expired. Please reconnect to restore your profile."; +"profile__edit_nav_title" = "Edit Profile"; +"profile__edit_saved" = "Profile updated"; +"profile__edit" = "Edit"; +"profile__pay_contacts_nav_title" = "Pay Contacts"; +"profile__pay_contacts_title" = "Let your contacts\npay you"; +"profile__pay_contacts_description" = "Use Bitkit with your contacts to send payments directly, anytime, anywhere."; +"profile__pay_contacts_toggle" = "Share payment data and enable payments with contacts"; +"profile__add_link_title" = "Add Link"; +"profile__add_link_label" = "LABEL"; +"profile__add_link_label_placeholder" = "For example 'Website'"; +"profile__add_link_url" = "LINK OR TEXT"; +"profile__add_link_url_placeholder" = "https://"; +"profile__add_link_note" = "Note: Any link you add will be publicly visible."; +"profile__add_tag_title" = "Add Tag"; +"profile__add_tag_label" = "TAG"; +"profile__add_tag_placeholder" = "For example: 'Developer'"; +"profile__suggestions_title" = "Suggestions To Add"; "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..443af1c52 --- /dev/null +++ b/Bitkit/Services/PubkyService.swift @@ -0,0 +1,234 @@ +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: - Auth Approval (Bitkit as authenticator) + + /// Parse a pubkyauth:// URL to extract details for UI display. + static func parseAuthUrl(_ authUrl: String) throws -> BitkitCore.PubkyAuthDetails { + try parsePubkyAuthUrl(authUrl: authUrl) + } + + /// Approve a pubkyauth:// request using the local secret key. + static func approveAuth(authUrl: String, secretKeyHex: String) async throws { + try await ServiceQueue.background(.core) { + try await approvePubkyAuth(authUrl: authUrl, secretKeyHex: secretKeyHex) + } + } + + // MARK: - Key Derivation + + /// Convert a BIP39 mnemonic to a seed. + static func mnemonicToSeed(mnemonic: String, passphrase: String? = nil) throws -> Data { + try BitkitCore.mnemonicToSeed(mnemonicPhrase: mnemonic, passphrase: passphrase) + } + + /// Derive an Ed25519 secret key from a BIP39 seed. Returns hex-encoded 32-byte key. + static func derivePubkySecretKey(seed: Data) throws -> String { + try BitkitCore.derivePubkySecretKey(seed: seed) + } + + /// Derive the z32-encoded public key from a hex-encoded secret key. + static func pubkyPublicKeyFromSecret(secretKeyHex: String) throws -> String { + try BitkitCore.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + } + + // MARK: - Homeserver Auth + + /// Sign up on a homeserver. Returns session secret for persistence. + static func signUp(secretKeyHex: String, homeserverZ32: String, signupCode: String? = nil) async throws -> String { + try await ServiceQueue.background(.core) { + try await pubkySignUp(secretKeyHex: secretKeyHex, homeserverPublicKeyZ32: homeserverZ32, signupCode: signupCode) + } + } + + /// Sign in with an existing secret key. Returns new session secret. + static func signIn(secretKeyHex: String) async throws -> String { + try await ServiceQueue.background(.core) { + try await pubkySignIn(secretKeyHex: secretKeyHex) + } + } + + // MARK: - Authenticated Storage + + /// Write content to a path on the user's homeserver. + static func sessionPut(sessionSecret: String, path: String, content: Data) async throws { + try await ServiceQueue.background(.core) { + try await pubkySessionPut(sessionSecret: sessionSecret, path: path, content: content) + } + } + + /// Delete a resource at path on the user's homeserver. + static func sessionDelete(sessionSecret: String, path: String) async throws { + try await ServiceQueue.background(.core) { + try await pubkySessionDelete(sessionSecret: sessionSecret, path: path) + } + } + + /// List resources in a directory on the user's homeserver. + static func sessionList(sessionSecret: String, dirPath: String) async throws -> [String] { + try await ServiceQueue.background(.core) { + try await pubkySessionList(sessionSecret: sessionSecret, dirPath: dirPath) + } + } + + /// Sign in with secret key and write content in one shot. + static func putWithSecretKey(secretKeyHex: String, path: String, content: Data) async throws { + try await ServiceQueue.background(.core) { + try await pubkyPutWithSecretKey(secretKeyHex: secretKeyHex, path: path, content: content) + } + } + + // 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) { + try await fetchPubkyFile(uri: uri) + } + } + + /// Fetch a public resource from a `pubky://` URI and return as a UTF-8 string. + static func fetchFileString(uri: String) async throws -> String { + try await ServiceQueue.background(.core) { + try await fetchPubkyFileString(uri: uri) + } + } + + // 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..6e3d0285e 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -6,6 +6,8 @@ enum KeychainEntryType { case bip39Passphrase(index: Int) case pushNotificationPrivateKey // For secp256k1 shared secret when decrypting push payload case securityPin + case paykitSession + case pubkySecretKey var storageKey: String { switch self { @@ -17,6 +19,10 @@ enum KeychainEntryType { return "push_notification_private_key" case .securityPin: return "security_pin" + case .paykitSession: + return "paykit_session" + case .pubkySecretKey: + return "pubky_secret_key" } } } @@ -83,6 +89,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 +102,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/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 998f2036e..a66ff348c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -453,6 +453,8 @@ extension AppViewModel { } handleNodeUri(url) + case let .pubkyAuth(data: authUrl): + handlePubkyAuthApproval(authUrl) case let .gift(code, amount): sheetViewModel.showSheet(.gift, data: GiftConfig(code: code, amount: Int(amount))) default: @@ -566,6 +568,31 @@ extension AppViewModel { sheetViewModel.showSheet(.lnurlAuth, data: LnurlAuthConfig(lnurl: lnurl, authData: data)) } + private func handlePubkyAuthApproval(_ authUrl: String) { + // State 1: No Pubky identity at all + guard (try? Keychain.loadString(key: .paykitSession))?.isEmpty == false else { + toast(type: .warning, title: t("pubky_auth__no_identity"), description: t("pubky_auth__no_identity_desc")) + return + } + + // State 2: Ring-authenticated (has session but no local secret key) + guard let secretKey = try? Keychain.loadString(key: .pubkySecretKey), + !secretKey.isEmpty + else { + toast(type: .info, title: t("pubky_auth__use_ring"), description: t("pubky_auth__use_ring_desc")) + return + } + + // State 3: Bitkit-generated identity — can approve + do { + let request = try PubkyAuthRequest.parse(url: authUrl) + sheetViewModel.showSheet(.pubkyAuthApproval, data: PubkyAuthApprovalConfig(authUrl: authUrl, request: request)) + } catch { + Logger.error("Failed to parse pubky auth URL: \(error)", context: "AppViewModel") + toast(type: .error, title: t("pubky_auth__invalid_request")) + } + } + private func handleNodeUri(_ url: String) { sheetViewModel.hideSheet() navigationViewModel.navigate(.fundManual(nodeUri: url)) diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index d8ff73db5..c0d0e6f22 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -11,8 +11,17 @@ enum Route: Hashable { case buyBitcoin case contacts case contactsIntro + case contactDetail(publicKey: String) + case contactImportOverview + case contactImportSelect + case addContact(publicKey: String) + case editContact(publicKey: String) case profile case profileIntro + case pubkyChoice + case createProfile + case editProfile + case payContacts case transferIntro case fundingOptions case spendingIntro diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index e811dd6cc..a4fa12073 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -12,6 +12,7 @@ enum SheetID: String, CaseIterable { case highBalance case lnurlAuth case lnurlWithdraw + case pubkyAuthApproval case notifications case quickpay case receive @@ -232,6 +233,20 @@ class SheetViewModel: ObservableObject { } } + var pubkyAuthApprovalSheetItem: PubkyAuthApprovalSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .pubkyAuthApproval else { return nil } + let pubkyConfig = config.data as? PubkyAuthApprovalConfig + guard let authUrl = pubkyConfig?.authUrl, let request = pubkyConfig?.request else { return nil } + return PubkyAuthApprovalSheetItem(authUrl: authUrl, request: request) + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } + var notificationsSheetItem: NotificationsSheetItem? { get { guard let config = activeSheetConfiguration, config.id == .notifications else { return nil } diff --git a/Bitkit/Views/Contacts/AddContactSheet.swift b/Bitkit/Views/Contacts/AddContactSheet.swift new file mode 100644 index 000000000..ad0e586b2 --- /dev/null +++ b/Bitkit/Views/Contacts/AddContactSheet.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct AddContactSheet: View { + @Environment(\.dismiss) private var dismiss + + let onAdd: (String) -> Void + let onScanQR: () -> Void + + @State private var pubkyInput: String = "" + + private var canAdd: Bool { + !pubkyInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("contacts__add_title")) + + VStack(alignment: .leading, spacing: 16) { + BodyMText(t("contacts__add_description")) + + Spacer() + + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("contacts__add_pubky_label"), textColor: .white64) + + HStack(spacing: 8) { + TextField( + "", + text: $pubkyInput, + backgroundColor: .clear, + font: .custom(Fonts.regular, size: 17), + testIdentifier: "AddContactPubkyField" + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button { + if let clipboard = UIPasteboard.general.string { + pubkyInput = clipboard + } + } label: { + Image("clipboard") + .resizable() + .scaledToFit() + .foregroundColor(.white64) + .frame(width: 24, height: 24) + } + .accessibilityIdentifier("AddContactPaste") + .accessibilityLabel(t("common__paste")) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.white08) + .cornerRadius(8) + } + + HStack(spacing: 16) { + CustomButton( + title: t("contacts__add_scan_qr"), + variant: .secondary, + icon: Image(systemName: "viewfinder") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white80) + ) { + onScanQR() + dismiss() + } + .accessibilityIdentifier("AddContactScanQR") + + CustomButton(title: t("contacts__add_button")) { + onAdd(pubkyInput.trimmingCharacters(in: .whitespacesAndNewlines)) + dismiss() + } + .disabled(!canAdd) + .accessibilityIdentifier("AddContactAdd") + } + } + .padding(.horizontal, 16) + } + .sheetBackground() + .presentationDetents([.medium]) + .presentationCornerRadius(32) + .presentationDragIndicator(.hidden) + } +} + +#Preview { + Color.clear + .sheet(isPresented: .constant(true)) { + AddContactSheet(onAdd: { _ in }, onScanQR: {}) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift new file mode 100644 index 000000000..a128d5065 --- /dev/null +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -0,0 +1,206 @@ +import SwiftUI + +struct AddContactView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var pubkyProfile: PubkyProfileManager + + let publicKey: String + + @State private var fetchedProfile: PubkyProfile? + @State private var isLoading = true + @State private var isSaving = false + + private var truncatedPublicKey: String { + guard publicKey.count > 10 else { return publicKey } + return "\(publicKey.prefix(4))...\(publicKey.suffix(4))" + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("contacts__add_title")) + .padding(.horizontal, 16) + + if isLoading && fetchedProfile == nil { + loadingContent + } else if let profile = fetchedProfile { + resultContent(profile) + } else { + errorContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + await loadProfile() + } + } + + // MARK: - Loading State + + @State private var dashedCircleRotation: Double = 0 + + @ViewBuilder + private var loadingContent: some View { + VStack(spacing: 0) { + CaptionMText(truncatedPublicKey, textColor: .white64) + .padding(.top, 24) + .padding(.bottom, 16) + + Circle() + .fill(Color.white.opacity(0.1)) + .frame(width: 80, height: 80) + .overlay { + Text(String(publicKey.prefix(1)).uppercased()) + .font(Fonts.bold(size: 28)) + .foregroundColor(.textPrimary) + } + .accessibilityHidden(true) + .padding(.bottom, 24) + + DisplayText(t("contacts__add_retrieving"), accentColor: .pubkyGreen) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("AddContactRetrievingTitle") + + Spacer() + + retrievingAnimation + + Spacer() + } + .padding(.horizontal, 32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + withAnimation(.linear(duration: 8).repeatForever(autoreverses: false)) { + dashedCircleRotation = 360 + } + } + } + + @ViewBuilder + private var retrievingAnimation: some View { + ZStack { + Image("ellipse-outer-green") + .resizable() + .scaledToFit() + .frame(width: 311, height: 311) + .rotationEffect(.degrees(dashedCircleRotation)) + + Image("ellipse-inner-green") + .resizable() + .scaledToFit() + .frame(width: 207, height: 207) + .rotationEffect(.degrees(-dashedCircleRotation)) + + Image("contact-card") + .resizable() + .scaledToFit() + .frame(width: 256, height: 256) + } + } + + // MARK: - Result State + + @ViewBuilder + private func resultContent(_ profile: PubkyProfile) -> some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + CenteredProfileHeader( + truncatedKey: profile.truncatedPublicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl + ) + .padding(.top, 24) + } + .padding(.horizontal, 16) + } + + Spacer() + + BodySText( + t("contacts__add_disclaimer", variables: ["name": profile.name]), + textColor: .white50 + ) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + .padding(.bottom, 16) + + HStack(spacing: 16) { + CustomButton(title: t("common__discard"), variant: .secondary) { + navigation.navigateBack() + } + .accessibilityIdentifier("AddContactDiscard") + + CustomButton(title: t("common__save"), isLoading: isSaving) { + await saveContact() + } + .disabled(isSaving) + .accessibilityIdentifier("AddContactSave") + } + .padding(.horizontal, 32) + .padding(.bottom, 32) + } + } + + // MARK: - Error State + + @ViewBuilder + private var errorContent: some View { + VStack(spacing: 16) { + Spacer() + BodyMText(t("contacts__add_error")) + CustomButton(title: t("profile__retry_load"), variant: .secondary) { + await loadProfile() + } + .accessibilityIdentifier("AddContactRetry") + Spacer() + } + .padding(.horizontal, 32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Data Loading + + private func loadProfile() async { + isLoading = true + if let profile = await contactsManager.fetchContactProfile(publicKey: publicKey) { + fetchedProfile = profile + } + isLoading = false + } + + // MARK: - Save Contact + + private func saveContact() async { + isSaving = true + defer { isSaving = false } + + do { + try await contactsManager.addContact(publicKey: publicKey, existingProfile: fetchedProfile) + app.toast(type: .success, title: t("contacts__add_success")) + navigation.navigateBack() + } catch { + Logger.error("Failed to save contact: \(error)", context: "AddContactView") + app.toast(type: .error, title: t("contacts__add_error")) + } + } +} + +#Preview { + NavigationStack { + AddContactView(publicKey: "pubkyz6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(PubkyProfileManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift new file mode 100644 index 000000000..2d96c40cc --- /dev/null +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -0,0 +1,282 @@ +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 + @State private var showDeleteConfirmation = false + @State private var isDeleting = false + @State private var showAddTagSheet = false + + 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 + } + isLoading = false + } + .onReceive(contactsManager.$contacts) { updatedContacts in + if let cached = updatedContacts.first(where: { $0.publicKey == publicKey }) { + profile = cached.profile + } + } + .alert( + t("contacts__delete_title", variables: ["name": profile?.name ?? ""]), + isPresented: $showDeleteConfirmation + ) { + Button(t("contacts__delete_confirm"), role: .destructive) { + Task { await performDelete() } + } + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("contacts__delete_description")) + } + } + + // MARK: - Contact Body + + @ViewBuilder + private func contactBody(_ profile: PubkyProfile) -> some View { + ScrollView { + VStack(spacing: 0) { + CenteredProfileHeader( + truncatedKey: profile.truncatedPublicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl + ) + .padding(.top, 24) + .padding(.bottom, 24) + + contactActions + .padding(.bottom, 32) + + VStack(alignment: .leading, spacing: 0) { + if !profile.links.isEmpty { + linksSection(profile) + } + + tagsSection(profile) + .padding(.top, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + } + .sheet(isPresented: $showAddTagSheet) { + AddProfileTagSheet { newTag in + addTag(newTag) + } + } + } + + // MARK: - Action Buttons + + @ViewBuilder + private var contactActions: some View { + HStack(spacing: 16) { + GradientCircleButton(icon: "copy", accessibilityLabel: t("common__copy")) { + UIPasteboard.general.string = publicKey + app.toast(type: .success, title: t("common__copied")) + } + .accessibilityIdentifier("ContactCopy") + + GradientCircleButton(icon: "share", accessibilityLabel: t("common__share")) { + shareContact() + } + .accessibilityIdentifier("ContactShare") + + GradientCircleButton(icon: "pencil", accessibilityLabel: t("common__edit")) { + navigation.navigate(.editContact(publicKey: publicKey)) + } + .accessibilityIdentifier("ContactEdit") + + GradientCircleButton(icon: "trash", accessibilityLabel: t("common__delete")) { + showDeleteConfirmation = true + } + .accessibilityIdentifier("ContactDelete") + } + } + + // 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: - Tags + + @ViewBuilder + private func tagsSection(_ profile: PubkyProfile) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__create_tags_label"), textColor: .white64) + + WrappingHStack(spacing: 8) { + ForEach(profile.tags, id: \.self) { tag in + Tag(tag, icon: .close, onDelete: { + removeTag(tag) + }) + } + + addTagButton + } + } + } + + @ViewBuilder + private var addTagButton: some View { + IconActionButton( + icon: "tag", + title: t("profile__create_add_tag"), + accessibilityId: "ContactAddTag" + ) { + showAddTagSheet = true + } + } + + // MARK: - Tag Persistence + + private func addTag(_ newTag: String) { + guard var current = profile else { return } + current = PubkyProfile( + publicKey: current.publicKey, + name: current.name, + bio: current.bio, + imageUrl: current.imageUrl, + links: current.links, + tags: current.tags + [newTag], + status: current.status + ) + profile = current + persistContact(current) + } + + private func removeTag(_ tag: String) { + guard var current = profile else { return } + current = PubkyProfile( + publicKey: current.publicKey, + name: current.name, + bio: current.bio, + imageUrl: current.imageUrl, + links: current.links, + tags: current.tags.filter { $0 != tag }, + status: current.status + ) + profile = current + persistContact(current) + } + + private func persistContact(_ profile: PubkyProfile) { + Task { + do { + try await contactsManager.updateContact( + publicKey: publicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl, + links: profile.links, + tags: profile.tags + ) + } catch { + Logger.error("Failed to persist contact tags: \(error)", context: "ContactDetailView") + app.toast(type: .error, title: t("contacts__error_saving")) + } + } + } + + // 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) {} + .accessibilityIdentifier("ContactRetry") + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Delete + + private func performDelete() async { + isDeleting = true + defer { isDeleting = false } + + do { + try await contactsManager.removeContact(publicKey: publicKey) + app.toast(type: .success, title: t("contacts__delete_success")) + navigation.navigateBack() + } catch { + Logger.error("Failed to delete contact: \(error)", context: "ContactDetailView") + app.toast(type: .error, title: t("contacts__delete_error")) + } + } + + // 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/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift new file mode 100644 index 000000000..6360db0e0 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -0,0 +1,209 @@ +import SwiftUI + +struct ContactImportOverviewView: View { + let profile: PubkyProfile + let contacts: [PubkyContact] + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var pubkyProfile: PubkyProfileManager + + @State private var isImporting = false + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("contacts__import_nav_title")) + .padding(.horizontal, 16) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + DisplayText( + t("contacts__import_found_title"), + accentColor: .pubkyGreen + ) + .padding(.top, 24) + .padding(.bottom, 8) + + BodyMText( + t("contacts__import_found_description", variables: ["key": profile.truncatedPublicKey]) + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 32) + + profileRow + .padding(.bottom, 24) + + CustomDivider() + + contactsSummary + .padding(.top, 24) + } + .padding(.horizontal, 32) + } + + Spacer() + + buttonBar + .padding(.horizontal, 32) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + } + + // MARK: - Profile Row + + @ViewBuilder + private var profileRow: some View { + HStack(alignment: .top, spacing: 16) { + HeadlineText(profile.name) + .fixedSize(horizontal: false, vertical: true) + .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 { + Text(String(profile.name.prefix(1)).uppercased()) + .font(Fonts.bold(size: 22)) + .foregroundColor(.textPrimary) + } + } + } + .accessibilityHidden(true) + } + .accessibilityIdentifier("ContactImportOverviewProfile") + } + + // MARK: - Contacts Summary + + @ViewBuilder + private var contactsSummary: some View { + HStack(spacing: 16) { + BodyMSBText(t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"])) + + Spacer() + + avatarStack + } + .accessibilityIdentifier("ContactImportOverviewSummary") + } + + @ViewBuilder + private var avatarStack: some View { + let displayContacts = Array(contacts.prefix(4)) + let remaining = contacts.count - displayContacts.count + HStack(spacing: -12) { + ForEach(Array(displayContacts.enumerated()), id: \.element.id) { index, contact in + Group { + if let imageUrl = contact.profile.imageUrl { + PubkyImage(uri: imageUrl, size: 40) + } else { + Circle() + .fill(Color.white.opacity(0.1)) + .frame(width: 40, height: 40) + .overlay { + Text(String(contact.displayName.prefix(1)).uppercased()) + .font(Fonts.bold(size: 15)) + .foregroundColor(.textPrimary) + } + } + } + .overlay( + Circle() + .stroke(Color.customBlack, lineWidth: 2) + ) + .zIndex(Double(displayContacts.count - index)) + } + + if remaining > 0 { + Circle() + .fill(Color.white.opacity(0.1)) + .frame(width: 40, height: 40) + .overlay { + Text("+\(remaining)") + .font(Fonts.bold(size: 13)) + .foregroundColor(.textPrimary) + } + .overlay( + Circle() + .stroke(Color.customBlack, lineWidth: 2) + ) + .zIndex(0) + } + } + .accessibilityHidden(true) + } + + // MARK: - Button Bar + + @ViewBuilder + private var buttonBar: some View { + HStack(spacing: 16) { + CustomButton(title: t("contacts__import_select"), variant: .secondary) { + navigation.navigate(.contactImportSelect) + } + .accessibilityIdentifier("ContactImportOverviewSelect") + + CustomButton( + title: t("contacts__import_all"), + isLoading: isImporting + ) { + await importAllContacts() + } + .accessibilityIdentifier("ContactImportOverviewImportAll") + } + } + + // MARK: - Actions + + private func importAllContacts() async { + isImporting = true + defer { isImporting = false } + + do { + try await contactsManager.importContacts(publicKeys: contacts.map(\.publicKey)) + navigation.navigate(.payContacts) + } catch { + app.toast(type: .error, title: t("contacts__import_error")) + } + } +} + +#Preview { + let profile = PubkyProfile( + publicKey: "pubky1abc123def456", + name: "Satoshi", + bio: "Building the future", + imageUrl: nil, + links: [], + status: nil + ) + let contacts = [ + PubkyContact(publicKey: "pubky1aaa111", profile: PubkyProfile( + publicKey: "pubky1aaa111", name: "Alice", bio: "", imageUrl: nil, links: [], status: nil + )), + PubkyContact(publicKey: "pubky1bbb222", profile: PubkyProfile( + publicKey: "pubky1bbb222", name: "Bob", bio: "", imageUrl: nil, links: [], status: nil + )), + PubkyContact(publicKey: "pubky1ccc333", profile: PubkyProfile( + publicKey: "pubky1ccc333", name: "Carol", bio: "", imageUrl: nil, links: [], status: nil + )), + ] + + NavigationStack { + ContactImportOverviewView(profile: profile, contacts: contacts) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(PubkyProfileManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift new file mode 100644 index 000000000..541502a2e --- /dev/null +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -0,0 +1,239 @@ +import SwiftUI + +struct ContactImportSelectView: View { + let contacts: [PubkyContact] + + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var contactsManager: ContactsManager + + @State private var selectedKeys: Set = [] + @State private var isImporting = false + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("contacts__import_nav_title")) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 8) { + DisplayText( + t("contacts__import_select_title"), + accentColor: .pubkyGreen + ) + + BodyMText(t("contacts__import_select_description")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 32) + .padding(.top, 24) + .padding(.bottom, 16) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(contacts) { contact in + contactSelectRow(contact) + CustomDivider() + .padding(.leading, 72) + } + } + .padding(.horizontal, 16) + } + + footerBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + selectedKeys = Set(contacts.map(\.publicKey)) + } + } + + // MARK: - Contact Select Row + + @ViewBuilder + private func contactSelectRow(_ contact: PubkyContact) -> some View { + let isSelected = selectedKeys.contains(contact.publicKey) + + Button { + if isSelected { + selectedKeys.remove(contact.publicKey) + } else { + selectedKeys.insert(contact.publicKey) + } + } label: { + HStack(spacing: 16) { + contactAvatar(name: contact.displayName, imageUrl: contact.profile.imageUrl) + + VStack(alignment: .leading, spacing: 4) { + CaptionText(contact.profile.truncatedPublicKey) + + BodyMSBText(contact.displayName) + .lineLimit(1) + } + + Spacer() + + checkmark(isSelected: isSelected) + } + .padding(.vertical, 12) + } + .accessibilityLabel(contact.displayName) + .accessibilityIdentifier("ContactImportSelect_\(contact.publicKey)") + } + + // MARK: - Checkmark + + @ViewBuilder + private func checkmark(isSelected: Bool) -> some View { + ZStack { + if isSelected { + Circle() + .fill(Color.pubkyGreen) + .frame(width: 24, height: 24) + .overlay { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + } + } else { + Circle() + .stroke(Color.white32, lineWidth: 1.5) + .frame(width: 24, height: 24) + } + } + .accessibilityHidden(true) + } + + // MARK: - Contact Avatar + + @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: - Footer Bar + + @ViewBuilder + private var footerBar: some View { + VStack(spacing: 0) { + CustomDivider() + + HStack(spacing: 12) { + BodySText(t("contacts__import_selected_count", variables: ["count": "\(selectedKeys.count)"])) + + Spacer() + + pillButton(title: t("contacts__import_select_all"), isActive: selectedKeys.count == contacts.count) { + selectedKeys = Set(contacts.map(\.publicKey)) + } + .accessibilityIdentifier("ContactImportSelectAll") + + pillButton(title: t("contacts__import_select_none"), isActive: selectedKeys.isEmpty) { + selectedKeys = [] + } + .accessibilityIdentifier("ContactImportSelectNone") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + CustomButton( + title: t("common__continue"), + isLoading: isImporting + ) { + await importSelectedContacts() + } + .padding(.horizontal, 32) + .padding(.bottom, 16) + .accessibilityIdentifier("ContactImportSelectContinue") + } + } + + // MARK: - Pill Button + + @ViewBuilder + private func pillButton(title: String, isActive: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(Fonts.medium(size: 13)) + .foregroundColor(isActive ? .white64 : .textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + Capsule() + .fill(isActive ? Color.white.opacity(0.05) : Color.white.opacity(0.1)) + ) + .overlay( + Capsule() + .stroke(Color.white10, lineWidth: 1) + ) + } + .disabled(isActive) + .accessibilityLabel(title) + } + + // MARK: - Actions + + private func importSelectedContacts() async { + let selected = contacts.filter { selectedKeys.contains($0.publicKey) } + + guard !selected.isEmpty else { + navigation.navigate(.payContacts) + return + } + + isImporting = true + defer { isImporting = false } + + do { + try await contactsManager.importContacts(publicKeys: selected.map(\.publicKey)) + navigation.navigate(.payContacts) + } catch { + app.toast(type: .error, title: t("contacts__import_error")) + } + } +} + +#Preview { + let contacts = [ + PubkyContact(publicKey: "pubky1aaa111", profile: PubkyProfile( + publicKey: "pubky1aaa111", name: "Alice", bio: "", imageUrl: nil, links: [], status: nil + )), + PubkyContact(publicKey: "pubky1bbb222", profile: PubkyProfile( + publicKey: "pubky1bbb222", name: "Bob", bio: "", imageUrl: nil, links: [], status: nil + )), + PubkyContact(publicKey: "pubky1ccc333", profile: PubkyProfile( + publicKey: "pubky1ccc333", name: "Carol", bio: "", imageUrl: nil, links: [], status: nil + )), + PubkyContact(publicKey: "pubky1ddd444", profile: PubkyProfile( + publicKey: "pubky1ddd444", name: "Dave", bio: "", imageUrl: nil, links: [], status: nil + )), + PubkyContact(publicKey: "pubky1eee555", profile: PubkyProfile( + publicKey: "pubky1eee555", name: "Eve", bio: "", imageUrl: nil, links: [], status: nil + )), + ] + + NavigationStack { + ContactImportSelectView(contacts: contacts) + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(ContactsManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Contacts/ContactsIntroView.swift b/Bitkit/Views/Contacts/ContactsIntroView.swift index 4a9669ae9..31bb769a2 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(.pubkyChoice) + } 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..d731cd474 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactsListView.swift @@ -0,0 +1,265 @@ +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 = "" + @State private var showAddContactSheet = false + + 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) + + searchAndAddBar + .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")) + } + } + .sheet(isPresented: $showAddContactSheet) { + AddContactSheet( + onAdd: { pubky in + navigation.navigate(.addContact(publicKey: pubky)) + }, + onScanQR: { + navigation.navigate(.scanner) + } + ) + } + } + + // MARK: - Search Bar + Add Button + + @ViewBuilder + private var searchAndAddBar: some View { + HStack(spacing: 12) { + 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()) + + Button { + showAddContactSheet = true + } label: { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [.gray5, .gray6], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + Circle() + .stroke(Color.white10, lineWidth: 1) + .padding(0.5) + ) + + Image("plus") + .resizable() + .scaledToFit() + .foregroundColor(.textPrimary) + .frame(width: 20, height: 20) + } + .frame(width: 48, height: 48) + } + .accessibilityLabel(t("contacts__add_button")) + .accessibilityIdentifier("ContactsAddButton") + } + } + + // 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 { + CaptionMText(title, textColor: .white64) + .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) { + CaptionText(truncatedKey) + + BodyMSBText(name) + .lineLimit(1) + } + + 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/Contacts/EditContactView.swift b/Bitkit/Views/Contacts/EditContactView.swift new file mode 100644 index 000000000..a156451ec --- /dev/null +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct EditContactView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var contactsManager: ContactsManager + + let publicKey: String + + @State private var name: String = "" + @State private var bio: String = "" + @State private var imageUrl: String? + @State private var links: [ProfileLinkInput] = [] + @State private var tags: [String] = [] + @State private var isSaving = false + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("contacts__edit_title")) + .padding(.horizontal, 16) + + ProfileEditFormView( + name: $name, + bio: $bio, + links: $links, + tags: $tags, + publicKey: publicKey, + isSaving: isSaving, + onSave: { await saveContact() }, + onCancel: { navigation.navigateBack() } + ) { + avatarSection + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + loadContactData() + } + } + + // MARK: - Avatar + + @ViewBuilder + private var avatarSection: some View { + Group { + if let imageUrl { + PubkyImage(uri: imageUrl, size: 100) + } else { + Circle() + .fill(Color.gray5) + .frame(width: 100, height: 100) + .overlay { + Image("user-square") + .resizable() + .scaledToFit() + .foregroundColor(.white32) + .frame(width: 50, height: 50) + } + } + } + .frame(maxWidth: .infinity) + } + + // MARK: - Data Loading + + private func loadContactData() { + guard let contact = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) else { return } + let profile = contact.profile + name = profile.name + bio = profile.bio + imageUrl = profile.imageUrl + links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } + tags = profile.tags + } + + // MARK: - Save + + private func saveContact() async { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + isSaving = true + defer { isSaving = false } + + do { + try await contactsManager.updateContact( + publicKey: publicKey, + name: trimmedName, + bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), + imageUrl: imageUrl, + links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, + tags: tags + ) + app.toast(type: .success, title: t("contacts__edit_saved")) + navigation.navigateBack() + } catch { + Logger.error("Failed to save contact: \(error)", context: "EditContactView") + app.toast(type: .error, title: t("contacts__edit_error")) + } + } +} + +#Preview { + NavigationStack { + EditContactView(publicKey: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(ContactsManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/AddLinkSheet.swift b/Bitkit/Views/Profile/AddLinkSheet.swift new file mode 100644 index 000000000..82416a037 --- /dev/null +++ b/Bitkit/Views/Profile/AddLinkSheet.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct AddLinkSheet: View { + @Environment(\.dismiss) private var dismiss + + let onSave: (String, String) -> Void + + @State private var label: String = "" + @State private var url: String = "" + @State private var showSuggestionsSheet = false + + private var canSave: Bool { + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("profile__add_link_title")) + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__add_link_label"), textColor: .white64) + + labelFieldWithSuggestions + } + + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__add_link_url"), textColor: .white64) + + TextField( + t("profile__add_link_url_placeholder"), + text: $url, + backgroundColor: .white08, + testIdentifier: "AddLinkUrl" + ) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + CaptionText(t("profile__add_link_note"), textColor: .white50) + + CustomButton(title: t("common__save")) { + onSave( + label.trimmingCharacters(in: .whitespacesAndNewlines), + url.trimmingCharacters(in: .whitespacesAndNewlines) + ) + dismiss() + } + .disabled(!canSave) + .accessibilityIdentifier("AddLinkSave") + } + .padding(.horizontal, 16) + + Spacer() + } + .sheetBackground() + .presentationDetents([.height(460)]) + .presentationCornerRadius(32) + .presentationDragIndicator(.hidden) + .sheet(isPresented: $showSuggestionsSheet) { + LinkSuggestionsSheet { suggestion in + label = suggestion + } + } + } + + @ViewBuilder + private var labelFieldWithSuggestions: some View { + HStack(spacing: 0) { + ZStack(alignment: .leading) { + if label.isEmpty { + Text(t("profile__add_link_label_placeholder")) + .foregroundColor(.secondary) + .font(.custom(Fonts.semiBold, size: 15)) + } + + SwiftUI.TextField("", text: $label) + .accentColor(.brandAccent) + .font(.custom(Fonts.semiBold, size: 15)) + .accessibilityIdentifier("AddLinkLabel") + } + + Button { + showSuggestionsSheet = true + } label: { + HStack(spacing: 8) { + Image("lightbulb") + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text(t("slashtags__profile_link_suggestions")) + .font(Fonts.semiBold(size: 13)) + .foregroundColor(.pubkyGreen) + } + .padding(.horizontal, 8) + } + .accessibilityLabel(t("slashtags__profile_link_suggestions")) + .accessibilityIdentifier("AddLinkSuggestions") + } + .padding() + .background(Color.white08) + .cornerRadius(8) + } +} + +#Preview { + Color.clear + .sheet(isPresented: .constant(true)) { + AddLinkSheet { _, _ in } + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/AddProfileTagSheet.swift b/Bitkit/Views/Profile/AddProfileTagSheet.swift new file mode 100644 index 000000000..846b8d4f7 --- /dev/null +++ b/Bitkit/Views/Profile/AddProfileTagSheet.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct AddProfileTagSheet: View { + @Environment(\.dismiss) private var dismiss + + let onSave: (String) -> Void + + @State private var tag: String = "" + @State private var showSuggestionsSheet = false + + private var canSave: Bool { + !tag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("profile__add_tag_title")) + + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__add_tag_label"), textColor: .white64) + + tagFieldWithSuggestions + } + + CustomButton(title: t("common__save")) { + onSave(tag.trimmingCharacters(in: .whitespacesAndNewlines)) + dismiss() + } + .disabled(!canSave) + .accessibilityIdentifier("AddTagSave") + } + .padding(.horizontal, 16) + + Spacer() + } + .sheetBackground() + .presentationDetents([.height(300)]) + .presentationCornerRadius(32) + .presentationDragIndicator(.hidden) + .sheet(isPresented: $showSuggestionsSheet) { + TagSuggestionsSheet { suggestion in + tag = suggestion + } + } + } + + @ViewBuilder + private var tagFieldWithSuggestions: some View { + HStack(spacing: 0) { + ZStack(alignment: .leading) { + if tag.isEmpty { + Text(t("profile__add_tag_placeholder")) + .foregroundColor(.secondary) + .font(.custom(Fonts.semiBold, size: 15)) + } + + SwiftUI.TextField("", text: $tag) + .accentColor(.brandAccent) + .font(.custom(Fonts.semiBold, size: 15)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("AddTagInput") + } + + Button { + showSuggestionsSheet = true + } label: { + HStack(spacing: 8) { + Image("lightbulb") + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + + Text(t("slashtags__profile_link_suggestions")) + .font(Fonts.semiBold(size: 13)) + .foregroundColor(.pubkyGreen) + } + .padding(.horizontal, 8) + } + .accessibilityLabel(t("slashtags__profile_link_suggestions")) + .accessibilityIdentifier("AddTagSuggestions") + } + .padding() + .background(Color.white08) + .cornerRadius(8) + } +} + +#Preview { + Color.clear + .sheet(isPresented: .constant(true)) { + AddProfileTagSheet { _ in } + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/CreateProfileView.swift b/Bitkit/Views/Profile/CreateProfileView.swift new file mode 100644 index 000000000..e1ff5e4de --- /dev/null +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -0,0 +1,240 @@ +import PhotosUI +import SwiftUI + +struct CreateProfileView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager + + @State private var derivedPublicKey: String = "" + @State private var username: String = "" + @State private var isLoading = false + @State private var isSaving = false + @State private var isRestoring = false + @State private var existingProfile: PubkyProfile? + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var avatarImage: UIImage? + + var body: some View { + VStack(spacing: 0) { + NavigationBar( + title: t(isRestoring ? "profile__restore_nav_title" : "profile__create_nav_title") + ) + .padding(.horizontal, 16) + + if isLoading { + loadingView + } else { + formContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + await loadInitialData() + } + } + + // MARK: - Form Content + + @ViewBuilder + private var formContent: some View { + ScrollView { + VStack(spacing: 0) { + avatarSection + .padding(.top, 32) + .padding(.bottom, 24) + + nameInput + .padding(.bottom, 16) + + CustomDivider() + .padding(.horizontal, 16) + .padding(.bottom, 16) + + pubkyKeySection + .padding(.bottom, 24) + } + } + .scrollDismissesKeyboard(.interactively) + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + CustomButton( + title: t("common__continue"), + isLoading: isSaving + ) { + await saveProfile() + } + .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("CreateProfileSave") + .padding(.top, 16) + .padding(.horizontal, 16) + } + + // MARK: - Avatar Section + + @ViewBuilder + private var avatarSection: some View { + PhotosPicker(selection: $selectedPhotoItem, matching: .images) { + avatarContent + } + .accessibilityIdentifier("CreateProfileAvatar") + .accessibilityLabel(t("profile__create_avatar_label")) + .onChange(of: selectedPhotoItem) { _, newItem in + Task { await loadSelectedImage(newItem) } + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var avatarContent: some View { + if let avatarImage { + Image(uiImage: avatarImage) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } else { + Circle() + .fill(Color.gray5) + .frame(width: 100, height: 100) + .overlay { + Image(systemName: "photo") + .font(.system(size: 32, weight: .medium)) + .foregroundColor(.white32) + } + } + } + + // MARK: - Name Input + + @ViewBuilder + private var nameInput: some View { + SwiftUI.TextField( + t("profile__create_name_placeholder"), + text: $username + ) + .font(Fonts.black(size: 44)) + .kerning(-1) + .textCase(.uppercase) + .multilineTextAlignment(.center) + .foregroundColor(.textPrimary) + .padding(.horizontal, 32) + .accessibilityIdentifier("CreateProfileUsername") + } + + // MARK: - Pubky Key Section + + @ViewBuilder + private var pubkyKeySection: some View { + VStack(spacing: 8) { + CaptionMText(t("profile__create_pubky_display_label"), textColor: .white64) + + BodySText( + derivedPublicKey.isEmpty ? "..." : derivedPublicKey, + textColor: .white + ) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 16) + } + } + + // MARK: - Loading + + @ViewBuilder + private var loadingView: some View { + VStack { + Spacer() + ActivityIndicator(size: 32) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Image Selection + + private func loadSelectedImage(_ item: PhotosPickerItem?) async { + guard let item else { return } + do { + if let data = try await item.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) + { + avatarImage = uiImage + } + } catch { + Logger.error("Failed to load selected image: \(error)", context: "CreateProfileView") + } + selectedPhotoItem = nil + } + + // MARK: - Data Loading + + private func loadInitialData() async { + isLoading = true + defer { isLoading = false } + + do { + let (publicKey, _) = try await pubkyProfile.deriveKeys() + derivedPublicKey = publicKey + + // Restore existing profile if one is found on the network + if let remote = await pubkyProfile.fetchRemoteProfile(publicKey: publicKey) { + username = remote.name + existingProfile = remote + isRestoring = true + } + } catch { + Logger.error("Failed to derive pubky keys: \(error)", context: "CreateProfileView") + app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) + navigation.navigateBack() + } + } + + // MARK: - Save Profile + + private func saveProfile() async { + let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + isSaving = true + defer { isSaving = false } + + do { + try await pubkyProfile.createIdentity( + name: trimmedName, + bio: existingProfile?.bio ?? "", + links: existingProfile?.links ?? [], + tags: existingProfile?.tags ?? [], + avatarImage: avatarImage + ) + navigation.navigate(.payContacts) + } catch { + Logger.error("Failed to save profile: \(error)", context: "CreateProfileView") + app.toast(type: .error, title: t("profile__create_error_title"), description: error.localizedDescription) + } + } +} + +// MARK: - Profile Link Input Model + +struct ProfileLinkInput: Identifiable { + let id = UUID() + var label: String + var url: String +} + +#Preview { + NavigationStack { + CreateProfileView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift new file mode 100644 index 000000000..930676c5d --- /dev/null +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -0,0 +1,150 @@ +import PhotosUI +import SwiftUI + +struct EditProfileView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager + + @State private var username: String = "" + @State private var bio: String = "" + @State private var links: [ProfileLinkInput] = [] + @State private var tags: [String] = [] + @State private var isSaving = false + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var avatarImage: UIImage? + + var body: some View { + VStack(spacing: 0) { + NavigationBar( + title: t("profile__edit_nav_title") + ) + .padding(.horizontal, 16) + + ProfileEditFormView( + name: $username, + bio: $bio, + links: $links, + tags: $tags, + publicKey: pubkyProfile.publicKey ?? "...", + isSaving: isSaving, + onSave: { await saveProfile() }, + onCancel: { navigation.navigateBack() } + ) { + avatarPicker + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + loadProfileData() + } + } + + // MARK: - Avatar Picker + + @ViewBuilder + private var avatarPicker: some View { + PhotosPicker(selection: $selectedPhotoItem, matching: .images) { + avatarContent + } + .accessibilityIdentifier("EditProfileAvatar") + .accessibilityLabel(t("profile__create_avatar_label")) + .onChange(of: selectedPhotoItem) { _, newItem in + Task { await loadSelectedImage(newItem) } + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var avatarContent: some View { + if let avatarImage { + Image(uiImage: avatarImage) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } else if let imageUrl = pubkyProfile.profile?.imageUrl { + PubkyImage(uri: imageUrl, size: 100) + } else { + Circle() + .fill(Color.gray5) + .frame(width: 100, height: 100) + .overlay { + Image("user-square") + .resizable() + .scaledToFit() + .foregroundColor(.white32) + .frame(width: 50, height: 50) + } + } + } + + // MARK: - Image Selection + + private func loadSelectedImage(_ item: PhotosPickerItem?) async { + guard let item else { return } + do { + if let data = try await item.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) + { + avatarImage = uiImage + } + } catch { + Logger.error("Failed to load selected image: \(error)", context: "EditProfileView") + } + selectedPhotoItem = nil + } + + // MARK: - Data Loading + + private func loadProfileData() { + guard let profile = pubkyProfile.profile else { return } + username = profile.name + bio = profile.bio + links = profile.links.map { ProfileLinkInput(label: $0.label, url: $0.url) } + tags = profile.tags + } + + // MARK: - Save Profile + + private func saveProfile() async { + let trimmedName = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + isSaving = true + defer { isSaving = false } + + do { + var avatarUri: String? + if let avatarImage { + avatarUri = try await pubkyProfile.uploadAvatar(image: avatarImage) + } + + try await pubkyProfile.saveProfile( + name: trimmedName, + bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), + links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, + tags: tags, + newImageUrl: avatarUri + ) + app.toast(type: .success, title: t("profile__edit_saved")) + navigation.navigateBack() + } catch { + Logger.error("Failed to save profile: \(error)", context: "EditProfileView") + app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + } + } +} + +#Preview { + NavigationStack { + EditProfileView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/LinkSuggestionsSheet.swift b/Bitkit/Views/Profile/LinkSuggestionsSheet.swift new file mode 100644 index 000000000..34d109042 --- /dev/null +++ b/Bitkit/Views/Profile/LinkSuggestionsSheet.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct LinkSuggestionsSheet: View { + @Environment(\.dismiss) private var dismiss + + let onSelect: (String) -> Void + + private let suggestions = [ + "Email", "Phone", "Website", "Twitter", + "Telegram", "Instagram", "Facebook", + "LinkedIn", "Github", "Calendly", + "Vimeo", "YouTube", "Twitch", + "Pinterest", "TikTok", "Spotify", + ] + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("profile__suggestions_title"), showBackButton: true) + + WrappingHStack(spacing: 8) { + ForEach(suggestions, id: \.self) { suggestion in + Tag(suggestion, onPress: { + onSelect(suggestion) + dismiss() + }) + } + } + .padding(.horizontal, 16) + + Spacer() + } + .sheetBackground() + .presentationDetents([.height(400)]) + .presentationCornerRadius(32) + .presentationDragIndicator(.hidden) + } +} + +#Preview { + Color.clear + .sheet(isPresented: .constant(true)) { + LinkSuggestionsSheet { _ in } + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift new file mode 100644 index 000000000..9eeb03cfa --- /dev/null +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct PayContactsView: View { + @EnvironmentObject var navigation: NavigationViewModel + + @State private var enablePayments = true + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("profile__pay_contacts_nav_title")) + .padding(.horizontal, 16) + + Spacer() + + Image("coin-stack") + .resizable() + .scaledToFit() + .frame(width: 279) + .padding(.bottom, 32) + + VStack(alignment: .leading, spacing: 8) { + DisplayText( + t("profile__pay_contacts_title"), + accentColor: .pubkyGreen + ) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 16) + + BodyMText(t("profile__pay_contacts_description"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 32) + + Spacer() + + Toggle(isOn: $enablePayments) { + BodyMText(t("profile__pay_contacts_toggle"), textColor: .white) + } + .tint(.pubkyGreen) + .accessibilityIdentifier("PayContactsToggle") + .padding(.horizontal, 32) + + CustomButton(title: t("common__continue")) { + navigation.path = [.profile] + } + .accessibilityIdentifier("PayContactsContinue") + .padding(.top, 16) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + } +} + +#Preview { + NavigationStack { + PayContactsView() + .environmentObject(NavigationViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/ProfileIntro.swift b/Bitkit/Views/Profile/ProfileIntro.swift index e4db0d38f..9e0763f5f 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(.pubkyChoice) }, + 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..0cc7374ba --- /dev/null +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -0,0 +1,277 @@ +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(spacing: 0) { + CenteredProfileHeader( + truncatedKey: profile.truncatedPublicKey, + name: profile.name, + bio: profile.bio, + imageUrl: profile.imageUrl, + showDivider: false + ) + .padding(.top, 24) + .padding(.bottom, 24) + + profileQRCode(profile) + .padding(.bottom, 24) + + profileActions + .padding(.bottom, 32) + + VStack(alignment: .leading, spacing: 0) { + if !profile.links.isEmpty { + profileLinks(profile) + } + + if !profile.tags.isEmpty { + profileTags(profile) + .padding(.top, 16) + } + + signOutButton + .padding(.top, 24) + .padding(.bottom, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + } + } + + // MARK: - Actions (edit, copy, share) + + @ViewBuilder + private var profileActions: some View { + HStack(spacing: 16) { + GradientCircleButton(icon: "pencil", accessibilityLabel: t("profile__edit")) { + navigation.navigate(.editProfile) + } + .accessibilityIdentifier("ProfileEdit") + + GradientCircleButton(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") + + GradientCircleButton(icon: "share", accessibilityLabel: t("common__share")) { + shareProfile() + } + .accessibilityIdentifier("ProfileShare") + } + } + + // 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) + } + } + } + } + .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: - Tags + + @ViewBuilder + private func profileTags(_ profile: PubkyProfile) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__create_tags_label"), textColor: .white64) + + WrappingHStack(spacing: 8) { + ForEach(profile.tags, id: \.self) { tag in + Tag(tag) + } + } + } + } + + // MARK: - Sign Out Button + + @ViewBuilder + private var signOutButton: some View { + Button { + showSignOutConfirmation = true + } label: { + HStack(spacing: 8) { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.system(size: 14, weight: .medium)) + Text(t("profile__sign_out")) + .font(Fonts.regular(size: 15)) + } + .foregroundColor(.white64) + } + .disabled(isSigningOut) + .opacity(isSigningOut ? 0.5 : 1) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("ProfileSignOut") + } + + // 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, 16) + .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/PubkyChoiceView.swift b/Bitkit/Views/Profile/PubkyChoiceView.swift new file mode 100644 index 000000000..cd764aefd --- /dev/null +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -0,0 +1,274 @@ +import SwiftUI + +struct PubkyChoiceView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var contactsManager: ContactsManager + @Environment(\.scenePhase) var scenePhase + + @State private var isAuthenticating = false + @State private var isWaitingForRing = false + @State private var isLoadingAfterAuth = false + @State private var showRingNotInstalledDialog = false + + private let pubkyRingAppStoreUrl = "https://apps.apple.com/app/pubky-ring/id6739356756" + + var body: some View { + ZStack { + backgroundIllustrations + + VStack(spacing: 0) { + NavigationBar(title: t("profile__nav_title")) + .padding(.horizontal, 16) + + VStack(alignment: .leading, spacing: 0) { + titleSection + .padding(.top, 24) + .padding(.bottom, 24) + + optionCards + } + .padding(.horizontal, 16) + + Spacer() + } + } + .clipped() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .task(id: isWaitingForRing) { + guard isWaitingForRing else { return } + await waitForApproval() + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active, isWaitingForRing { + // Ring returned to app — approval task handles completion + } + } + .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")) + } + } + + // MARK: - Title Section + + @ViewBuilder + private var titleSection: some View { + VStack(alignment: .leading, spacing: 8) { + DisplayText( + t("profile__choice_title"), + accentColor: .pubkyGreen + ) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + + BodyMText(isLoadingAfterAuth + ? t("profile__ring_loading") + : isWaitingForRing ? t("profile__ring_waiting") : t("profile__choice_description")) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + // MARK: - Option Cards + + @ViewBuilder + private var optionCards: some View { + VStack(spacing: 8) { + choiceCard( + icon: "user-plus", + title: t("profile__choice_create"), + accessibilityId: "PubkyChoiceCreate" + ) { + navigation.navigate(.createProfile) + } + .disabled(isAuthenticating || isWaitingForRing || isLoadingAfterAuth) + + if isWaitingForRing || isLoadingAfterAuth { + ringWaitingCard + } else { + choiceCard( + systemIcon: "key.fill", + title: t("profile__choice_import"), + isLoading: isAuthenticating, + accessibilityId: "PubkyChoiceImport" + ) { + await startRingAuth() + } + .disabled(isAuthenticating) + } + } + } + + @ViewBuilder + private func choiceCard( + icon: String? = nil, + systemIcon: String? = nil, + title: String, + isLoading: Bool = false, + accessibilityId: String, + action: @escaping () async -> Void + ) -> some View { + Button { + Task { await action() } + } label: { + HStack(spacing: 16) { + ZStack { + Circle() + .fill(Color.black) + .frame(width: 40, height: 40) + + if isLoading { + ActivityIndicator(size: 20) + } else if let icon { + Image(icon) + .resizable() + .scaledToFit() + .foregroundColor(.pubkyGreen) + .frame(width: 20, height: 20) + } else if let systemIcon { + Image(systemName: systemIcon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.pubkyGreen) + } + } + + BodyMSBText(title, textColor: .white) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.gray6) + .cornerRadius(16) + } + .accessibilityIdentifier(accessibilityId) + } + + // MARK: - Ring Auth + + private func startRingAuth() async { + isAuthenticating = true + + do { + try await pubkyProfile.startAuthentication() + isAuthenticating = false + isWaitingForRing = true + } catch PubkyServiceError.ringNotInstalled { + isAuthenticating = 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 + isLoadingAfterAuth = true + await navigateAfterAuth() + } catch is CancellationError { + isWaitingForRing = false + await pubkyProfile.cancelAuthentication() + } catch { + isWaitingForRing = false + app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) + } + } + + private func navigateAfterAuth() async { + guard let pk = pubkyProfile.publicKey else { + navigation.path = [.profile] + return + } + + let hasImportData = await contactsManager.prepareImport(profile: pubkyProfile.profile, publicKey: pk) + navigation.path = [hasImportData ? .contactImportOverview : .payContacts] + } + + // MARK: - Ring Waiting Card + + @ViewBuilder + private var ringWaitingCard: some View { + VStack(spacing: 12) { + HStack(spacing: 16) { + ZStack { + Circle() + .fill(Color.black) + .frame(width: 40, height: 40) + + ActivityIndicator(size: 20) + } + + BodyMSBText(t(isLoadingAfterAuth ? "profile__ring_loading" : "profile__ring_waiting"), textColor: .white) + + Spacer() + } + + if !isLoadingAfterAuth { + Button { + isWaitingForRing = false + Task { await pubkyProfile.cancelAuthentication() } + } label: { + BodySSBText(t("common__cancel"), textColor: .white64) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityIdentifier("PubkyChoiceCancelRing") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(Color.gray6) + .cornerRadius(16) + } + + // MARK: - Background Illustrations + + @ViewBuilder + private var backgroundIllustrations: some View { + 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 + 200 + ) + + 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 + 200 + ) + } + .ignoresSafeArea() + } +} + +#Preview { + NavigationStack { + PubkyChoiceView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + .environmentObject(ContactsManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/PubkyRingAuthView.swift b/Bitkit/Views/Profile/PubkyRingAuthView.swift new file mode 100644 index 000000000..e913e71b3 --- /dev/null +++ b/Bitkit/Views/Profile/PubkyRingAuthView.swift @@ -0,0 +1,208 @@ +import SwiftUI + +struct PubkyRingAuthView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var contactsManager: ContactsManager + @Environment(\.scenePhase) var scenePhase + + @State private var isAuthenticating = false + @State private var isWaitingForRing = false + @State private var isLoadingAfterAuth = 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(isLoadingAfterAuth + ? t("profile__ring_loading") + : 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 { + if isWaitingForRing || isLoadingAfterAuth { + VStack(spacing: 12) { + CustomButton( + title: t(isLoadingAfterAuth ? "profile__ring_loading" : "profile__ring_waiting"), + isLoading: true + ) {} + .disabled(true) + + if !isLoadingAfterAuth { + Button { + isWaitingForRing = false + Task { await pubkyProfile.cancelAuthentication() } + } label: { + Text(t("common__cancel")) + .font(Fonts.semiBold(size: 15)) + .foregroundColor(.white64) + } + .accessibilityIdentifier("PubkyRingCancelAuth") + } + } + } else { + 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, 16) + } + } + .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 + isLoadingAfterAuth = true + await navigateAfterAuth() + } catch is CancellationError { + isWaitingForRing = false + await pubkyProfile.cancelAuthentication() + } catch { + isWaitingForRing = false + app.toast(type: .error, title: t("profile__auth_error_title"), description: error.localizedDescription) + } + } + + private func navigateAfterAuth() async { + guard let pk = pubkyProfile.publicKey else { + navigation.path = [.profile] + return + } + + let hasImportData = await contactsManager.prepareImport(profile: pubkyProfile.profile, publicKey: pk) + navigation.path = [hasImportData ? .contactImportOverview : .payContacts] + } +} + +#Preview { + NavigationStack { + PubkyRingAuthView() + .environmentObject(AppViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + .environmentObject(ContactsManager()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Profile/TagSuggestionsSheet.swift b/Bitkit/Views/Profile/TagSuggestionsSheet.swift new file mode 100644 index 000000000..7eb75bba2 --- /dev/null +++ b/Bitkit/Views/Profile/TagSuggestionsSheet.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct TagSuggestionsSheet: View { + @Environment(\.dismiss) private var dismiss + + let onSelect: (String) -> Void + + private let suggestions = [ + "Developer", "Designer", "Founder", + "CEO", "CTO", "CDO", "CFO", + "Serious", "Funny", "Candid", + ] + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("profile__suggestions_title"), showBackButton: true) + + WrappingHStack(spacing: 8) { + ForEach(suggestions, id: \.self) { suggestion in + Tag(suggestion, onPress: { + onSelect(suggestion) + dismiss() + }) + } + } + .padding(.horizontal, 16) + + Spacer() + } + .sheetBackground() + .presentationDetents([.height(400)]) + .presentationCornerRadius(32) + .presentationDragIndicator(.hidden) + } +} + +#Preview { + Color.clear + .sheet(isPresented: .constant(true)) { + TagSuggestionsSheet { _ in } + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift new file mode 100644 index 000000000..50fec16dd --- /dev/null +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -0,0 +1,278 @@ +import SwiftUI + +// MARK: - Config & Sheet Item + +struct PubkyAuthApprovalConfig { + let authUrl: String + let request: PubkyAuthRequest +} + +struct PubkyAuthApprovalSheetItem: SheetItem { + let id: SheetID = .pubkyAuthApproval + let size: SheetSize = .large + let authUrl: String + let request: PubkyAuthRequest +} + +// MARK: - Sheet View + +struct PubkyAuthApprovalSheet: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var pubkyProfile: PubkyProfileManager + + let config: PubkyAuthApprovalSheetItem + + @State private var state: ApprovalState = .authorize + + private enum ApprovalState { + case authorize + case authorizing + case success + } + + private var headerTitle: String { + state == .success ? t("pubky_auth__success_title") : t("pubky_auth__title") + } + + var body: some View { + Sheet(id: .pubkyAuthApproval, data: config) { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: headerTitle, showBackButton: true) + + switch state { + case .authorize: + authorizeContent + case .authorizing: + authorizingContent + case .success: + successContent + } + } + .padding(.horizontal, 16) + } + } + + // MARK: - Authorize State (Screen 3) + + @ViewBuilder + private var authorizeContent: some View { + VStack(alignment: .leading, spacing: 0) { + descriptionText + .padding(.bottom, 32) + + permissionsSection + .padding(.bottom, 16) + + Spacer() + + trustWarning + .padding(.bottom, 16) + + profileCard + .padding(.bottom, 24) + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + sheets.hideSheet() + } + .accessibilityIdentifier("PubkyAuthCancel") + + CustomButton(title: t("pubky_auth__title")) { + await onAuthorize() + } + .accessibilityIdentifier("PubkyAuthAuthorize") + } + } + } + + // MARK: - Authorizing State (Screen 4) + + @ViewBuilder + private var authorizingContent: some View { + VStack(alignment: .leading, spacing: 0) { + descriptionText + .padding(.bottom, 32) + + permissionsSection + .padding(.bottom, 16) + + Spacer() + + trustWarning + .padding(.bottom, 16) + + profileCard + .padding(.bottom, 24) + + CustomButton(title: t("pubky_auth__authorizing"), isLoading: true) {} + .disabled(true) + } + } + + // MARK: - Success State (Screen 5) + + @ViewBuilder + private var successContent: some View { + VStack(alignment: .leading, spacing: 0) { + successDescriptionText + .padding(.bottom, 16) + + Spacer() + + Image("check") + .resizable() + .scaledToFit() + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + + Spacer() + + CustomButton(title: t("common__ok")) { + sheets.hideSheet() + } + .accessibilityIdentifier("PubkyAuthOK") + } + } + + // MARK: - Shared Components + + private var serviceText: String { + config.request.serviceNames.joined(separator: " and ") + } + + @ViewBuilder + private var descriptionText: some View { + BodyMText( + t("pubky_auth__description_prefix") + "" + serviceText + "" + t("pubky_auth__description_suffix"), + accentColor: .textPrimary, + accentFont: Fonts.bold + ) + .lineSpacing(4) + } + + @ViewBuilder + private var successDescriptionText: some View { + let truncatedKey = pubkyProfile.profile?.truncatedPublicKey ?? "" + BodyMText( + t("pubky_auth__success_prefix") + "" + truncatedKey + "" + + t("pubky_auth__success_middle") + "" + serviceText + "" + + t("pubky_auth__success_suffix"), + accentColor: .textPrimary, + accentFont: Fonts.bold + ) + .lineSpacing(4) + } + + @ViewBuilder + private var permissionsSection: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__requested_permissions"), textColor: .white64) + + ForEach(Array(config.request.permissions.enumerated()), id: \.offset) { _, permission in + permissionRow(permission) + } + + CustomDivider(color: .white10) + } + } + + @ViewBuilder + private func permissionRow(_ permission: PubkyAuthPermission) -> some View { + HStack(spacing: 4) { + Image(systemName: "folder") + .font(.system(size: 14)) + .foregroundColor(.white) + + BodySSBText(permission.path) + .lineLimit(1) + + Spacer() + + CaptionMText(permission.displayAccess, textColor: .gray1) + } + } + + @ViewBuilder + private var trustWarning: some View { + BodySText(t("pubky_auth__trust_warning")) + .lineSpacing(4) + } + + @ViewBuilder + private var profileCard: some View { + VStack(alignment: .leading, spacing: 16) { + CaptionMText( + pubkyProfile.profile?.truncatedPublicKey ?? "", + textColor: .white64 + ) + + HStack(alignment: .top, spacing: 16) { + HeadlineText(pubkyProfile.displayName ?? "") + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if let imageUri = pubkyProfile.displayImageUri { + PubkyImage(uri: imageUri, 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) + } + } + } + } + .padding(24) + .background(Color.gray6) + .cornerRadius(16) + } + + // MARK: - Actions + + private func onAuthorize() async { + state = .authorizing + + // Biometric authentication + let biometricResult = await BiometricAuth.authenticate() + + switch biometricResult { + case .success: + break + case .cancelled: + state = .authorize + return + case let .failed(message): + app.toast(type: .error, title: t("pubky_auth__biometric_failed"), description: message) + state = .authorize + return + } + + // Load secret key and approve + do { + guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), + !secretKey.isEmpty + else { + app.toast(type: .error, title: t("pubky_auth__no_identity")) + state = .authorize + return + } + + try await PubkyService.approveAuth( + authUrl: config.authUrl, + secretKeyHex: secretKey + ) + + state = .success + } catch { + Logger.error("Failed to approve pubky auth: \(error)", context: "PubkyAuthApprovalSheet") + app.toast(type: .error, title: t("pubky_auth__approval_failed"), description: error.localizedDescription) + state = .authorize + } + } +} diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift new file mode 100644 index 000000000..5407f3075 --- /dev/null +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -0,0 +1,125 @@ +@testable import Bitkit +import XCTest + +/// Tests for PubkyAuthRequest capability parsing and permission display. +final class PubkyAuthRequestTests: XCTestCase { + // MARK: - parseCapabilities + + func testParseCapabilitiesSingleEntry() { + let permissions = PubkyAuthRequest.parseCapabilities("/pub/pubky.app/:rw") + + XCTAssertEqual(permissions.count, 1) + XCTAssertEqual(permissions[0].path, "/pub/pubky.app/") + XCTAssertEqual(permissions[0].accessLevel, "rw") + } + + func testParseCapabilitiesMultipleEntries() { + let permissions = PubkyAuthRequest.parseCapabilities("/pub/pubky.app/:rw,/pub/paykit/v0/:r") + + XCTAssertEqual(permissions.count, 2) + XCTAssertEqual(permissions[0].path, "/pub/pubky.app/") + XCTAssertEqual(permissions[0].accessLevel, "rw") + XCTAssertEqual(permissions[1].path, "/pub/paykit/v0/") + XCTAssertEqual(permissions[1].accessLevel, "r") + } + + func testParseCapabilitiesEmptyString() { + let permissions = PubkyAuthRequest.parseCapabilities("") + + XCTAssertTrue(permissions.isEmpty) + } + + func testParseCapabilitiesMalformedNoColon() { + // No colon separator → should be filtered out + let permissions = PubkyAuthRequest.parseCapabilities("/pub/pubky.app/rw") + + XCTAssertTrue(permissions.isEmpty) + } + + func testParseCapabilitiesWhitespace() { + let permissions = PubkyAuthRequest.parseCapabilities(" /pub/pubky.app/:rw , /pub/paykit/v0/:r ") + + XCTAssertEqual(permissions.count, 2) + XCTAssertEqual(permissions[0].path, "/pub/pubky.app/") + XCTAssertEqual(permissions[1].path, "/pub/paykit/v0/") + } + + func testParseCapabilitiesEmptyPath() { + // Colon at start → empty path should be filtered + let permissions = PubkyAuthRequest.parseCapabilities(":rw") + + XCTAssertTrue(permissions.isEmpty) + } + + func testParseCapabilitiesEmptyAccess() { + // Trailing colon → empty access should be filtered + let permissions = PubkyAuthRequest.parseCapabilities("/pub/pubky.app/:") + + XCTAssertTrue(permissions.isEmpty) + } + + func testParseCapabilitiesMultipleColons() { + // Path contains a colon — lastIndex should split at the final one + let permissions = PubkyAuthRequest.parseCapabilities("/pub/some:thing/:rw") + + XCTAssertEqual(permissions.count, 1) + XCTAssertEqual(permissions[0].path, "/pub/some:thing/") + XCTAssertEqual(permissions[0].accessLevel, "rw") + } + + // MARK: - extractServiceName + + func testExtractServiceNameStandard() { + XCTAssertEqual(PubkyAuthRequest.extractServiceName("/pub/pubky.app/"), "pubky.app") + } + + func testExtractServiceNameDeepPath() { + // Should take the component at index 1, ignoring deeper segments + XCTAssertEqual(PubkyAuthRequest.extractServiceName("/pub/paykit/v0/"), "paykit") + } + + func testExtractServiceNameSingleComponent() { + // Only "pub" after trimming — fewer than 2 components + XCTAssertNil(PubkyAuthRequest.extractServiceName("/pub/")) + } + + func testExtractServiceNameEmpty() { + XCTAssertNil(PubkyAuthRequest.extractServiceName("")) + } + + func testExtractServiceNameRootSlash() { + XCTAssertNil(PubkyAuthRequest.extractServiceName("/")) + } + + func testExtractServiceNameNoLeadingSlash() { + // Trim handles missing leading slash + XCTAssertEqual(PubkyAuthRequest.extractServiceName("pub/pubky.app/"), "pubky.app") + } + + // MARK: - PubkyAuthPermission displayAccess + + func testDisplayAccessReadWrite() { + let permission = PubkyAuthPermission(path: "/test", accessLevel: "rw") + XCTAssertEqual(permission.displayAccess, "READ, WRITE") + } + + func testDisplayAccessReadOnly() { + let permission = PubkyAuthPermission(path: "/test", accessLevel: "r") + XCTAssertEqual(permission.displayAccess, "READ") + } + + func testDisplayAccessWriteOnly() { + let permission = PubkyAuthPermission(path: "/test", accessLevel: "w") + XCTAssertEqual(permission.displayAccess, "WRITE") + } + + func testDisplayAccessUnknownFlags() { + let permission = PubkyAuthPermission(path: "/test", accessLevel: "x") + XCTAssertEqual(permission.displayAccess, "") + } + + func testDisplayAccessEmpty() { + let permission = PubkyAuthPermission(path: "/test", accessLevel: "") + XCTAssertEqual(permission.displayAccess, "") + } +} diff --git a/BitkitTests/PubkyModelTests.swift b/BitkitTests/PubkyModelTests.swift new file mode 100644 index 000000000..535c19654 --- /dev/null +++ b/BitkitTests/PubkyModelTests.swift @@ -0,0 +1,259 @@ +@testable import Bitkit +import XCTest + +final class PubkyModelTests: XCTestCase { + // MARK: - PubkyProfile Truncation + + func testTruncatedPublicKeyLongKey() { + let profile = PubkyProfile( + publicKey: "pubkyz6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + name: "Test", + bio: "", + imageUrl: nil, + links: [], + status: nil + ) + + XCTAssertEqual(profile.truncatedPublicKey, "pubk...2doK") + } + + func testTruncatedPublicKeyShortKey() { + let profile = PubkyProfile( + publicKey: "abc", + name: "Test", + bio: "", + imageUrl: nil, + links: [], + status: nil + ) + + // Keys <= 10 chars are returned as-is + XCTAssertEqual(profile.truncatedPublicKey, "abc") + } + + func testTruncatedPublicKeyExactBoundary() { + let profile = PubkyProfile( + publicKey: "1234567890", + name: "Test", + bio: "", + imageUrl: nil, + links: [], + status: nil + ) + + // Exactly 10 chars should NOT be truncated + XCTAssertEqual(profile.truncatedPublicKey, "1234567890") + } + + func testTruncatedPublicKeyElevenChars() { + let profile = PubkyProfile( + publicKey: "12345678901", + name: "Test", + bio: "", + imageUrl: nil, + links: [], + status: nil + ) + + // 11 chars should be truncated + XCTAssertEqual(profile.truncatedPublicKey, "1234...8901") + } + + // MARK: - PubkyProfile Placeholder + + func testPlaceholderUsesKeyAsName() { + let placeholder = PubkyProfile.placeholder(publicKey: "pubkyz6MkhaXgBZDvotDk") + + XCTAssertEqual(placeholder.publicKey, "pubkyz6MkhaXgBZDvotDk") + XCTAssertEqual(placeholder.name, "pubk...otDk") + XCTAssertTrue(placeholder.bio.isEmpty) + XCTAssertNil(placeholder.imageUrl) + XCTAssertTrue(placeholder.links.isEmpty) + XCTAssertNil(placeholder.status) + } + + func testPlaceholderShortKeyUsesFullKey() { + let placeholder = PubkyProfile.placeholder(publicKey: "short") + + XCTAssertEqual(placeholder.name, "short") + } + + // MARK: - PubkyProfile Initialization + + func testProfileInitWithAllFields() { + let links = [PubkyProfileLink(label: "X", url: "https://x.com/user")] + let profile = PubkyProfile( + publicKey: "pk1", + name: "Satoshi", + bio: "Creator", + imageUrl: "https://example.com/avatar.png", + links: links, + status: "online" + ) + + XCTAssertEqual(profile.name, "Satoshi") + XCTAssertEqual(profile.bio, "Creator") + XCTAssertEqual(profile.imageUrl, "https://example.com/avatar.png") + XCTAssertEqual(profile.links.count, 1) + XCTAssertEqual(profile.links.first?.label, "X") + XCTAssertEqual(profile.status, "online") + } + + // MARK: - PubkyContact + + func testContactDisplayName() { + let profile = PubkyProfile( + publicKey: "pk1", + name: "Alice", + bio: "", + imageUrl: nil, + links: [], + status: nil + ) + let contact = PubkyContact(publicKey: "pk1", profile: profile) + + XCTAssertEqual(contact.displayName, "Alice") + } + + func testContactSortLetterAlpha() { + let profile = PubkyProfile(publicKey: "pk1", name: "Bob", bio: "", imageUrl: nil, links: [], status: nil) + let contact = PubkyContact(publicKey: "pk1", profile: profile) + + XCTAssertEqual(contact.sortLetter, "B") + } + + func testContactSortLetterNumeric() { + let profile = PubkyProfile(publicKey: "pk1", name: "42", bio: "", imageUrl: nil, links: [], status: nil) + let contact = PubkyContact(publicKey: "pk1", profile: profile) + + XCTAssertEqual(contact.sortLetter, "#") + } + + func testContactSortLetterEmoji() { + let profile = PubkyProfile(publicKey: "pk1", name: "🎉Party", bio: "", imageUrl: nil, links: [], status: nil) + let contact = PubkyContact(publicKey: "pk1", profile: profile) + + XCTAssertEqual(contact.sortLetter, "#") + } + + func testContactEquality() { + let profile1 = PubkyProfile(publicKey: "pk1", name: "Alice", bio: "", imageUrl: nil, links: [], status: nil) + let profile2 = PubkyProfile(publicKey: "pk1", name: "Alice Updated", bio: "new bio", imageUrl: nil, links: [], status: nil) + + let contact1 = PubkyContact(publicKey: "pk1", profile: profile1) + let contact2 = PubkyContact(publicKey: "pk1", profile: profile2) + + // Equality is based on publicKey, not profile contents + XCTAssertEqual(contact1, contact2) + } + + func testContactInequality() { + let profile1 = PubkyProfile(publicKey: "pk1", name: "Alice", bio: "", imageUrl: nil, links: [], status: nil) + let profile2 = PubkyProfile(publicKey: "pk2", name: "Alice", bio: "", imageUrl: nil, links: [], status: nil) + + let contact1 = PubkyContact(publicKey: "pk1", profile: profile1) + let contact2 = PubkyContact(publicKey: "pk2", profile: profile2) + + XCTAssertNotEqual(contact1, contact2) + } + + // MARK: - ContactSection + + func testContactSectionId() { + let section = ContactSection(id: "A", letter: "A", contacts: []) + + XCTAssertEqual(section.id, "A") + XCTAssertEqual(section.letter, "A") + XCTAssertTrue(section.contacts.isEmpty) + } + + // MARK: - PubkyProfileLink + + func testProfileLinkUniqueIds() { + let link1 = PubkyProfileLink(label: "X", url: "https://x.com") + let link2 = PubkyProfileLink(label: "X", url: "https://x.com") + + XCTAssertNotEqual(link1.id, link2.id) + } + + // MARK: - PubkyProfileData Decoding + + func testProfileDataDecodesWithTags() throws { + let json = """ + {"name":"Satoshi","bio":"","image":null,"links":[],"tags":["bitcoin","lightning"]} + """ + let data = try PubkyProfileData.decode(from: json) + + XCTAssertEqual(data.name, "Satoshi") + XCTAssertEqual(data.tags, ["bitcoin", "lightning"]) + } + + func testProfileDataDecodesWithoutTags() throws { + let json = """ + {"name":"Satoshi","bio":"","image":null,"links":[]} + """ + let data = try PubkyProfileData.decode(from: json) + + XCTAssertEqual(data.name, "Satoshi") + XCTAssertEqual(data.tags, []) + } + + func testProfileDataRoundTrip() throws { + let original = PubkyProfileData( + name: "Alice", + bio: "Test bio", + image: "pubky://abc/pub/bitkit.to/blobs/123.jpg", + links: [PubkyProfileData.Link(label: "Website", url: "https://example.com")], + tags: ["dev", "bitcoin"] + ) + + let encoded = try original.encoded() + let decoded = try JSONDecoder().decode(PubkyProfileData.self, from: encoded) + + XCTAssertEqual(decoded.name, "Alice") + XCTAssertEqual(decoded.bio, "Test bio") + XCTAssertEqual(decoded.image, "pubky://abc/pub/bitkit.to/blobs/123.jpg") + XCTAssertEqual(decoded.links.count, 1) + XCTAssertEqual(decoded.links.first?.label, "Website") + XCTAssertEqual(decoded.tags, ["dev", "bitcoin"]) + } + + func testProfileDataToProfile() { + let data = PubkyProfileData( + name: "Bob", + bio: "Hello", + image: "pubky://key/pub/bitkit.to/blobs/avatar.jpg", + links: [PubkyProfileData.Link(label: "X", url: "https://x.com/bob")], + tags: ["design"] + ) + + let profile = data.toProfile(publicKey: "pubkyTestKey123") + + XCTAssertEqual(profile.publicKey, "pubkyTestKey123") + XCTAssertEqual(profile.name, "Bob") + XCTAssertEqual(profile.bio, "Hello") + XCTAssertEqual(profile.tags, ["design"]) + XCTAssertEqual(profile.links.count, 1) + XCTAssertEqual(profile.links.first?.url, "https://x.com/bob") + } + + func testProfileDataFromProfile() { + let profile = PubkyProfile( + publicKey: "pk1", + name: "Alice", + bio: "Bio", + imageUrl: "pubky://img", + links: [PubkyProfileLink(label: "Site", url: "https://a.com")], + tags: ["swift", "ios"], + status: "active" + ) + + let data = PubkyProfileData.from(profile: profile) + + XCTAssertEqual(data.name, "Alice") + XCTAssertEqual(data.bio, "Bio") + XCTAssertEqual(data.image, "pubky://img") + XCTAssertEqual(data.tags, ["swift", "ios"]) + XCTAssertEqual(data.links.count, 1) + } +} diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift new file mode 100644 index 000000000..66adec0bb --- /dev/null +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -0,0 +1,55 @@ +@testable import Bitkit +import XCTest + +final class PubkyProfileManagerTests: XCTestCase { + // MARK: - HomegateResponse Decoding + + private typealias HomegateResponse = PubkyProfileManager.HomegateResponse + + func testHomegateResponseDecodesCamelCase() throws { + let json = """ + {"signupCode":"abc-123","homeserverPubky":"z6MkPubkyTestKey"} + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(HomegateResponse.self, from: data) + + XCTAssertEqual(response.signupCode, "abc-123") + XCTAssertEqual(response.homeserverPubky, "z6MkPubkyTestKey") + } + + func testHomegateResponseRejectsIncompleteJson() { + let json = """ + {"signupCode":"abc-123"} + """ + let data = json.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(HomegateResponse.self, from: data)) + } + + func testHomegateResponseRejectsEmptyJson() { + let json = "{}" + let data = json.data(using: .utf8)! + + XCTAssertThrowsError(try JSONDecoder().decode(HomegateResponse.self, from: data)) + } + + func testHomegateResponseWithExtraFieldsDecodes() throws { + let json = """ + {"signupCode":"abc","homeserverPubky":"z6Mk","extra":"ignored"} + """ + let data = json.data(using: .utf8)! + let response = try JSONDecoder().decode(HomegateResponse.self, from: data) + + XCTAssertEqual(response.signupCode, "abc") + XCTAssertEqual(response.homeserverPubky, "z6Mk") + } + + // MARK: - Profile Link Input Model + + func testProfileLinkInputHasUniqueIds() { + let link1 = ProfileLinkInput(label: "Website", url: "https://example.com") + let link2 = ProfileLinkInput(label: "Website", url: "https://example.com") + + XCTAssertNotEqual(link1.id, link2.id) + } +}