diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt index 7418537d..591c607c 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/database/repository/DatabaseNostrRepository.kt @@ -15,6 +15,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlin.time.Clock import kotlin.time.Instant class DatabaseNostrRepository( @@ -40,8 +41,8 @@ class DatabaseNostrRepository( override suspend fun createNewProfile( publicKey: HexKey, - name: String, - biography: String, + name: String?, + biography: String?, onError: () -> Unit, onProfileReady: (Profile) -> Unit ) { @@ -50,13 +51,21 @@ class DatabaseNostrRepository( about = biography, ) + val signedAt = if (name == null && biography == null) { + // We are most likely try to sign in to an already existing block... + Instant.fromEpochMilliseconds(1231006505000L) + } else { + null + } + val unsignedNostrEvent = saveUnsignedNostrEvent( UnsignedNostrEvent( pubKey = publicKey, kind = profileEventTemplate.kind, createdAt = Instant.fromEpochMilliseconds(profileEventTemplate.createdAt), tags = profileEventTemplate.tags, - content = profileEventTemplate.content + content = profileEventTemplate.content, + signedAt = signedAt // This will keep us from trying to sign this event... but we should automatically sync the npub profile ) ) diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt index ecaf38d1..c92f3c3c 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/repository/NostrRepository.kt @@ -19,8 +19,8 @@ interface NostrRepository { suspend fun createNewProfile( publicKey: HexKey, - name: String, - biography: String, + name: String?, + biography: String?, onError: () -> Unit, // onUnsignProfile: (UnsignedNostrEvent) -> Unit, // onUnbroadcastedProfile: (NostrEvent) -> Unit, @@ -64,8 +64,8 @@ interface NostrRepository { override suspend fun createNewProfile( publicKey: HexKey, - name: String, - biography: String, + name: String?, + biography: String?, onError: () -> Unit, onProfileReady: (Profile) -> Unit ) { diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SignInScreen.kt new file mode 100644 index 00000000..448b04a6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/SignInScreen.kt @@ -0,0 +1,293 @@ +package ac.aux.compose.ui.composable + +import ac.aux.compose.repository.NostrRepository +import ac.aux.compose.ui.theme.AuxTheme +import ac.aux.compose.ui.view.model.SignInToProfileViewModel +import ac.aux.compose.ui.view.state.SignInToProfileUIState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.NavigateNext +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SignInToProfileScreen( + initialSignInToProfileUIState: SignInToProfileUIState = SignInToProfileUIState.InputPrompt, + nostrRepository: NostrRepository +) { + val signInToProfileViewModel: SignInToProfileViewModel = viewModel ( + factory = SignInToProfileViewModel.factory( + initialSignInToProfileUIState, + nostrRepository + ) + ) + Scaffold { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + ) { + + Column( + modifier = Modifier.fillMaxWidth().padding( + 10.dp + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + + when (val signInToProfileUIState = signInToProfileViewModel.signInToProfileUIState.value) { + is SignInToProfileUIState.InputPrompt -> { + Text( + "Sign In", + style = MaterialTheme.typography.headlineSmall + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = signInToProfileViewModel.signInToProfileFormState.textField.text.value, + onValueChange = { + signInToProfileViewModel.signInToProfileFormState.textField.text.value = it + }, + isError = signInToProfileViewModel.signInToProfileFormState.textField.errorMessage.value != null, + supportingText = signInToProfileViewModel.signInToProfileFormState.textField.errorMessage.value?.let { + { + Text( + text = it, + modifier = Modifier.padding( + bottom = 8.dp + ) + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + label = { + Text( + text = "nsec, npub, nip-05 (static address)", + maxLines = 1, + ) + }, + placeholder = { + Text( + text = "Enter the nsec or npub (read only) that you want to sign in as", + maxLines = 1, + ) + }, + leadingIcon = { + Icon( + Icons.Default.Create, + contentDescription = "Write note" + ) + } + ) + + Button( + onClick = { + signInToProfileViewModel.signInToProfileUIState.value = signInToProfileViewModel.validateCredential().let { credentialEntity -> + when (credentialEntity) { + is NSec -> { + SignInToProfileUIState.ConfirmNsecSignIn( + nsec = signInToProfileViewModel.signInToProfileFormState.textField.text.value, + hexKey = credentialEntity.hex + ) + } + is NPub -> { + SignInToProfileUIState.ConfirmNpubSignIn( + npub = signInToProfileViewModel.signInToProfileFormState.textField.text.value, + hexKey = credentialEntity.hex + ) + } + else -> { + SignInToProfileUIState.Error + } + } + } + }, + ) { + Text( + "Next" + ) + Icon( + Icons.Default.NavigateNext, + contentDescription = "Next" + ) + } + + } + is SignInToProfileUIState.ConfirmNpubSignIn-> { + + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = "Sign in to Npub", + style = MaterialTheme.typography.headlineMedium + ) + + Text( + text = signInToProfileUIState.npub, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = signInToProfileUIState.hexKey, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + + + Spacer( + modifier = Modifier.weight(4f) + ) + + + + Text( + text = "This will give you read only access to the profile.", + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + + if (signInToProfileViewModel.isActionPending.value) { + LoadingIndicator( + modifier = Modifier + ) + } else { + Button( + onClick = { + signInToProfileViewModel.signInToAccount( + onError = { + signInToProfileViewModel.signInToProfileUIState.value = + SignInToProfileUIState.Error + } + ) + } + ) { + Text( + text = "Sign In" + ) + } + } + + } + is SignInToProfileUIState.ConfirmNsecSignIn-> { + + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = "Sign in to nsec", + style = MaterialTheme.typography.headlineMedium + ) + + Text( + text = "Be sure to keep this nsec safe.", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + + + Spacer( + modifier = Modifier.weight(4f) + ) + + + + Text( + text = "This will give you write access to the profile.", + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center + ) + + if (signInToProfileViewModel.isActionPending.value) { + LoadingIndicator( + modifier = Modifier + ) + } else { + Button( + onClick = { + signInToProfileViewModel.signInToAccount( + onError = { + signInToProfileViewModel.signInToProfileUIState.value = + SignInToProfileUIState.Error + } + ) + } + ) { + Text( + text = "Sign In" + ) + } + } + + } + is SignInToProfileUIState.Error -> { + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = "Something went wrong and we were unable to sign in to your provided profile. Please try again later.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + Spacer( + modifier = Modifier.weight(1f) + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun SignInToProfileScreenPreview() { + AuxTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + SignInToProfileScreen( + initialSignInToProfileUIState = + SignInToProfileUIState.InputPrompt, +// SignInToProfileUIState.Error, +// SignInToProfileUIState.ConfirmNpubSignIn( +// npub = "npub1uh366qnj68a7atvwgwdy4569xd6v5jtferftapqztwjwlzh9553q47pqsx", +// hexKey = "e5e3ad0272d1fbeead8e439a4ad3453374ca4969c8d2be84025ba4ef8ae5a522" +// ), +// SignInToProfileUIState.ConfirmNsecSignIn( +// nsec = "nsecthingsaretobekeptsecret", +// hexKey = "e5e3ad0272d1fbeead8e439a4ad3453374ca4969c8d2be84025ba4ef8ae5a522" +// ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt index 185704b9..911426ae 100644 --- a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/AuxNavHost.kt @@ -8,6 +8,7 @@ import ac.aux.compose.ui.composable.FeedScreen import ac.aux.compose.ui.composable.ImplementationPendingScreen import ac.aux.compose.ui.composable.LandingScreen import ac.aux.compose.ui.composable.LoadingScreen +import ac.aux.compose.ui.composable.SignInToProfileScreen import ac.aux.compose.ui.composable.SocialPreconditionScreen import ac.aux.compose.ui.composable.UnannouncedProfileScreen import ac.aux.compose.ui.composable.UnindexedProfileScreen @@ -20,6 +21,7 @@ import ac.aux.compose.ui.composable.navigation.routes.FeedRoute import ac.aux.compose.ui.composable.navigation.routes.ImplementationPendingRoute import ac.aux.compose.ui.composable.navigation.routes.LandingRoute import ac.aux.compose.ui.composable.navigation.routes.LoadingRoute +import ac.aux.compose.ui.composable.navigation.routes.SignInRoute import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -175,7 +177,7 @@ fun AuxNavHost( }, onNavigateToSignIn = { navController.navigate( - route = ImplementationPendingRoute("Sign in") + route = SignInRoute ) }, onNavigateToCreateProfile = { @@ -212,6 +214,11 @@ fun AuxNavHost( nostrRepository = nostrRepository ) } + composable { + SignInToProfileScreen( + nostrRepository = nostrRepository + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/routes/SignInRoute.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/routes/SignInRoute.kt new file mode 100755 index 00000000..285406e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/composable/navigation/routes/SignInRoute.kt @@ -0,0 +1,6 @@ +package ac.aux.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +object SignInRoute: Route() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SignInToProfileViewModel.kt new file mode 100644 index 00000000..d967984f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/model/SignInToProfileViewModel.kt @@ -0,0 +1,103 @@ +package ac.aux.compose.ui.view.model + +import ac.aux.compose.managers.SeedManager +import ac.aux.compose.repository.NostrRepository +import ac.aux.compose.ui.view.state.SignInToProfileUIState +import ac.aux.compose.ui.view.state.form.SignInToProfileFormState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +class SignInToProfileViewModel( + initialWriteNewNoteUIState: SignInToProfileUIState, + val signInToProfileFormState: SignInToProfileFormState = SignInToProfileFormState(), + val nostrRepository: NostrRepository +): ViewModel() { + companion object { + private const val TAG = "SignInToProfileViewModel" + + fun factory( + initialWriteNewNoteUIState: SignInToProfileUIState, + nostrRepository: NostrRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SignInToProfileViewModel( + initialWriteNewNoteUIState, + nostrRepository = nostrRepository + ) + } + } + } + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + var signInToProfileUIState: MutableState = mutableStateOf( + initialWriteNewNoteUIState + ) + + fun validateCredential(): Entity? { + return signInToProfileFormState.textField.text.value.let { credentialText -> + if (credentialText.isBlank()) { + signInToProfileFormState.textField.errorMessage.value = "Please input a credential" + return null + } else { + signInToProfileFormState.textField.errorMessage.value = null + } + + Nip19Parser.uriToRoute(signInToProfileFormState.textField.text.value)?.entity + } + } + + fun signInToAccount( + onError: () -> Unit, + ) { + logger.d { "signInToAccount" } + validateCredential().let { credentialEntity -> + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + when (credentialEntity) { + is NPub -> { + nostrRepository.createNewProfile( + publicKey = credentialEntity.hex, + name = null, + biography = null, + onError = onError, + onProfileReady = {} + ) + } + is NSec -> { + nostrRepository.createNewProfile( + publicKey = credentialEntity.toPubKeyHex(), + name = null, + biography = null, + onError = onError, + onProfileReady = {} + ) + } + else -> { + // TODO: Might want to give reasons for the error. + signInToProfileUIState.value = SignInToProfileUIState.Error + } + } + + } + } + + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/SignInToProfileUIState.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/SignInToProfileUIState.kt new file mode 100644 index 00000000..8282ef0e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/SignInToProfileUIState.kt @@ -0,0 +1,20 @@ +package ac.aux.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +abstract class SignInToProfileUIState { + data object Error: SignInToProfileUIState() + + data object InputPrompt: SignInToProfileUIState() + + data class ConfirmNpubSignIn( + val npub: String, + val hexKey: HexKey + ) : SignInToProfileUIState() + + data class ConfirmNsecSignIn( + val nsec: String, + val hexKey: HexKey + ) : SignInToProfileUIState() + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/form/SignInToProfileFormState.kt b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/form/SignInToProfileFormState.kt new file mode 100644 index 00000000..08a2b3cf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/aux/compose/ui/view/state/form/SignInToProfileFormState.kt @@ -0,0 +1,5 @@ +package ac.aux.compose.ui.view.state.form + +data class SignInToProfileFormState( + val textField: TextField = TextField(), +) \ No newline at end of file