diff --git a/CHANGELOG.md b/CHANGELOG.md index a72756e37..0bf3db7f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the Private Key onboarding screen. Currently behind the “New Onboarding Flow” feature flag. [#1595](https://github.com/planetary-social/nos/issues/1595) - Added the Public Key onboarding screen. Currently behind the “New Onboarding Flow” feature flag. [#1596](https://github.com/planetary-social/nos/issues/1596) - Added the Display Name onboarding screen. Currently behind the “New Onboarding Flow” feature flag. [#1597](https://github.com/planetary-social/nos/issues/1597) +- Added the Username onboarding screen. Currently behind the “New Onboarding Flow” feature flag. [#1598](https://github.com/planetary-social/nos/issues/1598) ## [0.2.2] - 2024-10-11Z diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 8654ffd2c..5012074a4 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 030E56F32CC2836D00A4A51E /* CopyKeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E56F22CC2836D00A4A51E /* CopyKeyView.swift */; }; 030E570D2CC2A05B00A4A51E /* DisplayNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E570C2CC2A05B00A4A51E /* DisplayNameView.swift */; }; 030E571B2CC2ADDB00A4A51E /* SaveProfileError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E571A2CC2ADDB00A4A51E /* SaveProfileError.swift */; }; + 030E57292CC2B0D100A4A51E /* UsernameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030E57282CC2B0D100A4A51E /* UsernameView.swift */; }; 030FECAB2CB5E0B900820014 /* BuildYourNetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030FECAA2CB5E0B900820014 /* BuildYourNetworkView.swift */; }; 0314CF742C9C7DD00001A53B /* youTube_fortnight_short.html in Resources */ = {isa = PBXBuildFile; fileRef = 0314CF732C9C7DD00001A53B /* youTube_fortnight_short.html */; }; 0314D5AC2C7D31060002E7F4 /* MediaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0314D5AB2C7D31060002E7F4 /* MediaService.swift */; }; @@ -599,6 +600,7 @@ 030E56F22CC2836D00A4A51E /* CopyKeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyKeyView.swift; sourceTree = ""; }; 030E570C2CC2A05B00A4A51E /* DisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayNameView.swift; sourceTree = ""; }; 030E571A2CC2ADDB00A4A51E /* SaveProfileError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveProfileError.swift; sourceTree = ""; }; + 030E57282CC2B0D100A4A51E /* UsernameView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UsernameView.swift; sourceTree = ""; }; 030FECAA2CB5E0B900820014 /* BuildYourNetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildYourNetworkView.swift; sourceTree = ""; }; 0314CF732C9C7DD00001A53B /* youTube_fortnight_short.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = youTube_fortnight_short.html; sourceTree = ""; }; 0314D5AB2C7D31060002E7F4 /* MediaService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaService.swift; sourceTree = ""; }; @@ -1490,6 +1492,7 @@ 3FB5E650299D28A200386527 /* OnboardingView.swift */, 038EF09C2CC16D640031F7F2 /* PrivateKeyView.swift */, 030E56C92CC1BC6200A4A51E /* PublicKeyView.swift */, + 030E57282CC2B0D100A4A51E /* UsernameView.swift */, 030E56E52CC2835A00A4A51E /* Components */, ); path = Onboarding; @@ -2334,6 +2337,7 @@ 03B4E6AE2C125D61006E5F59 /* FileStorageUploadResponseJSON.swift in Sources */, 5B79F6112B98AD0A002DA9BE /* ExcellentChoiceSheet.swift in Sources */, C973AB612A323167002AED16 /* Author+CoreDataProperties.swift in Sources */, + 030E57292CC2B0D100A4A51E /* UsernameView.swift in Sources */, C9B678E129EEC41000303F33 /* SocialGraphCache.swift in Sources */, 034EBDBA2C24895E006BA35A /* CurrentUserError.swift in Sources */, C93F488D2AC5C30C00900CEC /* NosFormField.swift in Sources */, diff --git a/Nos/Assets/Colors.xcassets/error.colorset/Contents.json b/Nos/Assets/Colors.xcassets/error.colorset/Contents.json new file mode 100644 index 000000000..0dff04e96 --- /dev/null +++ b/Nos/Assets/Colors.xcassets/error.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5D", + "green" : "0x19", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Nos/Assets/Localization/Localizable.xcstrings b/Nos/Assets/Localization/Localizable.xcstrings index 053e18db2..1899b1dd2 100644 --- a/Nos/Assets/Localization/Localizable.xcstrings +++ b/Nos/Assets/Localization/Localizable.xcstrings @@ -5236,18 +5236,6 @@ } } }, - "displayNameError" : { - "comment" : "error message for when we can't set the display name in onboarding", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "There is a problem connecting to the servers. You can skip for now and visit your Profile to update later." - } - } - } - }, "displayNameHeadline" : { "comment" : "headline for the display name screen in onboarding", "extractionState" : "manual", @@ -5753,6 +5741,18 @@ } } }, + "errorConnecting" : { + "comment" : "error message for when we can't set the display name or username in onboarding", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There is a problem connecting to the servers. You can skip for now and visit your Profile to update later." + } + } + } + }, "eventSource" : { "extractionState" : "manual", "localizations" : { @@ -19106,6 +19106,18 @@ } } }, + "usernameDescription" : { + "comment" : "a description for the username screen in onboarding", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is an easy to share alternative to your public key and allows people to search for you on Nos." + } + } + } + }, "usernameHeadline" : { "comment" : "headline for the username screen in onboarding", "extractionState" : "manual", @@ -19118,6 +19130,42 @@ } } }, + "usernameNIP05Parenthetical" : { + "comment" : "the word \"NIP-05\" in parentheses in English. \"NIP-05\" should not be translated, but the parentheses may", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "(NIP-05)" + } + } + } + }, + "usernameNotAvailable" : { + "comment" : "error text to display when the username is unavailable", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This username is not available." + } + } + } + }, + "usernamePlaceholder" : { + "comment" : "placeholder text for the username field in onboarding", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "username" + } + } + } + }, "usernameWarningMessage" : { "extractionState" : "manual", "localizations" : { diff --git a/Nos/Views/Onboarding/DisplayNameView.swift b/Nos/Views/Onboarding/DisplayNameView.swift index 62c95f95a..8e680919f 100644 --- a/Nos/Views/Onboarding/DisplayNameView.swift +++ b/Nos/Views/Onboarding/DisplayNameView.swift @@ -25,7 +25,7 @@ struct DisplayNameView: View { } } .navigationBarHidden(true) - .alert("displayNameError", isPresented: $showError) { + .alert("errorConnecting", isPresented: $showError) { Button { nextStep() } label: { @@ -49,7 +49,7 @@ struct DisplayNameView: View { prompt: Text("displayNamePlaceholder") .foregroundStyle(Color.textFieldPlaceholder) ) - .textInputAutocapitalization(.none) + .textInputAutocapitalization(.never) .foregroundStyle(Color.primaryTxt) .fontWeight(.bold) .autocorrectionDisabled() @@ -65,7 +65,7 @@ struct DisplayNameView: View { } func nextStep() { - state.step = .buildYourNetwork + state.step = .username } /// Saves the display name locally and publishes the event to relays. Sets `showError` if it fails. diff --git a/Nos/Views/Onboarding/OnboardingView.swift b/Nos/Views/Onboarding/OnboardingView.swift index 0030dbd0e..5b795fe54 100644 --- a/Nos/Views/Onboarding/OnboardingView.swift +++ b/Nos/Views/Onboarding/OnboardingView.swift @@ -23,6 +23,7 @@ enum OnboardingStep { case privateKey case publicKey case displayName + case username case buildYourNetwork case login } @@ -61,6 +62,9 @@ struct OnboardingView: View { case .displayName: DisplayNameView() .environment(state) + case .username: + UsernameView() + .environment(state) case .login: OnboardingLoginView(completion: completion) case .buildYourNetwork: diff --git a/Nos/Views/Onboarding/UsernameView.swift b/Nos/Views/Onboarding/UsernameView.swift new file mode 100644 index 000000000..e22b61996 --- /dev/null +++ b/Nos/Views/Onboarding/UsernameView.swift @@ -0,0 +1,185 @@ +import Dependencies +import Logger +import SwiftUI + +/// The possible states of ``UsernameView``. +fileprivate enum UsernameViewState { + case idle + case loading + case verificationFailed + case claimed + case errorAlert +} + +/// The Username view in the onboarding. +struct UsernameView: View { + @Environment(OnboardingState.self) private var state + @Environment(CurrentUser.self) private var currentUser + @Environment(\.managedObjectContext) private var viewContext + + @Dependency(\.crashReporting) private var crashReporting + @Dependency(\.namesAPI) private var namesAPI + + @State private var username = "" + @State private var usernameState: UsernameViewState = .idle + @State private var saveError: SaveProfileError? + + private var showAlert: Binding { + Binding { + usernameState == .errorAlert + } set: { _ in + usernameState = .idle + } + } + + private var nextButtonDisabled: Bool { + if username.isEmpty { + return true + } else if usernameState == .loading { + return true + } else if usernameState == .claimed { + return true + } else { + return false + } + } + + var body: some View { + ZStack { + Color.appBg + .ignoresSafeArea() + ViewThatFits(in: .vertical) { + displayNameStack + + ScrollView { + displayNameStack + } + } + } + .navigationBarHidden(true) + .alert("errorConnecting", isPresented: showAlert) { + Button { + nextStep() + } label: { + Text("skipForNow") + } + } + } + + var displayNameStack: some View { + VStack(alignment: .leading, spacing: 20) { + LargeNumberView(4) + HStack(alignment: .firstTextBaseline) { + Text("usernameHeadline") + .font(.clarityBold(.title)) + .foregroundStyle(Color.primaryTxt) + Text("usernameNIP05Parenthetical") + .font(.clarityRegular(.title2)) + .foregroundStyle(Color.secondaryTxt) + } + Text("usernameDescription") + .font(.body) + .foregroundStyle(Color.secondaryTxt) + HStack { + TextField( + "", + text: $username, + prompt: Text("usernamePlaceholder") + .foregroundStyle(Color.textFieldPlaceholder) + ) + .textInputAutocapitalization(.never) + .foregroundStyle(Color.primaryTxt) + .fontWeight(.bold) + .autocorrectionDisabled() + .padding() + .withStyledBorder() + + Text("@nos.social") + .fontWeight(.bold) + .foregroundStyle(Color.secondaryTxt) + } + if usernameState == .verificationFailed { + usernameAlreadyClaimedText + } + Spacer() + BigActionButton("next") { + await verifyAndSave() + } + .disabled(nextButtonDisabled) + } + .padding(40) + .readabilityPadding() + } + + var usernameAlreadyClaimedText: some View { + Text("usernameNotAvailable") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Color.error) + } + + func nextStep() { + state.step = .buildYourNetwork + } + + /// Checks whether the username is available and saves it. Updates `usernameState` based on the result. + func verifyAndSave() async { + usernameState = .loading + + guard !username.isEmpty, let keyPair = currentUser.keyPair else { + usernameState = .errorAlert + return + } + + do { + let verified = try await namesAPI.checkAvailability( + username: username, + publicKey: keyPair.publicKey + ) + guard verified else { + usernameState = .verificationFailed + return + } + await save() + } catch { + Log.error(error.localizedDescription) + usernameState = .verificationFailed + } + } + + /// Saves the username locally, publishes the metadata, and registers it. + func save() async { + usernameState = .loading + + guard let author = await currentUser.author, + let keyPair = currentUser.keyPair else { + usernameState = .errorAlert + return + } + + author.nip05 = "\(username)@nos.social" + do { + try viewContext.save() + try await currentUser.publishMetadata() + let relays = author.relays.compactMap { + $0.addressURL + } + try await namesAPI.register( + username: username, + keyPair: keyPair, + relays: relays + ) + usernameState = .claimed + nextStep() + } catch { + crashReporting.report(error) + usernameState = .errorAlert + } + } +} + +#Preview { + UsernameView() + .environment(OnboardingState()) + .inject(previewData: PreviewData()) +}