New Signup screen

This commit is contained in:
Vitor Pamplona 2024-01-09 16:23:17 -05:00
parent d27b9ae5a8
commit 7b7e3624ac
12 changed files with 798 additions and 344 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/appInsightsSettings.xml /.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml /.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
.DS_Store .DS_Store
/build /build
/captures /captures

View File

@ -130,8 +130,11 @@ object LocalPreferences {
return currentAccount return currentAccount
} }
private suspend fun updateCurrentAccount(npub: String) { private fun updateCurrentAccount(npub: String?) {
if (currentAccount != npub) { if (npub == null) {
currentAccount = null
encryptedPreferences().edit().clear().apply()
} else if (currentAccount != npub) {
currentAccount = npub currentAccount = npub
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply() encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
@ -250,7 +253,7 @@ object LocalPreferences {
deleteUserPreferenceFile(accountInfo.npub) deleteUserPreferenceFile(accountInfo.npub)
if (savedAccounts().isEmpty()) { if (savedAccounts().isEmpty()) {
encryptedPreferences().edit().clear().apply() updateCurrentAccount(null)
} else if (currentAccount() == accountInfo.npub) { } else if (currentAccount() == accountInfo.npub) {
updateCurrentAccount(savedAccounts().elementAt(0).npub) updateCurrentAccount(savedAccounts().elementAt(0).npub)
} }

View File

@ -66,7 +66,6 @@ import com.vitorpamplona.quartz.events.GeneralListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.events.IdentityClaim
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
@ -541,14 +540,36 @@ class Account(
} }
} }
suspend fun sendNewUserMetadata( fun sendNewUserMetadata(
toString: String, name: String? = null,
newName: String, picture: String? = null,
identities: List<IdentityClaim>, 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 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) Client.send(it)
LocalCache.justConsume(it, null) LocalCache.justConsume(it, null)
} }

View File

@ -27,8 +27,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.model.Account
import com.vitorpamplona.amethyst.service.Nip96Uploader import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.MediaCompressor
@ -39,8 +37,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.StringWriter
class NewUserMetadataViewModel : ViewModel() { class NewUserMetadataViewModel : ViewModel() {
private lateinit var account: Account private lateinit var account: Account
@ -97,59 +93,22 @@ class NewUserMetadataViewModel : ViewModel() {
fun create() { fun create() {
// Tries to not delete any existing attribute that we do not work with. // 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) { 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() { fun clear() {

View File

@ -73,7 +73,7 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size55dp
@ -121,7 +121,7 @@ fun AccountSwitchBottomSheet(
) { ) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Box { Box {
LoginPage(accountStateViewModel, isFirstLogin = false) LoginOrSignupScreen(accountStateViewModel, isFirstLogin = false)
TopAppBar( TopAppBar(
title = { title = {
Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) Text(text = stringResource(R.string.account_switch_add_account_dialog_title))

View File

@ -50,7 +50,7 @@ import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen 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 com.vitorpamplona.quartz.signers.NostrSignerExternal
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -71,7 +71,7 @@ fun AccountScreen(
LoadingAccounts() LoadingAccounts()
} }
is AccountState.LoggedOff -> { is AccountState.LoggedOff -> {
LoginPage(accountStateViewModel, isFirstLogin = true) LoginOrSignupScreen(accountStateViewModel, isFirstLogin = true)
} }
is AccountState.LoggedIn -> { is AccountState.LoggedIn -> {
CompositionLocalProvider( CompositionLocalProvider(

View File

@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.encoders.Nip19
@ -42,6 +43,7 @@ import com.vitorpamplona.quartz.signers.NostrSignerInternal
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -65,8 +67,7 @@ class AccountStateViewModel() : ViewModel() {
private suspend fun tryLoginExistingAccount() = private suspend fun tryLoginExistingAccount() =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } LocalPreferences.loadCurrentAccountFromEncryptedStorage()?.let { startUI(it) } ?: run { requestLoginUI() }
?: run { requestLoginUI() }
} }
private suspend fun requestLoginUI() { private suspend fun requestLoginUI() {
@ -144,25 +145,33 @@ class AccountStateViewModel() : ViewModel() {
startUI(account) startUI(account)
} }
suspend fun startUI(account: Account) = suspend fun startUI(
withContext(Dispatchers.Main) { account: Account,
if (account.isWriteable()) { onServicesReady: (() -> Unit)? = null,
_accountContent.update { AccountState.LoggedIn(account) } ) = withContext(Dispatchers.Main) {
} else { if (account.isWriteable()) {
_accountContent.update { AccountState.LoggedInViewOnly(account) } _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)
} }
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) @OptIn(DelicateCoroutinesApi::class)
private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = { private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = {
GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) } GlobalScope.launch(Dispatchers.IO) { LocalPreferences.saveToEncryptedStorage(it.account) }
@ -204,6 +213,7 @@ class AccountStateViewModel() : ViewModel() {
fun newKey( fun newKey(
useProxy: Boolean, useProxy: Boolean,
proxyPort: Int, proxyPort: Int,
name: String? = null,
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort) val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
@ -220,7 +230,10 @@ class AccountStateViewModel() : ViewModel() {
// saves to local preferences // saves to local preferences
LocalPreferences.updatePrefsForLogin(account) LocalPreferences.updatePrefsForLogin(account)
startUI(account) startUI(account) {
account.userProfile().latestContactList?.let { Client.send(it) }
account.sendNewUserMetadata(name = name)
}
} }
} }

View File

@ -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
}
}
}
}

View File

@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation 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.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.PackageUtils 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.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog 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.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
import com.vitorpamplona.quartz.signers.SignerType import com.vitorpamplona.quartz.signers.SignerType
@ -105,11 +103,21 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
@Preview
@Composable
fun LoginPage() {
val accountViewModel: AccountStateViewModel = viewModel()
LoginPage(accountViewModel, true) {
}
}
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun LoginPage( fun LoginPage(
accountViewModel: AccountStateViewModel, accountViewModel: AccountStateViewModel,
isFirstLogin: Boolean, isFirstLogin: Boolean,
onWantsToLogin: () -> Unit,
) { ) {
val key = remember { mutableStateOf(TextFieldValue("")) } val key = remember { mutableStateOf(TextFieldValue("")) }
var errorMessage by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf("") }
@ -208,219 +216,103 @@ fun LoginPage(
} }
Column( Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), modifier =
verticalArrangement = Arrangement.SpaceBetween, Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(Size20dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
// The first child is glued to the top. Image(
// Hence we have nothing at the top, an empty box is used. painterResource(id = R.drawable.amethyst),
Box(modifier = Modifier.height(0.dp)) contentDescription = stringResource(R.string.app_logo),
modifier = Modifier.size(150.dp),
contentScale = ContentScale.Inside,
)
// The second child, this column, is centered vertically. Spacer(modifier = Modifier.height(40.dp))
Column(
modifier = Modifier.padding(20.dp).fillMaxSize(), var showPassword by remember { mutableStateOf(false) }
horizontalAlignment = Alignment.CenterHorizontally,
) { val autofillNode =
Image( AutofillNode(
painterResource(id = R.drawable.amethyst), autofillTypes = listOf(AutofillType.Password),
contentDescription = stringResource(R.string.app_logo), onFill = { key.value = TextFieldValue(it) },
modifier = Modifier.size(200.dp),
contentScale = ContentScale.Inside,
) )
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNode
Spacer(modifier = Modifier.height(40.dp)) OutlinedTextField(
modifier =
var showPassword by remember { mutableStateOf(false) } Modifier
.onGloballyPositioned { coordinates ->
val autofillNode =
AutofillNode(
autofillTypes = listOf(AutofillType.Password),
onFill = { key.value = TextFieldValue(it) },
)
val autofill = LocalAutofill.current
LocalAutofillTree.current += autofillNode
OutlinedTextField(
modifier =
Modifier.onGloballyPositioned { coordinates ->
autofillNode.boundingBox = coordinates.boundsInWindow() autofillNode.boundingBox = coordinates.boundsInWindow()
} }
.onFocusChanged { focusState -> .onFocusChanged { focusState ->
autofill?.run { autofill?.run {
if (focusState.isFocused) { if (focusState.isFocused) {
requestAutofillForNode(autofillNode) requestAutofillForNode(autofillNode)
} else { } else {
cancelAutofillForNode(autofillNode) 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)
} }
} }
} },
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( Icon(
painter = painterResource(R.drawable.ic_qrcode), imageVector =
null, if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
modifier = Modifier.size(24.dp), contentDescription =
tint = MaterialTheme.colorScheme.primary, if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password,
)
},
) )
} }
}, }
visualTransformation = },
if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), leadingIcon = {
keyboardActions = if (dialogOpen) {
KeyboardActions( SimpleQrCodeScanner {
onGo = { dialogOpen = false
if (!acceptedTerms.value) { if (!it.isNullOrEmpty()) {
termsAcceptanceIsRequired = key.value = TextFieldValue(it)
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")
}
}
} }
} }
} }
IconButton(onClick = { dialogOpen = true }) {
if (termsAcceptanceIsRequired.isNotBlank()) { Icon(
Text( painter = painterResource(R.drawable.ic_qrcode),
text = termsAcceptanceIsRequired, null,
color = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp),
style = MaterialTheme.typography.bodySmall, tint = MaterialTheme.colorScheme.primary,
) )
} }
} },
visualTransformation =
if (PackageUtils.isOrbotInstalled(context)) { if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
Row(verticalAlignment = Alignment.CenterVertically) { keyboardActions =
Checkbox( KeyboardActions(
checked = useProxy.value, onGo = {
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) { if (!acceptedTerms.value) {
termsAcceptanceIsRequired = termsAcceptanceIsRequired =
context.getString(R.string.acceptance_of_terms_is_required) 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), shape = RoundedCornerShape(Size35dp),
modifier = Modifier.height(50.dp), modifier = Modifier.height(50.dp),
) { ) {
Text( Text(
text = stringResource(R.string.login), text = stringResource(R.string.login_with_external_signer),
modifier = Modifier.padding(horizontal = 40.dp), 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. Spacer(modifier = Modifier.height(Size40dp))
ClickableText(
text = AnnotatedString(stringResource(R.string.generate_a_new_key)), Text(text = stringResource(R.string.don_t_have_an_account))
modifier = Modifier.padding(20.dp).fillMaxWidth(),
onClick = { Spacer(modifier = Modifier.height(Size20dp))
if (acceptedTerms.value) {
accountViewModel.newKey(useProxy.value, proxyPort.value.toInt()) Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
} else { Button(
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) onClick = onWantsToLogin,
} shape = RoundedCornerShape(Size35dp),
}, modifier = Modifier.height(50.dp),
style = ) {
TextStyle( Text(
fontSize = Font14SP, text = stringResource(R.string.sign_up),
textDecoration = TextDecoration.Underline, modifier = Modifier.padding(horizontal = Size40dp),
color = MaterialTheme.colorScheme.primary, )
textAlign = TextAlign.Center, }
), }
)
} }
} }

View File

@ -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),
)
}
}
}
}

