From 7b7e3624acef4f502eb82116098e3fd05f972d6d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 Jan 2024 16:23:17 -0500 Subject: [PATCH] New Signup screen --- .gitignore | 1 + .../amethyst/LocalPreferences.kt | 9 +- .../vitorpamplona/amethyst/model/Account.kt | 33 +- .../ui/actions/NewUserMetadataViewModel.kt | 69 +-- .../ui/navigation/AccountSwitchBottomSheet.kt | 4 +- .../amethyst/ui/screen/AccountScreen.kt | 4 +- .../ui/screen/AccountStateViewModel.kt | 53 +- .../screen/loggedOff/LoginOrSignupScreen.kt | 51 ++ .../ui/screen/loggedOff/LoginScreen.kt | 499 +++++++++--------- .../ui/screen/loggedOff/SignUpScreen.kt | 299 +++++++++++ app/src/main/res/values/strings.xml | 11 +- .../quartz/events/MetadataEvent.kt | 109 +++- 12 files changed, 798 insertions(+), 344 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt diff --git a/.gitignore b/.gitignore index 4c295cc9b..c39ee8c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /.idea/deploymentTargetDropDown.xml /.idea/appInsightsSettings.xml /.idea/ktlint-plugin.xml +/.idea/ktfmt.xml .DS_Store /build /captures diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 60275baff..9edba1452 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -130,8 +130,11 @@ object LocalPreferences { return currentAccount } - private suspend fun updateCurrentAccount(npub: String) { - if (currentAccount != npub) { + private fun updateCurrentAccount(npub: String?) { + if (npub == null) { + currentAccount = null + encryptedPreferences().edit().clear().apply() + } else if (currentAccount != npub) { currentAccount = npub encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() @@ -250,7 +253,7 @@ object LocalPreferences { deleteUserPreferenceFile(accountInfo.npub) if (savedAccounts().isEmpty()) { - encryptedPreferences().edit().clear().apply() + updateCurrentAccount(null) } else if (currentAccount() == accountInfo.npub) { updateCurrentAccount(savedAccounts().elementAt(0).npub) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 279e488d5..df5224f7d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -66,7 +66,6 @@ import com.vitorpamplona.quartz.events.GeneralListEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent -import com.vitorpamplona.quartz.events.IdentityClaim import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent @@ -541,14 +540,36 @@ class Account( } } - suspend fun sendNewUserMetadata( - toString: String, - newName: String, - identities: List, + fun sendNewUserMetadata( + name: String? = null, + picture: String? = null, + banner: String? = null, + website: String? = null, + about: String? = null, + nip05: String? = null, + lnAddress: String? = null, + lnURL: String? = null, + twitter: String? = null, + mastodon: String? = null, + github: String? = null, ) { if (!isWriteable()) return - MetadataEvent.create(toString, newName, identities, signer) { + MetadataEvent.updateFromPast( + latest = userProfile().info?.latestMetadata, + name = name, + picture = picture, + banner = banner, + website = website, + about = about, + nip05 = nip05, + lnAddress = lnAddress, + lnURL = lnURL, + twitter = twitter, + mastodon = mastodon, + github = github, + signer = signer, + ) { Client.send(it) LocalCache.justConsume(it, null) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 92e4dfd75..1824dbbc5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -27,8 +27,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.ui.components.MediaCompressor @@ -39,8 +37,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import java.io.ByteArrayInputStream -import java.io.StringWriter class NewUserMetadataViewModel : ViewModel() { private lateinit var account: Account @@ -97,59 +93,22 @@ class NewUserMetadataViewModel : ViewModel() { fun create() { // Tries to not delete any existing attribute that we do not work with. - val latest = account.userProfile().info?.latestMetadata - val currentJson = - if (latest != null) { - ObjectMapper() - .readTree( - ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)), - ) as ObjectNode - } else { - ObjectMapper().createObjectNode() - } - currentJson.put("name", displayName.value.trim()) - currentJson.put("display_name", displayName.value.trim()) - currentJson.put("picture", picture.value.trim()) - currentJson.put("banner", banner.value.trim()) - currentJson.put("website", website.value.trim()) - currentJson.put("about", about.value.trim()) - currentJson.put("nip05", nip05.value.trim()) - currentJson.put("lud16", lnAddress.value.trim()) - currentJson.put("lud06", lnURL.value.trim()) - - var claims = latest?.identityClaims() ?: emptyList() - - if (twitter.value.isBlank()) { - // delete twitter - claims = claims.filter { it !is TwitterIdentity } - } - - if (github.value.isBlank()) { - // delete github - claims = claims.filter { it !is GitHubIdentity } - } - - if (mastodon.value.isBlank()) { - // delete mastodon - claims = claims.filter { it !is MastodonIdentity } - } - - // Updates while keeping other identities intact - val newClaims = - listOfNotNull( - TwitterIdentity.parseProofUrl(twitter.value), - GitHubIdentity.parseProofUrl(github.value), - MastodonIdentity.parseProofUrl(mastodon.value), - ) + - claims.filter { it !is TwitterIdentity && it !is GitHubIdentity && it !is MastodonIdentity } - - val writer = StringWriter() - ObjectMapper().writeValue(writer, currentJson) - viewModelScope.launch(Dispatchers.IO) { - account.sendNewUserMetadata(writer.buffer.toString(), displayName.value.trim(), newClaims) + account.sendNewUserMetadata( + name = displayName.value, + picture = picture.value, + banner = banner.value, + website = website.value, + about = about.value, + nip05 = nip05.value, + lnAddress = lnAddress.value, + lnURL = lnURL.value, + twitter = twitter.value, + mastodon = mastodon.value, + github = github.value, + ) + clear() } - clear() } fun clear() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 402baa618..61b3bd706 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -73,7 +73,7 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage +import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size55dp @@ -121,7 +121,7 @@ fun AccountSwitchBottomSheet( ) { Surface(modifier = Modifier.fillMaxSize()) { Box { - LoginPage(accountStateViewModel, isFirstLogin = false) + LoginOrSignupScreen(accountStateViewModel, isFirstLogin = false) TopAppBar( title = { Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 683fd4f86..b0ec68679 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -50,7 +50,7 @@ import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen -import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage +import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.quartz.signers.NostrSignerExternal import kotlinx.coroutines.launch @@ -71,7 +71,7 @@ fun AccountScreen( LoadingAccounts() } is AccountState.LoggedOff -> { - LoginPage(accountStateViewModel, isFirstLogin = true) + LoginOrSignupScreen(accountStateViewModel, isFirstLogin = true) } is AccountState.LoggedIn -> { CompositionLocalProvider( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 19194f211..fae3317c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Nip19 @@ -42,6 +43,7 @@ import com.vitorpamplona.quartz.signers.NostrSignerInternal import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -65,8 +67,7 @@ class AccountStateViewModel() : ViewModel() { private suspend fun tryLoginExistingAccount() = withContext(Dispatchers.IO) { - LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } - ?: run { requestLoginUI() } + LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } ?: run { requestLoginUI() } } private suspend fun requestLoginUI() { @@ -144,25 +145,33 @@ class AccountStateViewModel() : ViewModel() { startUI(account) } - suspend fun startUI(account: Account) = - withContext(Dispatchers.Main) { - if (account.isWriteable()) { - _accountContent.update { AccountState.LoggedIn(account) } - } else { - _accountContent.update { AccountState.LoggedInViewOnly(account) } - } - - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - // Prepares livedata objects on the main user. - account.userProfile().live() - } - serviceManager?.restartIfDifferentAccount(account) - } - - account.saveable.observeForever(saveListener) + suspend fun startUI( + account: Account, + onServicesReady: (() -> Unit)? = null, + ) = withContext(Dispatchers.Main) { + if (account.isWriteable()) { + _accountContent.update { AccountState.LoggedIn(account) } + } else { + _accountContent.update { AccountState.LoggedInViewOnly(account) } } + viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + // Prepares livedata objects on the main user. + account.userProfile().live() + } + serviceManager?.restartIfDifferentAccount(account) + + if (onServicesReady != null) { + // waits for the connection to go through + delay(1000) + onServicesReady() + } + } + + account.saveable.observeForever(saveListener) + } + @OptIn(DelicateCoroutinesApi::class) private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) } @@ -204,6 +213,7 @@ class AccountStateViewModel() : ViewModel() { fun newKey( useProxy: Boolean, proxyPort: Int, + name: String? = null, ) { viewModelScope.launch(Dispatchers.IO) { val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) @@ -220,7 +230,10 @@ class AccountStateViewModel() : ViewModel() { // saves to local preferences LocalPreferences.updatePrefsForLogin(account) - startUI(account) + startUI(account) { + account.userProfile().latestContactList?.let { Client.send(it) } + account.sendNewUserMetadata(name = name) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt new file mode 100644 index 000000000..358d42c1d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff + +import androidx.compose.animation.Crossfade +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel + +@Composable +fun LoginOrSignupScreen( + accountViewModel: AccountStateViewModel, + isFirstLogin: Boolean, +) { + var wantsNewUser by remember { + mutableStateOf(false) + } + + Crossfade(wantsNewUser, label = "LoginOrSignupScreen") { + if (it) { + SignUpPage(accountViewModel = accountViewModel) { + wantsNewUser = false + } + } else { + LoginPage(accountViewModel = accountViewModel, isFirstLogin = isFirstLogin) { + wantsNewUser = true + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 6f96b05e4..85c8fd90b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -75,19 +74,17 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.PackageUtils @@ -96,8 +93,9 @@ import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog -import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.SignerType @@ -105,11 +103,21 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.UUID +@Preview +@Composable +fun LoginPage() { + val accountViewModel: AccountStateViewModel = viewModel() + + LoginPage(accountViewModel, true) { + } +} + @OptIn(ExperimentalComposeUiApi::class) @Composable fun LoginPage( accountViewModel: AccountStateViewModel, isFirstLogin: Boolean, + onWantsToLogin: () -> Unit, ) { val key = remember { mutableStateOf(TextFieldValue("")) } var errorMessage by remember { mutableStateOf("") } @@ -208,219 +216,103 @@ fun LoginPage( } Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(Size20dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { - // The first child is glued to the top. - // Hence we have nothing at the top, an empty box is used. - Box(modifier = Modifier.height(0.dp)) + Image( + painterResource(id = R.drawable.amethyst), + contentDescription = stringResource(R.string.app_logo), + modifier = Modifier.size(150.dp), + contentScale = ContentScale.Inside, + ) - // The second child, this column, is centered vertically. - Column( - modifier = Modifier.padding(20.dp).fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painterResource(id = R.drawable.amethyst), - contentDescription = stringResource(R.string.app_logo), - modifier = Modifier.size(200.dp), - contentScale = ContentScale.Inside, + Spacer(modifier = Modifier.height(40.dp)) + + var showPassword by remember { mutableStateOf(false) } + + val autofillNode = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { key.value = TextFieldValue(it) }, ) + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNode - Spacer(modifier = Modifier.height(40.dp)) - - var showPassword by remember { mutableStateOf(false) } - - val autofillNode = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { key.value = TextFieldValue(it) }, - ) - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNode - - OutlinedTextField( - modifier = - Modifier.onGloballyPositioned { coordinates -> + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> autofillNode.boundingBox = coordinates.boundsInWindow() } - .onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNode) - } else { - cancelAutofillForNode(autofillNode) - } - } - }, - value = key.value, - onValueChange = { key.value = it }, - keyboardOptions = - KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringResource(R.string.nsec_npub_hex_private_key), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - imageVector = - if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showPassword) { - stringResource(R.string.show_password) - } else { - stringResource( - R.string.hide_password, - ) - }, - ) - } - } - }, - leadingIcon = { - if (dialogOpen) { - SimpleQrCodeScanner { - dialogOpen = false - if (!it.isNullOrEmpty()) { - key.value = TextFieldValue(it) + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) } } - } - IconButton(onClick = { dialogOpen = true }) { + }, + value = key.value, + onValueChange = { key.value = it }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.nsec_npub_hex_private_key), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showPassword = !showPassword }) { Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, + imageVector = + if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, ) } - }, - visualTransformation = - if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - keyboardActions = - KeyboardActions( - onGo = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) - } - - if (key.value.text.isBlank()) { - errorMessage = context.getString(R.string.key_is_required) - } - - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) - } - } - }, - ), - ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - if (isFirstLogin) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = acceptedTerms.value, - onCheckedChange = { acceptedTerms.value = it }, - ) - - val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) - - val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) - - val annotatedTermsString = - buildAnnotatedString { - withStyle(regularText) { append(stringResource(R.string.i_accept_the)) } - - withStyle(clickableTextStyle) { - pushStringAnnotation("openTerms", "") - append(stringResource(R.string.terms_of_use)) - } - } - - ClickableText( - text = annotatedTermsString, - ) { spanOffset -> - annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { - span -> - if (span.tag == "openTerms") { - runCatching { - uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") - } - } + } + }, + leadingIcon = { + if (dialogOpen) { + SimpleQrCodeScanner { + dialogOpen = false + if (!it.isNullOrEmpty()) { + key.value = TextFieldValue(it) } } } - - if (termsAcceptanceIsRequired.isNotBlank()) { - Text( - text = termsAcceptanceIsRequired, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + IconButton(onClick = { dialogOpen = true }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, ) } - } - - if (PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true - } - }, - ) - - Text(stringResource(R.string.connect_via_tor)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast.makeText( - context, - it, - Toast.LENGTH_LONG, - ) - .show() - } - }, - proxyPort, - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { - Button( - enabled = acceptedTerms.value, - onClick = { + }, + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { if (!acceptedTerms.value) { termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) @@ -436,60 +328,171 @@ fun LoginPage( } } }, + ), + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + if (isFirstLogin) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it }, + ) + + val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) + + val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) + + val annotatedTermsString = + buildAnnotatedString { + withStyle(regularText) { append(stringResource(R.string.i_accept_the)) } + + withStyle(clickableTextStyle) { + pushStringAnnotation("openTerms", "") + append(stringResource(R.string.terms_of_use)) + } + } + + ClickableText( + text = annotatedTermsString, + ) { spanOffset -> + annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { + span -> + if (span.tag == "openTerms") { + runCatching { + uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") + } + } + } + } + } + + if (termsAcceptanceIsRequired.isNotBlank()) { + Text( + text = termsAcceptanceIsRequired, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + if (PackageUtils.isOrbotInstalled(context)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } + }, + ) + + Text(stringResource(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) { + Button( + enabled = acceptedTerms.value, + onClick = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank()) { + accountViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.login), + modifier = Modifier.padding(horizontal = 40.dp), + ) + } + } + + if (PackageUtils.isAmberInstalled(context)) { + Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) { + Button( + enabled = acceptedTerms.value, + onClick = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + return@Button + } + + loginWithExternalSigner = true + return@Button + }, shape = RoundedCornerShape(Size35dp), modifier = Modifier.height(50.dp), ) { Text( - text = stringResource(R.string.login), + text = stringResource(R.string.login_with_external_signer), modifier = Modifier.padding(horizontal = 40.dp), ) } } - - if (PackageUtils.isAmberInstalled(context)) { - Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) { - Button( - enabled = acceptedTerms.value, - onClick = { - if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) - return@Button - } - - loginWithExternalSigner = true - return@Button - }, - shape = RoundedCornerShape(Size35dp), - modifier = Modifier.height(50.dp), - ) { - Text( - text = stringResource(R.string.login_with_external_signer), - modifier = Modifier.padding(horizontal = 40.dp), - ) - } - } - } } - // The last child is glued to the bottom. - ClickableText( - text = AnnotatedString(stringResource(R.string.generate_a_new_key)), - modifier = Modifier.padding(20.dp).fillMaxWidth(), - onClick = { - if (acceptedTerms.value) { - accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) - } else { - termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) - } - }, - style = - TextStyle( - fontSize = Font14SP, - textDecoration = TextDecoration.Underline, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - ), - ) + Spacer(modifier = Modifier.height(Size40dp)) + + Text(text = stringResource(R.string.don_t_have_an_account)) + + Spacer(modifier = Modifier.height(Size20dp)) + + Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { + Button( + onClick = onWantsToLogin, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.sign_up), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt new file mode 100644 index 000000000..78091d118 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt @@ -0,0 +1,299 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedOff + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.PackageUtils +import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.launch + +@Preview +@Composable +fun SignUpPage() { + val accountViewModel: AccountStateViewModel = viewModel() + + SignUpPage(accountViewModel) { + } +} + +@Composable +fun SignUpPage( + accountViewModel: AccountStateViewModel, + onWantsToLogin: () -> Unit, +) { + val displayName = remember { mutableStateOf(TextFieldValue("")) } + var errorMessage by remember { mutableStateOf("") } + val acceptedTerms = remember { mutableStateOf(false) } + var termsAcceptanceIsRequired by remember { mutableStateOf("") } + + val uri = LocalUriHandler.current + val context = LocalContext.current + val useProxy = remember { mutableStateOf(false) } + val proxyPort = remember { mutableStateOf("9050") } + var connectOrbotDialogOpen by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(Size20dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(id = R.drawable.amethyst), + contentDescription = stringResource(R.string.app_logo), + modifier = Modifier.size(150.dp), + contentScale = ContentScale.Inside, + ) + + Spacer(modifier = Modifier.height(Size40dp)) + + Text(text = stringResource(R.string.welcome), style = MaterialTheme.typography.titleLarge) + + Spacer(modifier = Modifier.height(Size20dp)) + + Text(text = stringResource(R.string.how_should_we_call_you), style = MaterialTheme.typography.titleMedium) + + Spacer(modifier = Modifier.height(Size20dp)) + + OutlinedTextField( + value = displayName.value, + onValueChange = { displayName.value = it }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.my_awesome_name), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardActions = + KeyboardActions( + onGo = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = + context.getString(R.string.acceptance_of_terms_is_required) + } + + if (displayName.value.text.isBlank()) { + errorMessage = context.getString(R.string.name_is_required) + } + + if (acceptedTerms.value && displayName.value.text.isNotBlank()) { + accountViewModel.login(displayName.value.text, useProxy.value, proxyPort.value.toInt()) { + errorMessage = context.getString(R.string.invalid_key) + } + } + }, + ), + ) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = acceptedTerms.value, + onCheckedChange = { acceptedTerms.value = it }, + ) + + val regularText = SpanStyle(color = MaterialTheme.colorScheme.onBackground) + + val clickableTextStyle = SpanStyle(color = MaterialTheme.colorScheme.primary) + + val annotatedTermsString = + buildAnnotatedString { + withStyle(regularText) { append(stringResource(R.string.i_accept_the)) } + + withStyle(clickableTextStyle) { + pushStringAnnotation("openTerms", "") + append(stringResource(R.string.terms_of_use)) + } + } + + ClickableText( + text = annotatedTermsString, + ) { spanOffset -> + annotatedTermsString.getStringAnnotations(spanOffset, spanOffset).firstOrNull()?.also { + span -> + if (span.tag == "openTerms") { + runCatching { + uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") + } + } + } + } + } + + if (termsAcceptanceIsRequired.isNotBlank()) { + Text( + text = termsAcceptanceIsRequired, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + if (PackageUtils.isOrbotInstalled(context)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } + }, + ) + + Text(stringResource(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) + } + } + + Spacer(modifier = Modifier.height(Size20dp)) + + Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { + Button( + enabled = acceptedTerms.value, + onClick = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + } + + if (displayName.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (acceptedTerms.value && displayName.value.text.isNotBlank()) { + accountViewModel.newKey(useProxy.value, proxyPort.value.toInt(), displayName.value.text) + } + }, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.create_account), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } + } + + Spacer(modifier = Modifier.height(Size40dp)) + + Text(text = stringResource(R.string.already_have_an_account)) + + Spacer(modifier = Modifier.height(Size20dp)) + + Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { + Button( + onClick = onWantsToLogin, + shape = RoundedCornerShape(Size35dp), + modifier = Modifier.height(50.dp), + ) { + Text( + text = stringResource(R.string.login), + modifier = Modifier.padding(horizontal = Size40dp), + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d859f890..ee7f9a7fe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,6 +93,8 @@ Add a Relay Display Name My display name + Ostrich McAwesome + Welcome Ostrich! Username My username About me @@ -146,7 +148,14 @@ terms of use Acceptance of terms is required Key is required + A name is required Login + Sign Up + Create Account + How should we call you? + Don\'t have a Nostr account? + Already have a Nostr account? + Create a new account Generate a new key Loading feed Loading account @@ -525,7 +534,7 @@ Changing the name for the new goals. Paste from clipboard - + For the App\'s Interface Dark, Light or System theme Automatically load images and GIFs diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt index 70956a3c5..cdc3b7f72 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/MetadataEvent.kt @@ -22,10 +22,13 @@ package com.vitorpamplona.quartz.events import android.util.Log import androidx.compose.runtime.Stable +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils import java.io.ByteArrayInputStream +import java.io.StringWriter @Stable abstract class IdentityClaim( @@ -176,23 +179,115 @@ class MetadataEvent( companion object { const val KIND = 0 - fun create( - contactMetaData: String, - newName: String, - identities: List, + fun updateFromPast( + latest: MetadataEvent?, + name: String?, + picture: String?, + banner: String?, + website: String?, + about: String?, + nip05: String?, + lnAddress: String?, + lnURL: String?, + twitter: String?, + mastodon: String?, + github: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (MetadataEvent) -> Unit, ) { + // Tries to not delete any existing attribute that we do not work with. + val currentJson = + if (latest != null) { + ObjectMapper() + .readTree( + ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8)), + ) as ObjectNode + } else { + ObjectMapper().createObjectNode() + } + + name?.let { addIfNotBlank(currentJson, "name", it.trim()) } + name?.let { addIfNotBlank(currentJson, "display_name", it.trim()) } + picture?.let { addIfNotBlank(currentJson, "picture", it.trim()) } + banner?.let { addIfNotBlank(currentJson, "banner", it.trim()) } + website?.let { addIfNotBlank(currentJson, "website", it.trim()) } + about?.let { addIfNotBlank(currentJson, "about", it.trim()) } + nip05?.let { addIfNotBlank(currentJson, "nip05", it.trim()) } + lnAddress?.let { addIfNotBlank(currentJson, "lud16", it.trim()) } + lnURL?.let { addIfNotBlank(currentJson, "lud06", it.trim()) } + + var claims = latest?.identityClaims() ?: emptyList() + + if (twitter?.isBlank() == true) { + // delete twitter + claims = claims.filter { it !is TwitterIdentity } + } + + if (github?.isBlank() == true) { + // delete github + claims = claims.filter { it !is GitHubIdentity } + } + + if (mastodon?.isBlank() == true) { + // delete mastodon + claims = claims.filter { it !is MastodonIdentity } + } + + // Updates while keeping other identities intact + val newClaims = + listOfNotNull( + twitter?.let { TwitterIdentity.parseProofUrl(it) }, + github?.let { GitHubIdentity.parseProofUrl(it) }, + mastodon?.let { MastodonIdentity.parseProofUrl(it) }, + ) + + claims.filter { it !is TwitterIdentity && it !is GitHubIdentity && it !is MastodonIdentity } + + val writer = StringWriter() + ObjectMapper().writeValue(writer, currentJson) + + val tags = mutableListOf>() + + tags.add( + arrayOf("alt", "User profile for ${name ?: currentJson.get("name").asText() ?: ""}"), + ) + + newClaims.forEach { tags.add(arrayOf("i", it.platformIdentity(), it.proof)) } + + signer.sign(createdAt, KIND, tags.toTypedArray(), writer.buffer.toString(), onReady) + } + + private fun addIfNotBlank( + currentJson: ObjectNode, + key: String, + value: String, + ) { + if (value.isBlank()) { + currentJson.remove(key) + } else { + currentJson.put(key, value.trim()) + } + } + + fun createFromScratch( + newName: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (MetadataEvent) -> Unit, + ) { + val prop = ObjectMapper().createObjectNode() + prop.put("name", newName.trim()) + + val writer = StringWriter() + ObjectMapper().writeValue(writer, prop) + val tags = mutableListOf>() tags.add( arrayOf("alt", "User profile for $newName"), ) - identities.forEach { tags.add(arrayOf("i", it.platformIdentity(), it.proof)) } - - signer.sign(createdAt, KIND, tags.toTypedArray(), contactMetaData, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), writer.buffer.toString(), onReady) } } }