diff --git a/AGENTS.md b/AGENTS.md index 85047f1bb..a2b217a8e 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 6c28c9406..99f856bf9 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 */; }; 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; @@ -167,6 +168,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 182817C12F59A7F10055A441 /* Paykit in Frameworks */, 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 4AFCA3702E05933800205CAE /* Zip in Frameworks */, 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */, @@ -281,6 +283,7 @@ 4AFCA36F2E05933800205CAE /* Zip */, 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, + 182817C02F59A7F10055A441 /* Paykit */, ); productName = Bitkit; productReference = 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */; @@ -388,6 +391,7 @@ 968FE13E2DFB016B0053CD7F /* XCRemoteSwiftPackageReference "ldk-node" */, 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, + 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -903,6 +907,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"; @@ -962,6 +974,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 3eca56f8e..206f9f400 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "17302aed9b1f94b6f928fd7e06b45ebec95883553cab0849836d3e292cab01d7", + "originHash" : "d158db056599c21ce7702af0c74aa95296da8e9b08fcbc00728f449ce4872dde", "pins" : [ { "identity" : "bitkit-core", @@ -35,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.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme b/Bitkit.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme index 5156ea857..05b745ae9 100644 --- a/Bitkit.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme +++ b/Bitkit.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme @@ -68,7 +68,7 @@ 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..827421c32 --- /dev/null +++ b/Bitkit/Components/CenteredProfileHeader.swift @@ -0,0 +1,82 @@ +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 + /// Set on own Profile screen for E2E; omit on contact previews so IDs stay unique. + var nameAccessibilityIdentifier: String? + var notesAccessibilityIdentifier: String? + + 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) + .accessibilityIdentifierIfPresent(nameAccessibilityIdentifier) + + if showBio, !bio.isEmpty { + BodyMText(bio, textColor: .white64) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 16) + .accessibilityIdentifierIfPresent(notesAccessibilityIdentifier) + } + + 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..8d962d98b 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,53 @@ struct Header: View { .padding(.leading, 16) .padding(.trailing, 10) } + + @ViewBuilder + private var profileButton: some View { + Button { + if pubkyProfile.isAuthenticated || pubkyProfile.cachedName != nil { + navigation.navigate(.profile) + } else if pubkyProfile.initializationErrorMessage != nil { + navigation.navigate(.profile) + } else if !pubkyProfile.isInitialized { + // Still initializing — don't navigate to choice screen yet + return + } 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")) + .accessibilityIdentifier("ProfileButton") + } + + @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 00355e741..676bdff8c 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,17 @@ struct Suggestions: View { case .invite: showShareSheet = true case .profile: - route = app.hasSeenProfileIntro ? .profile : .profileIntro + if pubkyProfile.isAuthenticated || pubkyProfile.cachedName != nil { + route = .profile + } else if pubkyProfile.initializationErrorMessage != nil { + route = .profile + } else if !pubkyProfile.isInitialized { + return + } else if app.hasSeenProfileIntro { + route = .pubkyChoice + } else { + route = .profileIntro + } case .quickpay: route = app.hasSeenQuickpayIntro ? .quickpay : .quickpayIntro case .notifications: @@ -293,3 +315,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..285bfb011 --- /dev/null +++ b/Bitkit/Components/ProfileEditFormView.swift @@ -0,0 +1,285 @@ +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 publicKeyLabel: String + let isSaving: Bool + let footerNote: String? + let deleteLabel: String? + let onSave: () async -> Void + let onCancel: () -> Void + let onDelete: (() -> 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) + + if let deleteLabel, let onDelete { + CustomDivider(color: .white16) + .padding(.bottom, 16) + + deleteSection(label: deleteLabel, action: onDelete) + .padding(.bottom, 24) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + .scrollDismissesKeyboard(.interactively) + .onTapGesture { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + footerBar + } + .sheet(isPresented: $showAddLinkSheet) { + AddLinkSheet { label, url in + links.append(ProfileLinkInput(label: label, url: url)) + } + } + .sheet(isPresented: $showAddTagSheet) { + AddProfileTagSheet { tag in + tags.append(tag) + } + } + } + + // MARK: - Pubky Key Section + + @ViewBuilder + private var pubkyKeySection: some View { + VStack(spacing: 8) { + CaptionMText(publicKeyLabel, 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(index: index) + } + + IconActionButton( + icon: "link", + isSystemIcon: true, + title: t("profile__create_add_link"), + accessibilityId: "ProfileEditAddLink" + ) { + showAddLinkSheet = true + } + } + } + + @ViewBuilder + private func linkRow(index: Int) -> some View { + let link = links[index] + + VStack(alignment: .leading, spacing: 4) { + CaptionMText(link.label, textColor: .white64) + + HStack { + ZStack(alignment: .leading) { + if link.url.isEmpty { + SwiftUI.Text(t("profile__add_link_url_placeholder")) + .foregroundColor(.secondary) + .font(.custom(Fonts.regular, size: 17)) + } + + SwiftUI.TextField( + "", + text: Binding( + get: { links[index].url }, + set: { links[index].url = $0 } + ) + ) + .font(.custom(Fonts.regular, size: 17)) + .foregroundColor(.textPrimary) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + 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) + .accessibilityIdentifier("ProfileEditLink_\(index)") + } + } + + // MARK: - Delete Section + + @ViewBuilder + private func deleteSection(label: String, action: @escaping () -> Void) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__edit_delete_section"), textColor: .white64) + + CustomButton( + title: label, + size: .small, + icon: Image("trash") + .resizable() + .scaledToFit() + .foregroundColor(.red) + .frame(width: 16, height: 16), + shouldExpand: false + ) { + action() + } + } + .accessibilityIdentifier("ProfileEditDelete") + } + + // 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 + } + } + } + + @ViewBuilder + private var footerBar: some View { + VStack(spacing: 0) { + LinearGradient( + colors: [.customBlack.opacity(0), .customBlack], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 32) + + VStack(alignment: .leading, spacing: 16) { + if let footerNote { + BodySText(footerNote, textColor: .white64) + } + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + onCancel() + } + .accessibilityIdentifier("ProfileEditCancel") + + CustomButton( + title: t("common__save"), + isLoading: isSaving + ) { + await onSave() + } + .accessibilityIdentifier("ProfileEditSave") + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + .background(Color.customBlack) + } + } +} diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift new file mode 100644 index 000000000..9af821326 --- /dev/null +++ b/Bitkit/Components/PubkyImage.swift @@ -0,0 +1,203 @@ +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() async { + lock.lock() + memoryCache.removeAll() + lock.unlock() + + await withCheckedContinuation { continuation in + diskQueue.async { [diskDirectory] in + try? FileManager.default.removeItem(at: diskDirectory) + try? FileManager.default.createDirectory(at: diskDirectory, withIntermediateDirectories: true) + continuation.resume() + } + } + } + + 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..4da84ebc4 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -34,6 +34,14 @@ enum Env { (infoPlistValue("E2E_BACKEND") ?? "local").lowercased() } + private static var isLocalE2EBackend: Bool { + isE2E && e2eBackend == "local" + } + + private static var e2eHomegateUrl: String { + infoPlistValue("E2E_HOMEGATE_URL") ?? "http://127.0.0.1:6288" + } + private static var e2eNetwork: LDKNode.Network { switch (infoPlistValue("E2E_NETWORK") ?? "regtest").lowercased() { case "bitcoin", "mainnet": @@ -258,6 +266,30 @@ 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. + static var homegateUrl: String { + if isLocalE2EBackend { + return e2eHomegateUrl + } + + switch network { + case .bitcoin: + return "https://homegate.pubky.app" + default: + 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 06660c787..911bcca45 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,37 @@ 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 func pubkyInitializationErrorView(message: String) -> some View { + VStack(spacing: 16) { + Spacer() + + BodyMText(t("other__try_again")) + + BodySText(message, textColor: .white64) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + CustomButton(title: t("common__retry"), variant: .secondary) { + await pubkyProfile.initialize() + } + + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } private var navigationContent: some View { HomeScreen() @@ -305,10 +345,60 @@ struct MainNavView: View { case .savingsProgress: SavingsProgressView() // Profile & Contacts - case .contacts: ComingSoonScreen() - case .contactsIntro: ComingSoonScreen() - case .profile: ComingSoonScreen() - case .profileIntro: ComingSoonScreen() + case .contacts: + if let initializationErrorMessage = pubkyProfile.initializationErrorMessage { + pubkyInitializationErrorView(message: initializationErrorMessage) + } else 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: + if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { + missingPendingImportView(fallbackRoute: fallbackRoute) + } else if let profile = contactsManager.pendingImportProfile { + ContactImportOverviewView( + profile: profile, + contacts: contactsManager.pendingImportContacts + ) + } else { + missingPendingImportView(fallbackRoute: .payContacts) + } + case .contactImportSelect: + if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { + missingPendingImportView(fallbackRoute: fallbackRoute) + } else { + ContactImportSelectView(contacts: contactsManager.pendingImportContacts) + } + case let .addContact(publicKey): AddContactView(publicKey: publicKey) + case let .editContact(publicKey): EditContactView(publicKey: publicKey) + case .profile: + if let initializationErrorMessage = pubkyProfile.initializationErrorMessage { + pubkyInitializationErrorView(message: initializationErrorMessage) + } else 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() @@ -372,6 +462,18 @@ struct MainNavView: View { } } + @ViewBuilder + private func missingPendingImportView(fallbackRoute: Route) -> some View { + Color.customBlack + .task { + guard navigation.currentRoute?.isContactImportRoute == true else { + return + } + + navigation.path = [fallbackRoute] + } + } + private func handleClipboard() { Task { @MainActor in guard let uri = UIPasteboard.general.string else { diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift new file mode 100644 index 000000000..429567ffe --- /dev/null +++ b/Bitkit/Managers/ContactsManager.swift @@ -0,0 +1,668 @@ +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 +} + +enum PubkyPublicKeyFormat { + private static let rawKeyLength = 52 + private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") + + static let maximumInputLength = pubkyPrefix.count + rawKeyLength + + static func bounded(_ input: String) -> String { + String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(maximumInputLength)) + } + + static func normalized(_ input: String) -> String? { + let boundedInput = bounded(input) + let rawKey = stripPubkyPrefix(boundedInput) + + guard rawKey.count == rawKeyLength else { + return nil + } + + guard rawKey.allSatisfy({ allowedCharacters.contains($0) }) else { + return nil + } + + return ensurePubkyPrefix(rawKey) + } + + static func matches(_ lhs: String?, _ rhs: String?) -> Bool { + guard let lhs = lhs.flatMap(normalized), + let rhs = rhs.flatMap(normalized) + else { + return false + } + + return lhs == rhs + } +} + +enum ContactsManagerError: LocalizedError { + case invalidPublicKey + case cannotAddYourself + + var errorDescription: String? { + switch self { + case .invalidPublicKey: + return t("slashtags__contact_error_key") + case .cannotAddYourself: + return t("slashtags__contact_error_yourself") + } + } +} + +// 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 + @Published var loadErrorMessage: String? + + /// 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 hasPendingImport: Bool { + pendingImportProfile != nil && !pendingImportContacts.isEmpty + } + + 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 + loadErrorMessage = nil + clearPendingImport() + } + + func clearPendingImport() { + pendingImportProfile = nil + pendingImportContacts = [] + } + + // 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 + loadErrorMessage = nil + defer { isLoading = false } + + let basePath = contactsBasePath + Logger.info("Loading contacts from \(basePath) for \(publicKey)", context: "ContactsManager") + + do { + let sessionSecret = try getSessionSecret() + + let contactPaths = try await Task.detached { + try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: basePath) + }.value + + Logger.debug("Listed \(contactPaths.count) contacts from homeserver", context: "ContactsManager") + + let strippedKey = stripPubkyPrefix(publicKey) + + let loadedResult: (contacts: [PubkyContact], failures: Int, + missingFailures: Int, firstError: Error?) = await withTaskGroup(of: Result.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 .success(PubkyContact(publicKey: prefixedKey, profile: profile)) + } catch { + Logger.warn("Failed to load contact data for '\(prefixedKey)': \(error)", context: "ContactsManager") + return .failure(error) + } + } + } + + var results: [PubkyContact] = [] + var failures = 0 + var missingFailures = 0 + var firstError: Error? + + for await result in group { + switch result { + case let .success(contact): + results.append(contact) + case let .failure(error): + failures += 1 + if Self.isMissingContactsDataError(error) { + missingFailures += 1 + } + firstError = firstError ?? error + } + } + + return (results, failures, missingFailures, firstError) + } + + if !contactPaths.isEmpty, loadedResult.contacts.isEmpty { + if loadedResult.failures == loadedResult.missingFailures { + contacts = [] + hasLoaded = true + Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager") + return + } + throw loadedResult.firstError ?? PubkyServiceError.profileNotFound + } + + contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + hasLoaded = true + + if loadedResult.failures > 0 { + Logger.warn( + "Skipped \(loadedResult.failures) unreadable contacts while loading list", + context: "ContactsManager" + ) + } + + Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager") + } catch { + if Self.isMissingContactsDataError(error) { + contacts = [] + hasLoaded = true + loadErrorMessage = nil + Logger.info("Contacts storage missing, treating list as empty", context: "ContactsManager") + return + } + + Logger.error("Failed to load contacts: \(error)", context: "ContactsManager") + if contacts.isEmpty { + loadErrorMessage = error.localizedDescription + } + throw error + } + } + + // MARK: - Add Contact (prefer bitkit.to profile, then pubky.app, then placeholder) + + func addContact(publicKey: String, existingProfile: PubkyProfile? = nil, ownPublicKey: String? = nil) async throws { + guard let prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw ContactsManagerError.invalidPublicKey + } + + if PubkyPublicKeyFormat.matches(prefixedKey, ownPublicKey) { + throw ContactsManagerError.cannotAddYourself + } + + guard !contacts.contains(where: { $0.publicKey == prefixedKey }) else { + Logger.debug("Contact \(prefixedKey) already exists, skipping add", context: "ContactsManager") + return + } + + // Use existing profile if provided (e.g., already fetched during preview), + // otherwise resolve remote profile with a placeholder fallback. + let profile: PubkyProfile = if let existingProfile { + PubkyProfile( + publicKey: prefixedKey, + name: existingProfile.name, + bio: existingProfile.bio, + imageUrl: existingProfile.imageUrl, + links: existingProfile.links, + tags: existingProfile.tags, + status: existingProfile.status + ) + } else { + try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true) + } + + // 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 (prefer bitkit.to profiles, then pubky.app, then placeholder) + + func importContacts(publicKeys: [String]) async throws { + let prefixedKeys = Array(Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized))) + + // Resolve profiles remotely, then write each to bitkit.to + let loadedResult: (contacts: [PubkyContact], failures: Int, + firstError: Error?) = await withTaskGroup(of: Result.self) { group in + for key in prefixedKeys { + group.addTask { [self] in + do { + let profile = await resolveImportContactProfile(publicKey: key) + let contactData = PubkyProfileData.from(profile: profile) + try await savePubkyProfileData(publicKey: key, data: contactData) + return .success(PubkyContact(publicKey: key, profile: profile)) + } catch { + Logger.warn("Failed to save imported contact '\(key)': \(error)", context: "ContactsManager") + return .failure(error) + } + } + } + + var results: [PubkyContact] = [] + var failures = 0 + var firstError: Error? + + for await result in group { + switch result { + case let .success(contact): + results.append(contact) + case let .failure(error): + failures += 1 + firstError = firstError ?? error + } + } + + return (results, failures, firstError) + } + + if !prefixedKeys.isEmpty, loadedResult.contacts.isEmpty { + throw loadedResult.firstError ?? PubkyServiceError.profileNotFound + } + + // Merge with existing contacts, avoiding duplicates + let existingKeys = Set(contacts.map(\.publicKey)) + let newContacts = loadedResult.contacts.filter { !existingKeys.contains($0.publicKey) } + contacts.append(contentsOf: newContacts) + contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + + if loadedResult.failures > 0 { + Logger.warn("Skipped \(loadedResult.failures) contacts during import", context: "ContactsManager") + } + + 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 } + } + + /// Delete all contacts from the homeserver and clear the local list. + func deleteAllContacts() async { + let sessionSecret: String + do { + sessionSecret = try getSessionSecret() + } catch { + Logger.warn("No active session, clearing local contacts only", context: "ContactsManager") + contacts.removeAll() + return + } + + let basePath = contactsBasePath + + let contactPaths: [String] + do { + contactPaths = try await Task.detached { + try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: basePath) + }.value + } catch { + if Self.isMissingContactsDataError(error) { + contacts.removeAll() + return + } + Logger.warn("Failed to list contacts for deletion: \(error)", context: "ContactsManager") + contacts.removeAll() + return + } + + for path in contactPaths { + let contactKey = extractPublicKey(from: path) + guard !contactKey.isEmpty else { continue } + do { + try await Task.detached { + try await PubkyService.sessionDelete( + sessionSecret: sessionSecret, + path: "\(basePath)\(contactKey)" + ) + }.value + } catch { + Logger.warn("Failed to delete contact '\(contactKey)': \(error)", context: "ContactsManager") + } + } + + contacts.removeAll() + Logger.info("Deleted all contacts", context: "ContactsManager") + } + + // MARK: - Discover Remote Contacts (list from pubky.app, then resolve each profile) + + /// 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 { + clearPendingImport() + await discoverRemoteContacts(publicKey: publicKey) + + guard !pendingImportContacts.isEmpty else { + return false + } + + pendingImportProfile = profile ?? PubkyProfile.placeholder(publicKey: ensurePubkyPrefix(publicKey)) + return true + } + + func destinationAfterAuthentication(profile: PubkyProfile?, publicKey: String) async -> Route { + let hasImportData = await prepareImport(profile: profile, publicKey: publicKey) + return hasImportData ? .contactImportOverview : .payContacts + } + + /// 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 discoveryResult: (contacts: [PubkyContact], failures: Int) = await withTaskGroup(of: Result.self) { group in + for key in contactKeys { + let pk = ensurePubkyPrefix(key) + group.addTask { [self] in + let profile = await resolveImportContactProfile(publicKey: pk) + return .success(PubkyContact(publicKey: pk, profile: profile)) + } + } + + var results: [PubkyContact] = [] + var failures = 0 + + for await result in group { + switch result { + case let .success(contact): + results.append(contact) + case .failure: + failures += 1 + } + } + + return (results, failures) + } + + if discoveryResult.failures > 0 { + Logger.warn("Skipped \(discoveryResult.failures) remote contacts during discovery", context: "ContactsManager") + } + + pendingImportContacts = discoveryResult.contacts.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } catch { + Logger.warn("Failed to discover remote contacts: \(error)", context: "ContactsManager") + pendingImportContacts = [] + } + } + + // MARK: - Fetch Contact Profile (prefer bitkit.to profile, then pubky.app) + + func fetchContactProfile(publicKey: String, includePlaceholder: Bool = false) async -> PubkyProfile? { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + return nil + } + + do { + return try await resolveContactProfile(publicKey: normalizedKey, includePlaceholder: includePlaceholder) + } catch { + return nil + } + } + + // 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 + } + + /// Resolve a contact profile using bitkit.to first, then pubky.app, optionally falling back to a placeholder. + private func resolveContactProfile(publicKey: String, includePlaceholder: Bool = false) async throws -> PubkyProfile { + let prefixedKey = ensurePubkyPrefix(publicKey) + do { + return try await PubkyProfileManager.resolveRemoteProfile(publicKey: prefixedKey) + } catch { + if includePlaceholder, Self.isMissingContactsDataError(error) { + Logger.info("No remote profile found for '\(prefixedKey)', using placeholder", context: "ContactsManager") + return PubkyProfile.placeholder(publicKey: prefixedKey) + } + + Logger.warn("Failed to resolve contact profile for '\(prefixedKey)': \(error)", context: "ContactsManager") + throw error + } + } + + private func resolveImportContactProfile(publicKey: String) async -> PubkyProfile { + let prefixedKey = ensurePubkyPrefix(publicKey) + + for attempt in 0 ..< 2 { + do { + return try await resolveContactProfile(publicKey: prefixedKey, includePlaceholder: true) + } catch { + if attempt == 0, !(error is CancellationError) { + Logger.warn( + "Retrying imported contact profile resolution for '\(prefixedKey)' after transient error: \(error)", + context: "ContactsManager" + ) + try? await Task.sleep(nanoseconds: 250_000_000) + continue + } + + Logger.warn( + "Falling back to placeholder while importing contact '\(prefixedKey)': \(error)", + context: "ContactsManager" + ) + return PubkyProfile.placeholder(publicKey: prefixedKey) + } + } + + 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) + } + + static func isMissingContactsDataError(_ error: Error) -> Bool { + if case .profileNotFound = error as? PubkyServiceError { + return true + } + + if let appError = error as? AppError, + isMissingContactsDataMessage(appError.debugMessage) + { + return true + } + + let nsError = error as NSError + + if nsError.domain == NSCocoaErrorDomain { + let cocoaCode = CocoaError.Code(rawValue: nsError.code) + if cocoaCode == .fileNoSuchFile || cocoaCode == .fileReadNoSuchFile { + return true + } + } + + if nsError.domain == NSPOSIXErrorDomain, nsError.code == Int(ENOENT) { + return true + } + + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorFileDoesNotExist { + return true + } + + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? Error { + return isMissingContactsDataError(underlyingError) + } + + if isMissingContactsDataMessage(String(describing: error)) + || isMissingContactsDataMessage(String(reflecting: error)) + { + return true + } + + if isMissingContactsDataMessage(error.localizedDescription) { + return true + } + + return false + } + + private static func isMissingContactsDataMessage(_ message: String?) -> Bool { + guard let message else { + return false + } + + let normalized = message.lowercased() + let indicatesMissingResource = normalized.contains("404") + || normalized.contains("no such file") + || normalized.contains("does not exist") + || normalized.contains("profile not found") + || normalized.contains("profilenotfound") + || (normalized.contains("fetch failed") && normalized.contains("not found")) + + return indicatesMissingResource + } +} diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift new file mode 100644 index 000000000..e93d8155a --- /dev/null +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -0,0 +1,659 @@ +import Foundation +import SwiftUI + +enum PubkyAuthState: Equatable { + case idle + case authenticating + case completingAuthentication + case authenticated + case error(String) +} + +private enum PubkyProfileManagerError: LocalizedError { + case avatarEncodingFailed + + var errorDescription: String? { + switch self { + case .avatarEncodingFailed: + return "Failed to encode avatar image" + } + } +} + +@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 initializationErrorMessage: String? + @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 { + isInitialized = false + initializationErrorMessage = nil + sessionRestorationFailed = false + + 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 { + // Both import and re-sign-in failed — session is invalid + Logger.error("Re-sign-in failed, clearing session: \(error)", context: "PubkyProfileManager") + try? Keychain.delete(key: .paykitSession) + return InitResult.restorationFailed + } + } + + // No secret key available (Ring-managed) — keep session for next attempt + // Could be a transient network issue; user gets a toast to reconnect if needed + Logger.warn("No secret key to recover session", context: "PubkyProfileManager") + return InitResult.restorationFailed + }.value + } catch { + Logger.error("Failed to initialize paykit: \(error)", context: "PubkyProfileManager") + authState = .idle + initializationErrorMessage = error.localizedDescription + return + } + + 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 + clearCachedProfileMetadata() + } + + isInitialized = 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 publicKey = rawKey.hasPrefix("pubky") ? rawKey : "pubky\(rawKey)" + return try await uploadAvatar(image: image, secretKeyHex: secretKeyHex, publicKey: publicKey) + } + + guard let publicKey, !publicKey.isEmpty else { + throw PubkyServiceError.sessionNotActive + } + + return try await uploadAvatar(image: image, sessionSecret: sessionSecret, publicKey: publicKey) + } + + /// Strip the `pubky` prefix from a public key for use in `pubky://` URIs. + private nonisolated static func stripPubkyPrefix(_ key: String) -> String { + key.hasPrefix("pubky") ? String(key.dropFirst(5)) : key + } + + private func compressAvatar(_ image: UIImage, maxSize: CGFloat = 400) throws -> Data { + // Resize to max dimensions + let scale = min(maxSize / image.size.width, maxSize / image.size.height, 1.0) + 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)) + } + + guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { + throw PubkyProfileManagerError.avatarEncodingFailed + } + return jpegData + } + + 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" + } + } + + private func uploadAvatar(image: UIImage, sessionSecret: String, publicKey: String) async throws -> String { + let imageData = try compressAvatar(image) + let blobPath = avatarBlobPath() + let blobUri = "pubky://\(Self.stripPubkyPrefix(publicKey))\(blobPath)" + + try await PubkyService.sessionPut(sessionSecret: sessionSecret, path: blobPath, content: imageData) + return blobUri + } + + private func uploadAvatar(image: UIImage, secretKeyHex: String, publicKey: String) async throws -> String { + let imageData = try compressAvatar(image) + let blobPath = avatarBlobPath() + let blobUri = "pubky://\(Self.stripPubkyPrefix(publicKey))\(blobPath)" + + try await PubkyService.putWithSecretKey(secretKeyHex: secretKeyHex, path: blobPath, content: imageData) + return blobUri + } + + /// 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. + nonisolated static func resolvedImageUrl(newImageUrl: String?, existingImageUrl: String?) -> String? { + newImageUrl ?? existingImageUrl + } + + func createIdentity( + name: String, + bio: String, + links: [PubkyProfileLink], + tags: [String] = [], + existingImageUrl: String? = nil, + 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) + } + + return session + }.value + + var avatarUri: String? + if let avatarImage { + avatarUri = try await uploadAvatar(image: avatarImage, sessionSecret: sessionSecret, publicKey: publicKeyZ32) + } + let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: avatarUri, existingImageUrl: existingImageUrl) + + try await writeProfile( + sessionSecret: sessionSecret, + name: name, + bio: bio, + imageUrl: resolvedImageUrl, + links: links, + tags: tags + ) + + do { + try Keychain.upsert(key: .pubkySecretKey, data: Data(secretKeyHex.utf8)) + try Keychain.upsert(key: .paykitSession, data: Data(sessionSecret.utf8)) + _ = try await PubkyService.importSession(secret: sessionSecret) + } catch { + try? Keychain.delete(key: .pubkySecretKey) + try? Keychain.delete(key: .paykitSession) + await PubkyService.forceSignOut() + throw error + } + + let createdProfile = PubkyProfile( + publicKey: publicKeyZ32, + name: name, + bio: bio, + imageUrl: resolvedImageUrl, + links: links, + tags: tags, + status: nil + ) + + publicKey = publicKeyZ32 + authState = .authenticated + 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 { + let sessionSecret = try activeSessionSecret() + + let resolvedImageUrl = Self.resolvedImageUrl(newImageUrl: newImageUrl, existingImageUrl: 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) + } + + func deleteProfile() async throws { + let sessionSecret = try activeSessionSecret() + let path = Self.profilePath + + try await Task.detached { + try await PubkyService.sessionDelete( + sessionSecret: sessionSecret, + path: path + ) + }.value + + await signOut() + } + + /// 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 nonisolated static var profilePath: String { + switch Env.network { + case .bitcoin: + return "/pub/bitkit.to/profile.json" + default: + return "/pub/staging.bitkit.to/profile.json" + } + } + + static func isRingAvailable() -> Bool { + guard let url = URL(string: "pubkyauth://check") else { + return false + } + + return UIApplication.shared.canOpenURL(url) + } + + // 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 + + guard Self.isRingAvailable() else { + authState = .idle + throw PubkyServiceError.ringNotInstalled + } + + 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 { + await cancelPendingAuthSetup() + authState = .idle + throw PubkyServiceError.invalidAuthUrl + } + + let canOpen = UIApplication.shared.canOpenURL(url) + guard canOpen else { + await cancelPendingAuthSetup() + authState = .idle + throw PubkyServiceError.ringNotInstalled + } + + let didOpen = await UIApplication.shared.open(url) + guard didOpen else { + await cancelPendingAuthSetup() + authState = .idle + throw PubkyServiceError.authFailed("Failed to open Pubky Ring") + } + } + + /// Long-polls the relay, persists + imports the session, and loads the profile in a single off-main-actor pass. + @discardableResult + func completeAuthentication() async throws -> String { + 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 = .completingAuthentication + Logger.info("Pubky auth completed for \(pk)", context: "PubkyProfileManager") + await loadProfile() + return pk + } catch let serviceError as PubkyServiceError { + authState = .idle + throw serviceError + } catch { + authState = .error(error.localizedDescription) + throw error + } + } + + func finalizeAuthentication() { + guard case .completingAuthentication = authState else { return } + authState = .authenticated + } + + // MARK: - Profile + + func loadProfile() async { + guard let pk = publicKey, !isLoadingProfile else { return } + + isLoadingProfile = true + + do { + let loadedProfile = try await Task.detached { + try await Self.resolveRemoteProfile(publicKey: pk) + }.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? { + do { + return try await Self.resolveRemoteProfile(publicKey: publicKey) + } catch { + Logger.debug("No remote profile found for \(publicKey): \(error)", context: "PubkyProfileManager") + return nil + } + } + + nonisolated static func resolveRemoteProfile(publicKey: String) async throws -> PubkyProfile { + try await resolveRemoteProfile( + publicKey: publicKey, + fetchBitkitProfile: fetchBitkitProfile, + fetchPubkyProfile: fetchPubkyProfile + ) + } + + nonisolated static func resolveRemoteProfile( + publicKey: String, + fetchBitkitProfile: @escaping @Sendable (String) async -> PubkyProfile?, + fetchPubkyProfile: @escaping @Sendable (String) async throws -> PubkyProfile + ) async throws -> PubkyProfile { + if let bitkitProfile = await fetchBitkitProfile(publicKey) { + return bitkitProfile + } + + return try await fetchPubkyProfile(publicKey) + } + + /// Read the user's bitkit profile.json which contains the complete profile data we wrote. + private nonisolated 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 + } + } + + private nonisolated static func fetchPubkyProfile(publicKey: String) async throws -> PubkyProfile { + let profileDto = try await PubkyService.getProfile(publicKey: publicKey) + Logger.debug( + "Profile loaded from pubky FFI — name: \(profileDto.name), image: \(profileDto.image ?? "nil")", + context: "PubkyProfileManager" + ) + return PubkyProfile(publicKey: publicKey, ffiProfile: profileDto) + } + + // MARK: - Sign Out + + static func clearLocalState() async { + await PubkyService.forceSignOut() + try? Keychain.delete(key: .paykitSession) + try? Keychain.delete(key: .pubkySecretKey) + await PubkyImageCache.shared.clear() + UserDefaults.standard.removeObject(forKey: cachedNameKey) + UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + } + + func signOut() async { + await Task.detached { + do { + try await PubkyService.signOut() + } catch { + Logger.warn("Server sign out failed, forcing local sign out: \(error)", context: "PubkyProfileManager") + } + await Self.clearLocalState() + }.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) + } + + private func activeSessionSecret() throws -> String { + guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), + !sessionSecret.isEmpty + else { + throw PubkyServiceError.sessionNotActive + } + return sessionSecret + } + + // MARK: - Helpers + + var isAuthenticated: Bool { + authState == .authenticated + } + + private func cancelPendingAuthSetup() async { + do { + try await Task.detached { + try await PubkyService.cancelAuth() + }.value + } catch { + Logger.warn("Cancel pending auth setup failed: \(error)", context: "PubkyProfileManager") + } + } +} 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..a228ab37b --- /dev/null +++ b/Bitkit/Models/PubkyProfile.swift @@ -0,0 +1,146 @@ +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(label: String, url: String) { + self.label = label + self.url = url + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + label = try container.decodeIfPresent(String.self, forKey: .label) ?? "" + url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + } + } + + 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.decodeIfPresent(String.self, forKey: .name) ?? "" + bio = try container.decodeIfPresent(String.self, forKey: .bio) ?? "" + image = try container.decodeIfPresent(String.self, forKey: .image) + links = try container.decodeIfPresent([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 0b97c483a..e3fba4dae 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"; @@ -608,6 +609,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."; @@ -939,6 +957,47 @@ "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__delete_label" = "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_detail" = "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"; @@ -974,6 +1033,69 @@ "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__deriving_keys" = "Deriving your keys…"; +"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" = "NOTES"; +"profile__create_bio_placeholder" = "Short note. 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_delete_section" = "DELETE"; +"profile__edit_public_note" = "Please note that all your profile information will be publicly available and visible."; +"profile__delete_title" = "Delete Profile?"; +"profile__delete_description" = "This will delete your Pubky profile."; +"profile__delete_confirm" = "Yes, Delete"; +"profile__delete_label" = "Delete Profile"; +"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/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 883af6594..ac5de5933 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -27,6 +27,9 @@ enum AppReset { let coreWipeResult = try await wipeAllDatabases() Logger.info("Core DB wipe: \(coreWipeResult)") + // Clear any live Pubky runtime state and cached profile images. + await PubkyProfileManager.clearLocalState() + // Wipe keychain try Keychain.wipeEntireKeychain() diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index f251d0f0f..0bd805741 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 { @@ -13,6 +15,8 @@ enum KeychainEntryType { case let .bip39Passphrase(index): "bip39_passphrase_\(index)" case .pushNotificationPrivateKey: "push_notification_private_key" case .securityPin: "security_pin" + case .paykitSession: "paykit_session" + case .pubkySecretKey: "pubky_secret_key" } } } @@ -122,6 +126,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 @@ -130,6 +139,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 48d7c8b78..d8fe398bc 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: @@ -548,6 +550,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 7b8219e16..29a7c7c60 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 @@ -93,6 +102,29 @@ enum Route: Hashable { case logs } +extension Route { + var isContactImportRoute: Bool { + switch self { + case .contactImportOverview, .contactImportSelect: + true + default: + false + } + } +} + +func shouldDiscardPendingImport(currentRoute: Route?, destination: Route?) -> Bool { + guard currentRoute?.isContactImportRoute == true else { + return false + } + + return destination?.isContactImportRoute != true +} + +func fallbackRouteForMissingPendingImport(hasPendingImport: Bool) -> Route? { + hasPendingImport ? nil : .payContacts +} + @MainActor class NavigationViewModel: ObservableObject { @Published var path: [Route] = [] diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 4efe4519a..69219ea2b 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..763da064f --- /dev/null +++ b/Bitkit/Views/Contacts/AddContactSheet.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct AddContactSheet: View { + @Environment(\.dismiss) private var dismiss + + let currentPublicKey: String? + let onAdd: (String) -> Void + let onScanQR: () -> Void + + @State private var pubkyInput: String = "" + + private var trimmedInput: String { + pubkyInput.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var validationMessage: String? { + guard !trimmedInput.isEmpty else { + return nil + } + + if PubkyPublicKeyFormat.matches(trimmedInput, currentPublicKey) { + return t("slashtags__contact_error_yourself") + } + + guard PubkyPublicKeyFormat.normalized(trimmedInput) != nil else { + return t("slashtags__contact_error_key") + } + + return nil + } + + private var canAdd: Bool { + validationMessage == nil && !trimmedInput.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() + .keyboardType(.asciiCapable) + .onChange(of: pubkyInput) { _, newValue in + let boundedInput = PubkyPublicKeyFormat.bounded(newValue) + if boundedInput != newValue { + pubkyInput = boundedInput + } + } + + Button { + if let clipboard = UIPasteboard.general.string { + pubkyInput = PubkyPublicKeyFormat.bounded(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) + + if let validationMessage { + BodySText(validationMessage, textColor: .red) + .fixedSize(horizontal: false, vertical: true) + } + } + + 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"), isDisabled: !canAdd) { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(trimmedInput) else { + return + } + + onAdd(normalizedKey) + dismiss() + } + .accessibilityIdentifier("AddContactAdd") + } + } + .padding(.horizontal, 16) + } + .sheetBackground() + .presentationDetents([.medium]) + .presentationCornerRadius(32) + .presentationDragIndicator(.hidden) + } +} + +#Preview { + Color.clear + .sheet(isPresented: .constant(true)) { + AddContactSheet(currentPublicKey: nil, 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..eee6425ea --- /dev/null +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -0,0 +1,245 @@ +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 + @State private var errorMessage: String? + @State private var canRetry = true + + 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(errorMessage ?? t("contacts__add_error")) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + if canRetry { + CustomButton(title: t("profile__retry_load"), variant: .secondary) { + await loadProfile() + } + .accessibilityIdentifier("AddContactRetry") + } else { + CustomButton(title: t("common__discard"), variant: .secondary) { + navigation.navigateBack() + } + .accessibilityIdentifier("AddContactDiscardInvalid") + } + Spacer() + } + .padding(.horizontal, 32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Data Loading + + private func loadProfile() async { + isLoading = true + fetchedProfile = nil + errorMessage = nil + canRetry = true + + guard PubkyPublicKeyFormat.normalized(publicKey) != nil else { + errorMessage = t("slashtags__contact_error_key") + canRetry = false + isLoading = false + return + } + + if PubkyPublicKeyFormat.matches(publicKey, pubkyProfile.publicKey) { + errorMessage = t("slashtags__contact_error_yourself") + canRetry = false + isLoading = false + return + } + + if let profile = await contactsManager.fetchContactProfile(publicKey: publicKey, includePlaceholder: true) { + fetchedProfile = profile + } else { + errorMessage = t("contacts__add_error") + } + + isLoading = false + } + + // MARK: - Save Contact + + private func saveContact() async { + isSaving = true + defer { isSaving = false } + + do { + try await contactsManager.addContact( + publicKey: publicKey, + existingProfile: fetchedProfile, + ownPublicKey: pubkyProfile.publicKey + ) + 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"), description: error.localizedDescription) + } + } +} + +#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..b021dca34 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -0,0 +1,264 @@ +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 showAddTagSheet = false + @State private var hasResolvedContactFromContacts = 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 + hasResolvedContactFromContacts = true + } + isLoading = false + } + .onReceive(contactsManager.$contacts) { updatedContacts in + if let cached = updatedContacts.first(where: { $0.publicKey == publicKey }) { + profile = cached.profile + hasResolvedContactFromContacts = true + } else if hasResolvedContactFromContacts { + hasResolvedContactFromContacts = false + profile = nil + navigation.path = [.contacts] + } + } + } + + // 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, + nameAccessibilityIdentifier: "ContactViewName", + notesAccessibilityIdentifier: "ContactViewNotes" + ) + .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") + } + } + + // MARK: - Links / Metadata + + @ViewBuilder + private func linksSection(_ profile: PubkyProfile) -> some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(profile.links.enumerated()), id: \.element.id) { index, link in + ProfileLinkRow(label: link.label, value: link.url, linkIndex: index) + } + } + } + + // MARK: - Tags + + @ViewBuilder + private func tagsSection(_ profile: PubkyProfile) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__create_tags_label"), textColor: .white64) + .accessibilityIdentifier("ContactViewTagsHeader") + + 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) { + if let contact = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) { + profile = contact.profile + } else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey) { + profile = fetched + } + } + .accessibilityIdentifier("ContactRetry") + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // 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..d5264c5d7 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -0,0 +1,207 @@ +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 overflow = contacts.count - displayContacts.count + + ZStack(alignment: .leading) { + ForEach(Array(displayContacts.enumerated()), id: \.element.id) { index, contact in + contactImportAvatar(contact) + .offset(x: CGFloat(index * 24)) + } + + if overflow > 0 { + Circle() + .fill(Color.gray4) + .frame(width: 36, height: 36) + .overlay { + Text("+\(overflow)") + .font(Fonts.bold(size: 12)) + .foregroundColor(.textPrimary) + } + .offset(x: CGFloat(displayContacts.count * 24)) + } + } + .frame(width: CGFloat(max(displayContacts.count - 1, 0) * 24 + 36), height: 36, alignment: .leading) + .accessibilityHidden(true) + } + + @ViewBuilder + private func contactImportAvatar(_ contact: PubkyContact) -> some View { + if let imageUrl = contact.profile.imageUrl { + PubkyImage(uri: imageUrl, size: 36) + } else { + Circle() + .fill(Color.white.opacity(0.1)) + .frame(width: 36, height: 36) + .overlay { + Text(String(contact.displayName.prefix(1)).uppercased()) + .font(Fonts.bold(size: 13)) + .foregroundColor(.textPrimary) + } + } + } + + // 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)) + contactsManager.clearPendingImport() + navigation.path = [.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..d9c34f556 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -0,0 +1,241 @@ +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 { + contactsManager.clearPendingImport() + navigation.path = [.payContacts] + return + } + + isImporting = true + defer { isImporting = false } + + do { + try await contactsManager.importContacts(publicKeys: selected.map(\.publicKey)) + contactsManager.clearPendingImport() + navigation.path = [.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..5aa9d0858 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactsListView.swift @@ -0,0 +1,322 @@ +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, let errorMessage = contactsManager.loadErrorMessage { + errorContent(message: errorMessage) + } 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 { + await loadContacts() + } + .sheet(isPresented: $showAddContactSheet) { + AddContactSheet( + currentPublicKey: pubkyProfile.publicKey, + 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(.profile) + } + .accessibilityIdentifier("ContactsMyProfile") + + CustomDivider() + } + } + + // MARK: - Contacts List + + @ViewBuilder + private var contactsList: some View { + if !filteredContacts.isEmpty { + VStack(alignment: .leading, spacing: 0) { + sectionHeader(t("contacts__nav_title").uppercased()) + CustomDivider() + + ForEach(filteredContacts) { 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 Contacts + + private var filteredContacts: [PubkyContact] { + let trimmed = searchText.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return contactsManager.contacts } + + let query = trimmed.lowercased() + return contactsManager.contacts.filter { + $0.displayName.lowercased().contains(query) || + $0.publicKey.lowercased().contains(query) + } + } + + // MARK: - Loading & Empty States + + @ViewBuilder + private var loadingContent: some View { + VStack { + Spacer() + ActivityIndicator(size: 32) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func errorContent(message: String) -> some View { + VStack(spacing: 16) { + Spacer() + + BodyMText(t("contacts__error_loading")) + + if message != t("contacts__error_loading") { + BodySText(message, textColor: .white64) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + + CustomButton(title: t("profile__retry_load"), variant: .secondary) { + await loadContacts() + } + .accessibilityIdentifier("ContactsRetry") + + Spacer() + } + .padding(.horizontal, 32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var emptyContent: some View { + VStack(spacing: 0) { + if pubkyProfile.isAuthenticated, let profile = pubkyProfile.profile { + VStack(alignment: .leading, spacing: 0) { + myProfileSection(profile) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + VStack(spacing: 16) { + BodyMText(t("contacts__empty_state"), textColor: .white64) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + CustomButton(title: t("contacts__add_button")) { + showAddContactSheet = true + } + .accessibilityIdentifier("ContactsEmptyAddButton") + } + .padding(.horizontal, 32) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func loadContacts() async { + guard let pk = pubkyProfile.publicKey else { return } + + do { + try await contactsManager.loadContacts(for: pk) + } catch { + Logger.error("Failed to load contacts in view: \(error)", context: "ContactsListView") + + if !contactsManager.contacts.isEmpty { + app.toast( + type: .error, + title: t("contacts__error_loading"), + description: error.localizedDescription + ) + } + } + } +} + +#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..36d30bc17 --- /dev/null +++ b/Bitkit/Views/Contacts/EditContactView.swift @@ -0,0 +1,179 @@ +import PhotosUI +import SwiftUI + +struct EditContactView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var pubkyProfile: PubkyProfileManager + + 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 + @State private var showDeleteConfirmation = false + @State private var selectedPhotoItem: PhotosPickerItem? + @State private var avatarImage: UIImage? + + 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, + publicKeyLabel: t("profile__create_pubky_label"), + isSaving: isSaving, + footerNote: nil, + deleteLabel: t("contacts__delete_label"), + onSave: { await saveContact() }, + onCancel: { navigation.navigateBack() }, + onDelete: { showDeleteConfirmation = true } + ) { + avatarSection + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + loadContactData() + } + .alert(t("contacts__delete_title", variables: ["name": name]), isPresented: $showDeleteConfirmation) { + Button(t("contacts__delete_confirm"), role: .destructive) { + Task { await deleteContact() } + } + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("contacts__delete_description")) + } + } + + // MARK: - Avatar + + @ViewBuilder + private var avatarSection: some View { + PhotosPicker(selection: $selectedPhotoItem, matching: .images) { + Group { + if let avatarImage { + Image(uiImage: avatarImage) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } else 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) + } + } + } + } + .accessibilityIdentifier("EditContactAvatar") + .accessibilityLabel(t("profile__create_avatar_label")) + .onChange(of: selectedPhotoItem) { _, newItem in + Task { await loadSelectedImage(newItem) } + } + .frame(maxWidth: .infinity) + } + + 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: "EditContactView") + } + selectedPhotoItem = nil + } + + // 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: - Delete + + private func deleteContact() async { + do { + try await contactsManager.removeContact(publicKey: publicKey) + app.toast(type: .success, title: t("contacts__delete_success")) + navigation.path = [.contacts] + } catch { + Logger.error("Failed to delete contact: \(error)", context: "EditContactView") + app.toast(type: .error, title: t("contacts__delete_error")) + } + } + + // MARK: - Save + + private func saveContact() async { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + isSaving = true + defer { isSaving = false } + + do { + let uploadedImageUrl = if let avatarImage { + try await pubkyProfile.uploadAvatar(image: avatarImage) + } else { + imageUrl + } + + try await contactsManager.updateContact( + publicKey: publicKey, + name: trimmedName, + bio: bio.trimmingCharacters(in: .whitespacesAndNewlines), + imageUrl: uploadedImageUrl, + links: links.map { PubkyProfileLink(label: $0.label, url: $0.url) }, + tags: tags + ) + imageUrl = uploadedImageUrl + 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()) + .environmentObject(PubkyProfileManager()) + } + .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..de1e4fca0 --- /dev/null +++ b/Bitkit/Views/Profile/CreateProfileView.swift @@ -0,0 +1,242 @@ +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(spacing: 12) { + Spacer() + ActivityIndicator(size: 32) + BodyMText(t("profile__deriving_keys"), textColor: .white64) + 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 ?? [], + existingImageUrl: existingProfile?.imageUrl, + 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..dc53ede02 --- /dev/null +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -0,0 +1,178 @@ +import PhotosUI +import SwiftUI + +struct EditProfileView: View { + @EnvironmentObject var app: AppViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var contactsManager: ContactsManager + + @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 showDeleteConfirmation = 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 ?? "...", + publicKeyLabel: t("profile__create_pubky_display_label"), + isSaving: isSaving, + footerNote: t("profile__edit_public_note"), + deleteLabel: t("profile__delete_label"), + onSave: { await saveProfile() }, + onCancel: { navigation.navigateBack() }, + onDelete: { showDeleteConfirmation = true } + ) { + avatarPicker + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .bottomSafeAreaPadding() + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + loadProfileData() + } + .alert(t("profile__delete_title"), isPresented: $showDeleteConfirmation) { + Button(t("profile__delete_confirm"), role: .destructive) { + Task { await deleteProfile() } + } + Button(t("common__dialog_cancel"), role: .cancel) {} + } message: { + Text(t("profile__delete_description")) + } + } + + // 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: - Delete Profile + + private func deleteProfile() async { + do { + await contactsManager.deleteAllContacts() + try await pubkyProfile.deleteProfile() + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } catch { + Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") + app.toast(type: .error, title: t("profile__edit_error_title"), description: error.localizedDescription) + } + } + + // 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"), accessibilityIdentifier: "ProfileUpdatedToast") + 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()) + .environmentObject(ContactsManager()) + } + .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..cf364160e --- /dev/null +++ b/Bitkit/Views/Profile/ProfileView.swift @@ -0,0 +1,258 @@ +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, + nameAccessibilityIdentifier: "ProfileViewName", + notesAccessibilityIdentifier: "ProfileViewNotes" + ) + .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) + } + } + .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: "ProfilePubkyCopiedToast") + } + } + .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(Array(profile.links.enumerated()), id: \.element.id) { index, link in + ProfileLinkRow(label: link.label, value: link.url, linkIndex: index) + } + } + } + + // MARK: - Tags + + @ViewBuilder + private func profileTags(_ profile: PubkyProfile) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("profile__create_tags_label"), textColor: .white64) + .accessibilityIdentifier("ProfileViewTagsHeader") + + WrappingHStack(spacing: 8) { + ForEach(profile.tags, id: \.self) { tag in + Tag(tag) + } + } + } + } + + // 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 + let linkIndex: Int + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(label, textColor: .white64) + .accessibilityIdentifier("ProfileLinkLabel_\(linkIndex)") + + BodySSBText(value, textColor: .white) + .accessibilityIdentifier("ProfileLinkValue_\(linkIndex)") + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 16) + .accessibilityElement(children: .contain) + + CustomDivider() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#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..c00e7f317 --- /dev/null +++ b/Bitkit/Views/Profile/PubkyChoiceView.swift @@ -0,0 +1,272 @@ +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 { + let publicKey = try await pubkyProfile.completeAuthentication() + isLoadingAfterAuth = true + await navigateAfterAuth(publicKey: publicKey) + } 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(publicKey: String) async { + let destination = await contactsManager.destinationAfterAuthentication( + profile: pubkyProfile.profile, + publicKey: publicKey + ) + navigation.path = [destination] + pubkyProfile.finalizeAuthentication() + } + + // 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..bc2d2fd2f --- /dev/null +++ b/Bitkit/Views/Profile/PubkyRingAuthView.swift @@ -0,0 +1,204 @@ +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() { + isRingInstalled = PubkyProfileManager.isRingAvailable() + } + + 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 { + let publicKey = try await pubkyProfile.completeAuthentication() + isLoadingAfterAuth = true + await navigateAfterAuth(publicKey: publicKey) + } 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(publicKey: String) async { + let destination = await contactsManager.destinationAfterAuthentication( + profile: pubkyProfile.profile, + publicKey: publicKey + ) + navigation.path = [destination] + pubkyProfile.finalizeAuthentication() + } +} + +#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/Security/AuthCheck.swift b/Bitkit/Views/Security/AuthCheck.swift index aa11703cb..2f08c8277 100644 --- a/Bitkit/Views/Security/AuthCheck.swift +++ b/Bitkit/Views/Security/AuthCheck.swift @@ -13,6 +13,7 @@ struct AuthCheck: View { @State private var biometricFailedOnce = false @State private var errorIdentifier: String? + let onCancel: (() -> Void)? let onPinVerified: () -> Void private var biometryTypeName: String { @@ -106,6 +107,26 @@ struct AuthCheck: View { var body: some View { VStack(spacing: 0) { + if let onCancel { + HStack(spacing: 0) { + Button(action: onCancel) { + Image("arrow-left") + .resizable() + .scaledToFit() + .foregroundColor(.textPrimary) + .frame(width: 24, height: 24) + } + .accessibilityIdentifier("NavigationBack") + + Spacer() + } + .frame(height: 48) + .padding(.horizontal, 16) + } else { + Spacer() + .frame(height: 48) + } + Spacer() Image("logo") @@ -161,11 +182,16 @@ struct AuthCheck: View { } #Preview { - AuthCheck { - print("PIN verified!") - } + AuthCheck( + onCancel: nil, + onPinVerified: { + print("PIN verified!") + } + ) + .environmentObject(AppViewModel()) .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) .environmentObject(WalletViewModel()) - .environmentObject(AppViewModel()) + .environmentObject(SessionManager()) .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift new file mode 100644 index 000000000..03e3723b8 --- /dev/null +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -0,0 +1,330 @@ +import SwiftUI + +// MARK: - Config & Sheet Item + +enum PubkyApprovalLocalAuthMode: Equatable { + case authCheck + case biometrics + case none +} + +func resolvePubkyApprovalLocalAuthMode( + isPinEnabled: Bool, + isBiometricEnabled: Bool, + isBiometrySupported: Bool +) -> PubkyApprovalLocalAuthMode { + if isPinEnabled { + return .authCheck + } + + if isBiometricEnabled, isBiometrySupported { + return .biometrics + } + + return .none +} + +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 + @EnvironmentObject private var settings: SettingsViewModel + + let config: PubkyAuthApprovalSheetItem + + @State private var state: ApprovalState = .authorize + @State private var isShowingAuthCheck = false + + 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) + } + .fullScreenCover(isPresented: $isShowingAuthCheck) { + AuthCheck( + onCancel: { + isShowingAuthCheck = false + }, + onPinVerified: { + isShowingAuthCheck = false + Task { + await confirmAuthorize() + } + } + ) + } + } + + // 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 + + @MainActor + private func onAuthorize() async { + switch resolvePubkyApprovalLocalAuthMode( + isPinEnabled: settings.pinEnabled, + isBiometricEnabled: settings.useBiometrics, + isBiometrySupported: BiometricAuth.isAvailable + ) { + case .authCheck: + isShowingAuthCheck = true + case .biometrics: + await authorizeWithBiometrics() + case .none: + await confirmAuthorize() + } + } + + @MainActor + private func authorizeWithBiometrics() async { + let biometricResult = await BiometricAuth.authenticate() + + switch biometricResult { + case .success: + await confirmAuthorize() + case .cancelled: + return + case let .failed(message): + app.toast(type: .error, title: t("pubky_auth__biometric_failed"), description: message) + } + } + + @MainActor + private func confirmAuthorize() async { + state = .authorizing + + 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/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift new file mode 100644 index 000000000..ccf64a4d2 --- /dev/null +++ b/BitkitTests/ContactsManagerTests.swift @@ -0,0 +1,141 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class ContactsManagerTests: XCTestCase { + func testPubkyPublicKeyFormatNormalizesPrefixedAndUnprefixedKeys() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let prefixedKey = "pubky\(rawKey)" + + XCTAssertEqual(PubkyPublicKeyFormat.normalized(rawKey), prefixedKey) + XCTAssertEqual(PubkyPublicKeyFormat.normalized(prefixedKey), prefixedKey) + } + + func testPubkyPublicKeyFormatRejectsInvalidLengthAndCharacters() { + XCTAssertNil(PubkyPublicKeyFormat.normalized("pubkyshort")) + XCTAssertNil(PubkyPublicKeyFormat.normalized("pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5x0")) + } + + func testPubkyPublicKeyFormatMatchesEquivalentRepresentations() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let prefixedKey = "pubky\(rawKey)" + + XCTAssertTrue(PubkyPublicKeyFormat.matches(rawKey, prefixedKey)) + XCTAssertFalse(PubkyPublicKeyFormat.matches(prefixedKey, "pubkyinvalid")) + } + + func testClearPendingImportOnlyClearsTemporaryImportState() { + let manager = ContactsManager() + let profile = makeProfile(publicKey: "pubky_profile") + let contact = makeContact(publicKey: "pubky_contact") + + manager.contacts = [contact] + manager.hasLoaded = true + manager.loadErrorMessage = "still here" + manager.pendingImportProfile = profile + manager.pendingImportContacts = [contact] + + manager.clearPendingImport() + + XCTAssertEqual(manager.contacts, [contact]) + XCTAssertTrue(manager.hasLoaded) + XCTAssertEqual(manager.loadErrorMessage, "still here") + XCTAssertNil(manager.pendingImportProfile) + XCTAssertTrue(manager.pendingImportContacts.isEmpty) + XCTAssertFalse(manager.hasPendingImport) + } + + func testHasPendingImportRequiresProfileAndContacts() { + let manager = ContactsManager() + + manager.pendingImportProfile = makeProfile(publicKey: "pubky_profile") + XCTAssertFalse(manager.hasPendingImport) + + manager.pendingImportContacts = [makeContact(publicKey: "pubky_contact")] + XCTAssertTrue(manager.hasPendingImport) + } + + func testIsMissingContactsDataErrorRecognizesMissingCocoaFileError() { + let error = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.fileNoSuchFile.rawValue) + + XCTAssertTrue(ContactsManager.isMissingContactsDataError(error)) + } + + func testIsMissingContactsDataErrorRecognizesUnderlyingMissingFileError() { + let underlying = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.fileReadNoSuchFile.rawValue) + let wrapped = NSError(domain: "BitkitTests", code: 99, userInfo: [NSUnderlyingErrorKey: underlying]) + + XCTAssertTrue(ContactsManager.isMissingContactsDataError(wrapped)) + } + + func testIsMissingContactsDataErrorRecognizesWrappedAppErrorNotFoundMessage() { + let error = AppError(message: "App Error", debugMessage: "Fetch failed: 404 Not Found") + + XCTAssertTrue(ContactsManager.isMissingContactsDataError(error)) + } + + func testIsMissingContactsDataErrorRecognizesPubkyProfileNotFoundIdentifier() { + let error = AppError(message: "App Error", debugMessage: "BitkitCore.PubkyError.ProfileNotFound") + + XCTAssertTrue(ContactsManager.isMissingContactsDataError(error)) + } + + func testIsMissingContactsDataErrorDoesNotTreatGenericNotFoundAsEmptyContacts() { + let error = AppError(message: "App Error", debugMessage: "Resolution failed: relay host not found") + + XCTAssertFalse(ContactsManager.isMissingContactsDataError(error)) + } + + func testIsMissingContactsDataErrorRecognizesProfileNotFound() { + XCTAssertTrue(ContactsManager.isMissingContactsDataError(PubkyServiceError.profileNotFound)) + } + + func testIsMissingContactsDataErrorRejectsNonMissingErrors() { + let error = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.fileReadCorruptFile.rawValue) + + XCTAssertFalse(ContactsManager.isMissingContactsDataError(error)) + } + + func testShouldDiscardPendingImportWhenLeavingImportFlow() { + XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contacts)) + XCTAssertTrue(shouldDiscardPendingImport(currentRoute: .contactImportSelect, destination: nil)) + } + + func testShouldNotDiscardPendingImportWhenStayingInsideImportFlow() { + XCTAssertFalse(shouldDiscardPendingImport(currentRoute: .contactImportOverview, destination: .contactImportSelect)) + XCTAssertFalse(shouldDiscardPendingImport(currentRoute: .contacts, destination: .profile)) + } + + func testDeleteAllContactsClearsLocalList() async { + let manager = ContactsManager() + manager.contacts = [ + makeContact(publicKey: "pubkyaaa"), + makeContact(publicKey: "pubkybbb"), + ] + + await manager.deleteAllContacts() + + XCTAssertTrue(manager.contacts.isEmpty) + } + + func testFallbackRouteForMissingPendingImportUsesPayContacts() { + XCTAssertEqual(fallbackRouteForMissingPendingImport(hasPendingImport: false), .payContacts) + XCTAssertNil(fallbackRouteForMissingPendingImport(hasPendingImport: true)) + } + + private func makeProfile(publicKey: String) -> PubkyProfile { + PubkyProfile( + publicKey: publicKey, + name: "Alice", + bio: "bio", + imageUrl: nil, + links: [], + tags: [], + status: nil + ) + } + + private func makeContact(publicKey: String) -> PubkyContact { + PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey)) + } +} diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift new file mode 100644 index 000000000..3e8f24e80 --- /dev/null +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -0,0 +1,44 @@ +@testable import Bitkit +import XCTest + +final class PubkyAuthApprovalSheetTests: XCTestCase { + func testResolvePubkyApprovalLocalAuthModePrefersPinWhenPinEnabled() { + let mode = resolvePubkyApprovalLocalAuthMode( + isPinEnabled: true, + isBiometricEnabled: true, + isBiometrySupported: true + ) + + XCTAssertEqual(mode, .authCheck) + } + + func testResolvePubkyApprovalLocalAuthModeUsesBiometricsWhenPinDisabled() { + let mode = resolvePubkyApprovalLocalAuthMode( + isPinEnabled: false, + isBiometricEnabled: true, + isBiometrySupported: true + ) + + XCTAssertEqual(mode, .biometrics) + } + + func testResolvePubkyApprovalLocalAuthModeUsesNoneWhenBiometricsDisabled() { + let mode = resolvePubkyApprovalLocalAuthMode( + isPinEnabled: false, + isBiometricEnabled: false, + isBiometrySupported: true + ) + + XCTAssertEqual(mode, .none) + } + + func testResolvePubkyApprovalLocalAuthModeUsesNoneWhenBiometricsUnavailable() { + let mode = resolvePubkyApprovalLocalAuthMode( + isPinEnabled: false, + isBiometricEnabled: true, + isBiometrySupported: false + ) + + XCTAssertEqual(mode, .none) + } +} 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/PubkyImageCacheTests.swift b/BitkitTests/PubkyImageCacheTests.swift new file mode 100644 index 000000000..6b3e1d398 --- /dev/null +++ b/BitkitTests/PubkyImageCacheTests.swift @@ -0,0 +1,48 @@ +@testable import Bitkit +import CryptoKit +import UIKit +import XCTest + +final class PubkyImageCacheTests: XCTestCase { + func testClearRemovesCachedImageFromMemoryAndDisk() async throws { + let cache = PubkyImageCache.shared + let uri = "pubky://test-user/pub/bitkit.to/blobs/avatar.jpg" + let image = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { context in + context.cgContext.setFillColor(UIColor.red.cgColor) + context.cgContext.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + let imageData = try XCTUnwrap(image.pngData()) + let diskPath = pubkyImageDiskPath(for: uri) + + await cache.clear() + cache.store(image, data: imageData, for: uri) + + XCTAssertNotNil(cache.memoryImage(for: uri)) + + let fileStored = await waitForFile(at: diskPath) + XCTAssertTrue(fileStored) + + await cache.clear() + + XCTAssertNil(cache.memoryImage(for: uri)) + XCTAssertFalse(FileManager.default.fileExists(atPath: diskPath.path)) + let diskImage = await cache.image(for: uri) + XCTAssertNil(diskImage) + } + + private func pubkyImageDiskPath(for uri: String) -> URL { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + let hash = SHA256.hash(data: Data(uri.utf8)).compactMap { String(format: "%02x", $0) }.joined() + return caches.appendingPathComponent("pubky-images", isDirectory: true).appendingPathComponent(hash) + } + + private func waitForFile(at path: URL, attempts: Int = 10) async -> Bool { + for _ in 0 ..< attempts { + if FileManager.default.fileExists(atPath: path.path) { + return true + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return false + } +} diff --git a/BitkitTests/PubkyModelTests.swift b/BitkitTests/PubkyModelTests.swift new file mode 100644 index 000000000..8fffeb2e5 --- /dev/null +++ b/BitkitTests/PubkyModelTests.swift @@ -0,0 +1,298 @@ +@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 testProfileDataDecodesWithoutLinks() throws { + let json = """ + {"name":"Satoshi","bio":"","image":null,"tags":["bitcoin"]} + """ + let data = try PubkyProfileData.decode(from: json) + + XCTAssertEqual(data.name, "Satoshi") + XCTAssertTrue(data.links.isEmpty) + XCTAssertEqual(data.tags, ["bitcoin"]) + } + + func testProfileDataDecodesWithMissingFieldsUsingDefaults() throws { + let json = """ + {} + """ + let data = try PubkyProfileData.decode(from: json) + + XCTAssertEqual(data.name, "") + XCTAssertEqual(data.bio, "") + XCTAssertNil(data.image) + XCTAssertTrue(data.links.isEmpty) + XCTAssertEqual(data.tags, []) + } + + func testProfileDataDecodesLinksWithMissingFieldsUsingDefaults() throws { + let json = """ + {"links":[{"label":"Website"},{"url":"https://example.com"},{}]} + """ + let data = try PubkyProfileData.decode(from: json) + + XCTAssertEqual(data.links.count, 3) + XCTAssertEqual(data.links[0].label, "Website") + XCTAssertEqual(data.links[0].url, "") + XCTAssertEqual(data.links[1].label, "") + XCTAssertEqual(data.links[1].url, "https://example.com") + XCTAssertEqual(data.links[2].label, "") + XCTAssertEqual(data.links[2].url, "") + } + + 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..55ecce146 --- /dev/null +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -0,0 +1,147 @@ +@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: - Image Resolution + + func testResolvedImageUrlPrefersNewImage() { + let resolved = PubkyProfileManager.resolvedImageUrl( + newImageUrl: "pubky://new-avatar", + existingImageUrl: "pubky://existing-avatar" + ) + + XCTAssertEqual(resolved, "pubky://new-avatar") + } + + func testResolvedImageUrlFallsBackToExistingImage() { + let resolved = PubkyProfileManager.resolvedImageUrl( + newImageUrl: nil, + existingImageUrl: "pubky://existing-avatar" + ) + + XCTAssertEqual(resolved, "pubky://existing-avatar") + } + + func testResolvedImageUrlAllowsMissingAvatar() { + let resolved = PubkyProfileManager.resolvedImageUrl( + newImageUrl: nil, + existingImageUrl: nil + ) + + XCTAssertNil(resolved) + } + + // MARK: - Remote Profile Resolution + + func testResolveRemoteProfilePrefersBitkitProfile() async throws { + let bitkitProfile = makeProfile(publicKey: "pubky_test", name: "Bitkit") + let pubkyFallback = makeProfile(publicKey: "pubky_test", name: "Pubky") + + let resolved = try await PubkyProfileManager.resolveRemoteProfile( + publicKey: "pubky_test", + fetchBitkitProfile: { _ in bitkitProfile }, + fetchPubkyProfile: { _ in + XCTFail("Expected bitkit profile to win before pubky fallback") + return pubkyFallback + } + ) + + XCTAssertEqual(resolved.name, "Bitkit") + } + + func testResolveRemoteProfileFallsBackToPubkyProfile() async throws { + let fallbackProfile = makeProfile(publicKey: "pubky_test", name: "Pubky") + + let resolved = try await PubkyProfileManager.resolveRemoteProfile( + publicKey: "pubky_test", + fetchBitkitProfile: { _ in nil }, + fetchPubkyProfile: { _ in fallbackProfile } + ) + + XCTAssertEqual(resolved.name, "Pubky") + } + + func testResolveRemoteProfileThrowsWhenNoRemoteProfileExists() async { + await XCTAssertThrowsErrorAsync { + try await PubkyProfileManager.resolveRemoteProfile( + publicKey: "pubky_missing", + fetchBitkitProfile: { _ in nil }, + fetchPubkyProfile: { _ in throw PubkyServiceError.profileNotFound } + ) + } + } + + // 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) + } + + private func makeProfile(publicKey: String, name: String) -> PubkyProfile { + PubkyProfile( + publicKey: publicKey, + name: name, + bio: "bio", + imageUrl: nil, + links: [], + tags: [], + status: nil + ) + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> some Any, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch {} +} diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0291423..c071de730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Pubky profile onboarding with contact sync, import, and editing #476 - Add transfer from savings button on empty spending wallet when user has on-chain balance #523 ### Changed