View File

@ -93,6 +93,8 @@
<string name="add_a_relay">Add a Relay</string> <string name="add_a_relay">Add a Relay</string>
<string name="display_name">Display Name</string> <string name="display_name">Display Name</string>
<string name="my_display_name">My display name</string> <string name="my_display_name">My display name</string>
<string name="my_awesome_name">Ostrich McAwesome</string>
<string name="welcome">Welcome Ostrich!</string>
<string name="username">Username</string> <string name="username">Username</string>
<string name="my_username">My username</string> <string name="my_username">My username</string>
<string name="about_me">About me</string> <string name="about_me">About me</string>
@ -146,7 +148,14 @@
<string name="terms_of_use">terms of use</string> <string name="terms_of_use">terms of use</string>
<string name="acceptance_of_terms_is_required">Acceptance of terms is required</string> <string name="acceptance_of_terms_is_required">Acceptance of terms is required</string>
<string name="key_is_required">Key is required</string> <string name="key_is_required">Key is required</string>
<string name="name_is_required">A name is required</string>
<string name="login">Login</string> <string name="login">Login</string>
<string name="sign_up">Sign Up</string>
<string name="create_account">Create Account</string>
<string name="how_should_we_call_you">How should we call you?</string>
<string name="don_t_have_an_account">Don\'t have a Nostr account?</string>
<string name="already_have_an_account">Already have a Nostr account?</string>
<string name="create_a_new_account">Create a new account</string>
<string name="generate_a_new_key">Generate a new key</string> <string name="generate_a_new_key">Generate a new key</string>
<string name="loading_feed">Loading feed</string> <string name="loading_feed">Loading feed</string>
<string name="loading_account">Loading account</string> <string name="loading_account">Loading account</string>
@ -525,7 +534,7 @@
<string name="messages_new_subject_message_placeholder">Changing the name for the new goals.</string> <string name="messages_new_subject_message_placeholder">Changing the name for the new goals.</string>
<string name="paste_from_clipboard">Paste from clipboard</string> <string name="paste_from_clipboard">Paste from clipboard</string>
<string name="language_description">For the App\'s Interface</string> <string name="language_description">For the App\'s Interface</string>
<string name="theme_description">Dark, Light or System theme</string> <string name="theme_description">Dark, Light or System theme</string>
<string name="automatically_load_images_gifs_description">Automatically load images and GIFs</string> <string name="automatically_load_images_gifs_description">Automatically load images and GIFs</string>

