Files
damus/damus/Views/LoginView.swift
Daniel D’Aquino 4703ed80a7 Damus Purple initial Proof-of-Concept support
This commit includes various code changes necessary to get a basic proof of concept of the feature working.

This is NOT a full working feature yet, only a preliminary prototype/PoC. It includes:
- [X] Basic Storekit configuration
- [X] Basic purchase mechanism
- [X] Basic layout and copywriting
- [X] Basic design
- [X] Manage button (To help user cancel their subscription)
- [X] Thank you confirmation + special welcome view
- [X] Star badge on profile (by checking the Damus Purple API)
- [X] Connection to Damus purple API for fetching account info, registering for an account and sending over the App Store receipt data

The feature sits behind a feature flag which is OFF by default (it can be turned ON via Settings --> Developer settings --> Enable experimental Purple API and restarting the app)

Testing
---------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
damus-api: 59ce44a92cff1c1aaed9886f9befbd5f1053821d
Server: Ubuntu 22.04 (VM)
Setup:
1. On the server, delete the `mdb` database files to start from scratch
2. In iOS, reinstall the app if necessary to make sure there are no in-app purchases
3. Enable subscriptions support via developer settings with localhost test mode and restart app
4. Start server with mock parameters (Run `npm run dev`)

Steps:
1. Open top bar and click on "Purple"
2. Purple screen should appear and show both benefits and the purchase options. PASS
3. Click on "monthly". An Apple screen to confirm purchase should appear. PASS
4. Welcome screen with animation should appear. PASS
5. Click continue and restart app (Due to known issue tracked at https://github.com/damus-io/damus/issues/1814)
6. Post something
7. Gold star should appear beside your name
8. Look at the server logs. There should be some requests to create the account (POST), to send the receipt (POST), and to get account status
9. Go to purple view. There should be some information about the subscription, as well as a "manage" button. PASS
10. Click on "manage" button. An iOS sheet should appear allow the user to unsubscribe or manage their subscription to Damus Purple.

Feature flag testing
--------------------

PASS

Preconditions: Continue from above test
Steps:
1. Disable Damus Purple experiment support on developer settings. Restart the app.
2. Check your post. There should be no star beside your profile name. PASS
3. Check side menu. There should be no "Damus Purple" option. PASS
4. Check server logs. There should be no new requests being done to the server. PASS

Closes: https://github.com/damus-io/damus/issues/1422
2023-12-24 09:30:26 -08:00

450 lines
13 KiB
Swift

//
// LoginView.swift
// damus
//
// Created by William Casarin on 2022-05-22.
//
import SwiftUI
enum ParsedKey {
case pub(Pubkey)
case priv(Privkey)
case hex(String)
case nip05(String)
var is_pub: Bool {
if case .pub = self {
return true
}
if case .nip05 = self {
return true
}
return false
}
var is_hex: Bool {
if case .hex = self {
return true
}
return false
}
var is_priv: Bool {
if case .priv = self {
return true
}
return false
}
}
struct LoginView: View {
@State var key: String = ""
@State var is_pubkey: Bool = false
@State var error: String? = nil
@State private var credential_handler = CredentialHandler()
@State private var shouldSaveKey: Bool = true
var nav: NavigationCoordinator
func get_error(parsed_key: ParsedKey?) -> String? {
if self.error != nil {
return self.error
}
if !key.isEmpty && parsed_key == nil {
return LoginError.invalid_key.errorDescription
}
return nil
}
var body: some View {
ZStack(alignment: .top) {
VStack {
SignInHeader()
.padding(.top, 100)
SignInEntry(key: $key, shouldSaveKey: $shouldSaveKey)
let parsed = parse_key(key)
if parsed?.is_hex ?? false {
// convert to bech32 here
}
if let error = get_error(parsed_key: parsed) {
Text(error)
.foregroundColor(.red)
.padding()
}
if parsed?.is_pub ?? false {
Text("This is a public key, you will not be able to make notes or interact in any way. This is used for viewing accounts from their perspective.", comment: "Warning that the inputted account key is a public key and the result of what happens because of it.")
.foregroundColor(Color.orange)
.bold()
.fixedSize(horizontal: false, vertical: true)
}
if let p = parsed {
Button(action: {
Task {
do {
try await process_login(p, is_pubkey: is_pubkey, shouldSaveKey: shouldSaveKey)
} catch {
self.error = error.localizedDescription
}
}
}) {
HStack {
Text("Login", comment: "Button to log into account.")
.fontWeight(.semibold)
}
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
}
.buttonStyle(GradientButtonStyle())
.padding(.top, 10)
}
CreateAccountPrompt(nav: nav)
.padding(.top, 10)
Spacer()
}
.padding()
}
.background(DamusBackground(maxHeight: 350), alignment: .top)
.onAppear {
credential_handler.check_credentials()
}
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: BackNav())
}
}
extension View {
func nsecLoginStyle(key: String, title: String) -> some View {
self
.placeholder(when: key.isEmpty) {
Text(title).foregroundColor(.white.opacity(0.6))
}
.padding(10)
.autocapitalization(.none)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.font(.body.monospaced())
.textContentType(.password)
}
}
func parse_key(_ thekey: String) -> ParsedKey? {
var key = thekey
if key.count > 0 && key.first! == "@" {
key = String(key.dropFirst())
}
if hex_decode(key) != nil {
return .hex(key)
}
if (key.contains { $0 == "@" }) {
return .nip05(key)
}
if let bech_key = decode_bech32_key(key) {
switch bech_key {
case .pub(let pk): return .pub(pk)
case .sec(let sec): return .priv(sec)
}
}
return nil
}
enum LoginError: LocalizedError {
case invalid_key
case nip05_failed
var errorDescription: String? {
switch self {
case .invalid_key:
return NSLocalizedString("Invalid key", comment: "Error message indicating that an invalid account key was entered for login.")
case .nip05_failed:
return "Could not fetch pubkey"
}
}
}
func process_login(_ key: ParsedKey, is_pubkey: Bool, shouldSaveKey: Bool = true) async throws {
if shouldSaveKey {
switch key {
case .priv(let priv):
try handle_privkey(priv)
case .pub(let pub):
try clear_saved_privkey()
save_pubkey(pubkey: pub)
case .nip05(let id):
guard let nip05 = await get_nip05_pubkey(id: id) else {
throw LoginError.nip05_failed
}
// this is a weird way to login anyways
/*
var bootstrap_relays = load_bootstrap_relays(pubkey: nip05.pubkey)
for relay in nip05.relays {
if !(bootstrap_relays.contains { $0 == relay }) {
bootstrap_relays.append(relay)
}
}
*/
save_pubkey(pubkey: nip05.pubkey)
case .hex(let hexstr):
if is_pubkey, let pubkey = hex_decode_pubkey(hexstr) {
try clear_saved_privkey()
save_pubkey(pubkey: pubkey)
} else if let privkey = hex_decode_privkey(hexstr) {
try handle_privkey(privkey)
}
}
}
func handle_privkey(_ privkey: Privkey) throws {
try save_privkey(privkey: privkey)
guard let pk = privkey_to_pubkey(privkey: privkey) else {
throw LoginError.invalid_key
}
CredentialHandler().save_credential(pubkey: pk, privkey: privkey)
save_pubkey(pubkey: pk)
}
func handle_transient_privkey(_ key: ParsedKey) -> Keypair? {
if case let .priv(priv) = key, let pubkey = privkey_to_pubkey(privkey: priv) {
return Keypair(pubkey: pubkey, privkey: priv)
}
return nil
}
let keypair = shouldSaveKey ? get_saved_keypair() : handle_transient_privkey(key)
guard let keypair = keypair else {
return
}
await MainActor.run {
notify(.login(keypair))
}
}
struct NIP05Result: Decodable {
let names: Dictionary<String, String>
let relays: Dictionary<String, [String]>?
}
struct NIP05User {
let pubkey: Pubkey
//let relays: [String]
}
func get_nip05_pubkey(id: String) async -> NIP05User? {
let parts = id.components(separatedBy: "@")
guard parts.count == 2 else {
return nil
}
let user = parts[0]
let host = parts[1]
guard let url = URL(string: "https://\(host)/.well-known/nostr.json?name=\(user)"),
let (data, _) = try? await URLSession.shared.data(for: URLRequest(url: url)),
let json: NIP05Result = decode_data(data),
let pubkey_hex = json.names[user],
let pubkey = hex_decode_pubkey(pubkey_hex)
else {
return nil
}
/*
var relays: [String] = []
if let rs = json.relays, let rs = rs[pubkey] {
relays = rs
}
*/
return NIP05User(pubkey: pubkey/*, relays: relays*/)
}
struct KeyInput: View {
let title: String
let key: Binding<String>
let shouldSaveKey: Binding<Bool>
var privKeyFound: Binding<Bool>
@State private var is_secured: Bool = true
init(_ title: String, key: Binding<String>, shouldSaveKey: Binding<Bool>, privKeyFound: Binding<Bool>) {
self.title = title
self.key = key
self.shouldSaveKey = shouldSaveKey
self.privKeyFound = privKeyFound
}
var body: some View {
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.gray)
.onTapGesture {
if let pastedkey = UIPasteboard.general.string {
self.key.wrappedValue = pastedkey
}
}
SignInScan(shouldSaveKey: shouldSaveKey, loginKey: key, privKeyFound: privKeyFound)
if is_secured {
SecureField("", text: key)
.nsecLoginStyle(key: key.wrappedValue, title: title)
} else {
TextField("", text: key)
.nsecLoginStyle(key: key.wrappedValue, title: title)
}
Image(systemName: "eye.slash")
.foregroundColor(.gray)
.onTapGesture {
is_secured.toggle()
}
}
.padding(.horizontal, 10)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(.gray, lineWidth: 1)
}
}
}
struct SignInHeader: View {
var body: some View {
VStack {
Image("logo-nobg")
.resizable()
.frame(width: 56, height: 56, alignment: .center)
.shadow(color: DamusColors.purple, radius: 2)
.padding(.bottom)
Text("Sign in", comment: "Title of view to log into an account.")
.font(.system(size: 32, weight: .bold))
.padding(.bottom, 5)
Text("Welcome to the social network you control", comment: "Welcome text")
.foregroundColor(Color("DamusMediumGrey"))
}
}
}
struct SignInEntry: View {
let key: Binding<String>
let shouldSaveKey: Binding<Bool>
@State private var privKeyFound: Bool = false
var body: some View {
VStack(alignment: .leading) {
Text("Enter your account key", comment: "Prompt for user to enter an account key to login.")
.fontWeight(.medium)
.padding(.top, 30)
KeyInput(NSLocalizedString("nsec1...", comment: "Prompt for user to enter in an account key to login. This text shows the characters the key could start with if it was a private key."),
key: key,
shouldSaveKey: shouldSaveKey,
privKeyFound: $privKeyFound)
if privKeyFound {
Toggle(NSLocalizedString("Save Key in Secure Keychain", comment: "Toggle to save private key to the Apple secure keychain."), isOn: shouldSaveKey)
}
}
}
}
struct SignInScan: View {
@State var showQR: Bool = false
@State var qrkey: ParsedKey?
@Binding var shouldSaveKey: Bool
@Binding var loginKey: String
@Binding var privKeyFound: Bool
let generator = UINotificationFeedbackGenerator()
var body: some View {
VStack {
Button(action: { showQR.toggle() }, label: {
Image(systemName: "qrcode.viewfinder")})
.foregroundColor(.gray)
}
.sheet(isPresented: $showQR, onDismiss: {
if qrkey == nil { resetView() }}
) {
QRScanNSECView(showQR: $showQR,
privKeyFound: $privKeyFound,
codeScannerCompletion: { scannerCompletion($0) })
}
.onChange(of: showQR) { show in
if showQR { resetView() }
}
}
func handleQRString(_ string: String) {
qrkey = parse_key(string)
if let key = qrkey, key.is_priv {
loginKey = string
privKeyFound = true
shouldSaveKey = false
generator.notificationOccurred(.success)
}
}
func scannerCompletion(_ result: Result<ScanResult, ScanError>) {
switch result {
case .success(let success):
handleQRString(success.string)
case .failure:
return
}
}
func resetView() {
loginKey = ""
qrkey = nil
privKeyFound = false
shouldSaveKey = true
}
}
struct CreateAccountPrompt: View {
var nav: NavigationCoordinator
var body: some View {
HStack {
Text("New to Nostr?", comment: "Ask the user if they are new to Nostr")
.foregroundColor(Color("DamusMediumGrey"))
Button(NSLocalizedString("Create account", comment: "Button to navigate to create account view.")) {
nav.push(route: Route.CreateAccount)
}
Spacer()
}
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
// let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"
let pubkey = "npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955"
let bech32_pubkey = "KeyInput"
Group {
LoginView(key: pubkey, nav: .init())
LoginView(key: bech32_pubkey, nav: .init())
}
}
}