New Signup screen

pull/741/head
Vitor Pamplona 2024-01-09 16:23:17 -05:00
rodzic d27b9ae5a8
commit 7b7e3624ac
12 zmienionych plików z 798 dodań i 344 usunięć

1
.gitignore vendored
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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() {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -93,6 +93,8 @@
<string name="add_a_relay">Add a Relay</string>
<string name="display_name">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="my_username">My username</string>
<string name="about_me">About me</string>
@ -146,7 +148,14 @@
<string name="terms_of_use">terms of use</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="name_is_required">A name is required</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="loading_feed">Loading feed</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="paste_from_clipboard">Paste from clipboard</string>
<string name="language_description">For the App\'s Interface</string>
<string name="theme_description">Dark, Light or System theme</string>
<string name="automatically_load_images_gifs_description">Automatically load images and GIFs</string>

Wyświetl plik

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