View File

@ -22,10 +22,13 @@ package com.vitorpamplona.quartz.events
import android.util.Log import android.util.Log
import androidx.compose.runtime.Stable 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.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.StringWriter
@Stable @Stable
abstract class IdentityClaim( abstract class IdentityClaim(
@ -176,23 +179,115 @@ class MetadataEvent(
companion object { companion object {
const val KIND = 0 const val KIND = 0
fun create( fun updateFromPast(
contactMetaData: String, latest: MetadataEvent?,
newName: String, name: String?,
identities: List<IdentityClaim>, picture: String?,
banner: String?,
website: String?,
about: String?,
nip05: String?,
lnAddress: String?,
lnURL: String?,
twitter: String?,
mastodon: String?,
github: String?,
signer: NostrSigner, signer: NostrSigner,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
onReady: (MetadataEvent) -> Unit, 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<Array<String>>()
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<Array<String>>() val tags = mutableListOf<Array<String>>()
tags.add( tags.add(
arrayOf("alt", "User profile for $newName"), arrayOf("alt", "User profile for $newName"),
) )
identities.forEach { tags.add(arrayOf("i", it.platformIdentity(), it.proof)) } signer.sign(createdAt, KIND, tags.toTypedArray(), writer.buffer.toString(), onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), contactMetaData, onReady)
} }
} }
} }