diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b9b5da9e..da3088e5 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -590,4 +590,9 @@ Twelve words, or nsec1… Use a different key You are signing in as + + Enter a name for the profile. + It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here. + Set up a profile + We could not find a profile for this key on the relays we asked. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt index 8620b38d..39f16323 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt @@ -20,6 +20,15 @@ interface SynchronizeNostrEventRequestDao { @Upsert suspend fun upsert(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) + /** + * Records that the relay has said everything it has, *if* nothing arrived: a request + * an event has already moved to `processed` stays there. Conditional in SQL rather + * than read-then-write because the event handling that writes `processed` runs in + * its own coroutine and can land after the subscription has closed. + */ + @Query("UPDATE SynchronizeNostrEventRequest SET status = :status, updatedAt = :updatedAt WHERE id = :id AND status = 'sent'") + suspend fun markCompleteIfSent(id: String, status: String, updatedAt: kotlin.time.Instant): Int + @Insert suspend fun insert(synchronizeNostrEventRequests: List) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 2e64b2f5..906f0d62 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -124,6 +124,46 @@ class DatabaseNostrRepository( ) { logger.d("createNewProfile: $publicKey") + saveUnsignedNostrEvents(bootstrapUnsignedEvents(publicKey, name, biography)) + + onCompletion.invoke() + } + + override suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) { + logger.d("setUpProfileForExistingKey: $publicKey") + + val events = bootstrapUnsignedEvents(publicKey, name, biography) + val profile = events.first { it.kind == MetadataEvent.KIND } + + // The placeholder row is *replaced*, under its own id, rather than joined by a + // second kind 0: `getLocalAccounts` is every kind-0 row, and two for one pubkey + // would be two accounts disagreeing about which is this one. Its `signedAt` goes + // back to null so the notary signs it, and the sync requests that hang off the id + // stay where they are as the record of the search that found nothing. + database.unsignedNostrEventDao().upsert( + profile.copy( + id = placeholderUnsignedNostrEventId, + signedAt = null, + ) + ) + saveUnsignedNostrEvents(events.filterNot { it.kind == MetadataEvent.KIND }) + } + + /** + * What a key gets when it first becomes a profile here: the kind 0, a contact list + * that follows itself, and the relay lists the app reads, all pointed at the defaults. + * The kind 0 is first. + */ + private fun bootstrapUnsignedEvents( + publicKey: HexKey, + name: String?, + biography: String?, + ): List { val profileEventTemplate = MetadataEvent.createNew( name = name, about = biography, @@ -207,9 +247,7 @@ class DatabaseNostrRepository( ) ) - saveUnsignedNostrEvents(unsignedNostrEvents) - - onCompletion.invoke() + return unsignedNostrEvents } override suspend fun signInToProfile( @@ -495,6 +533,14 @@ class DatabaseNostrRepository( ) } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) { + database.synchronizeNostrEventRequestDao().markCompleteIfSent( + id = synchronizeNostrEventRequest.id, + status = press.mantra.compose.nostr.SignInSync.STATUS_COMPLETE, + updatedAt = Clock.System.now(), + ) + } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: press.mantra.compose.database.model.NegentropySynchronizeRequest) { database.negentropySynchronizeRequestDao().upsert( negentropySynchronizeRequest.copy( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt new file mode 100644 index 00000000..93e62645 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt @@ -0,0 +1,91 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * The sync that finds an existing identity's profile: what it asks for, whom it asks, + * and the one hop it follows. + * + * Three call sites want the same request -- the screen that first queues it, the + * "try again" on the screen that waits for it, and the hop below -- so it is built here + * once. See docs/nsec-sign-in.md, Phase 5. + */ +object SignInSync { + const val PURPOSE = "sign-in" + + /** A request has left the queue but no relay has finished answering it. */ + val IN_FLIGHT_STATUSES = setOf("pending", "sent") + + /** Set once a relay has said everything it has -- EOSE, CLOSED or the bounded timeout. */ + const val STATUS_COMPLETE = "complete" + + /** The profile, the contacts, and every relay list the app reads. */ + val KINDS = arrayOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + IndexerRelayListEvent.KIND, + ChannelListEvent.KIND, + RelayFeedsListEvent.KIND, + ) + + /** + * Whom to ask first. Indexer relays exist to hold everyone's kinds 0, 3 and 10002 -- + * that is the whole of their purpose, and it is exactly the list above -- so they are + * where an identity that has lived elsewhere for years will be found. Our own relay + * stays in the set so a profile created here is found here. This used to be our relay + * alone, which is the right answer only for a profile this app made. + */ + val bootstrapRelays: List = + (Relays.DefaultIndexerRelayList + Relays.ephemeral).distinct() + + fun requests( + unsignedNostrEventId: Long, + publicKey: HexKey, + relays: Collection, + level: Int, + ): List = relays.map { relay -> + SynchronizeNostrEventRequest( + purpose = PURPOSE, + synchronizationFilters = arrayOf( + SynchronizationFilter( + authors = arrayOf(publicKey), + kinds = KINDS, + ) + ), + unsignedNostrEventId = unsignedNostrEventId, + relayURL = relay.url, + level = level, + ) + } + + /** + * The relays a kind 10002 says its author writes to -- `r` tags with no marker or a + * `write` marker -- less the ones already asked. This is the outbox model doing what + * it is for: the indexers know *where* the user publishes, and the user's own relays + * are where the rest of their lists are authoritative. Bounded at one hop by the + * caller, which only follows from a level-0 request; anything past that is the feed, + * not the profile. + */ + fun writeRelaysOf(tags: Array>, alreadyAsked: Collection): List = + tags.asSequence() + .filter { it.size >= 2 && it[0] == "r" && (it.getOrNull(2).isNullOrBlank() || it[2] == "write") } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it[1]) } + .distinct() + .filterNot { it in alreadyAsked } + .toList() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 5372e220..aaf5147a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -60,6 +60,19 @@ interface NostrRepository { publicKey: HexKey ) + /** + * The exit from a sign-in whose search found nothing: the key already exists, only + * the events do not. Gives [publicKey] the same bootstrap [createNewProfile] gives a + * fresh key, with the kind 0 written *over* the placeholder [signInToProfile] planted + * -- see the implementation for why over rather than beside. + */ + suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) + suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -93,6 +106,13 @@ interface NostrRepository { suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) + /** + * The relay has finished answering [synchronizeNostrEventRequest] -- EOSE, CLOSED or + * the bounded timeout -- and the request is still `sent`, meaning nothing arrived. Until + * this existed "still searching" and "searched, found nothing" were the same row. + */ + suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) + suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) @@ -231,6 +251,15 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) { + TODO("Not yet implemented") + } + override suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -278,6 +307,10 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { + TODO("Not yet implemented") + } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 0fa869a7..7a86f4be 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -5,73 +5,222 @@ 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.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import press.mantra.compose.ui.theme.spacing +import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res -import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.enter_a_name_for_the_profile +import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2 +import mantra.composeapp.generated.resources.introduce_yourself +import mantra.composeapp.generated.resources.it_may_be_new_or_it_may_live_on_relays_we +import mantra.composeapp.generated.resources.name_eg_alan_turing +import mantra.composeapp.generated.resources.set_up_a_profile +import mantra.composeapp.generated.resources.try_again import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your -import androidx.compose.material3.SnackbarHost +import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key +import mantra.composeapp.generated.resources.what_should_people_know_about_you +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.UnsyncedProfileViewModel +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +/** + * Waiting for a sign-in sync -- and, since Phase 5 of docs/nsec-sign-in.md, able to say + * when the wait is over with nothing to show for it. Before that the spinner below was + * the whole screen and had no exit: a key made in a client that never wrote a kind 0 + * sat under "searching" for good. + */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnsyncedProfileScreen( - unsyncedProfilePublicKey: String + unsyncedProfilePublicKey: String, + nostrRepository: NostrRepository, ) { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - .readableContent() - ) { + val viewModel: UnsyncedProfileViewModel = viewModel( + factory = UnsyncedProfileViewModel.factory( + publicKey = unsyncedProfilePublicKey, + nostrRepository = nostrRepository, + ) + ) + val uiState by viewModel.uiState.collectAsState() - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space125 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - press.mantra.compose.ui.composable.widgets.LoadingDataIndicator( - fillScreen = false - ) - - Spacer( - modifier = Modifier.weight(2f) + // imePadding for the not-found form's fields. + Scaffold( + modifier = Modifier.imePadding(), + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + ) { innerPadding -> + ScreenStateTransition( + state = uiState, + modifier = Modifier.fillMaxSize().padding(innerPadding), + label = "unsynced profile", + ) { state -> + when (state) { + is UnsyncedProfileUIState.Searching -> Searching() + is UnsyncedProfileUIState.NotFound -> NotFound( + viewModel = viewModel, + unsignedNostrEventId = state.unsignedNostrEventId, ) } } } } +@Composable +private fun Searching() { + Column( + modifier = Modifier + .fillMaxSize() + .readableContent() + ) { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space125 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } +} + +/** + * Every relay asked has answered and none had a profile. Two ways out: ask again, or + * give the key that is already on this device a profile -- the six-event bootstrap a + * fresh key gets, without a fresh key. + */ +@Composable +private fun NotFound( + viewModel: UnsyncedProfileViewModel, + unsignedNostrEventId: Long, +) { + val nameField = viewModel.formState.nameField.textFieldState + val biographyField = viewModel.formState.biographyField.textFieldState + val nameMissing = viewModel.nameMissing.value + val isActionPending = viewModel.isActionPending.value + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .readableContent() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + EmptyState( + message = stringResource(Res.string.we_could_not_find_a_profile_for_this_key), + icon = Icons.Default.PersonSearch, + action = { + TextButton( + enabled = !isActionPending, + onClick = { viewModel.tryAgain(unsignedNostrEventId) }, + ) { + Text(stringResource(Res.string.try_again)) + } + }, + ) + + Text( + text = stringResource(Res.string.it_may_be_new_or_it_may_live_on_relays_we), + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = stringResource(Res.string.set_up_a_profile), + style = MaterialTheme.typography.titleMedium, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = nameField, + isError = nameMissing, + supportingText = if (nameMissing) { + { Text(text = stringResource(Res.string.enter_a_name_for_the_profile)) } + } else null, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + label = { Text(text = stringResource(Res.string.name_eg_alan_turing), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.enter_the_name_you_want_to_use_for_your_2), maxLines = 1) }, + // The label names the field; the icon only decorates it. + leadingIcon = { Icon(Icons.Default.Badge, contentDescription = Decorative) }, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = biographyField, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + label = { Text(text = stringResource(Res.string.introduce_yourself), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.what_should_people_know_about_you), maxLines = 1) }, + leadingIcon = { Icon(Icons.Default.Description, contentDescription = Decorative) }, + ) + + Button( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = { viewModel.setUpProfile(unsignedNostrEventId) }, + ) { + Text(text = stringResource(Res.string.set_up_a_profile)) + } + } +} + @ConformancePreviews @Composable private fun UnsyncedProfileScreenPreview() { @@ -80,8 +229,9 @@ private fun UnsyncedProfileScreenPreview() { modifier = Modifier.fillMaxSize() ) { UnsyncedProfileScreen( - unsyncedProfilePublicKey = "npub is for living." + unsyncedProfilePublicKey = "a".repeat(64), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 976bc510..7048f9df 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -701,7 +701,8 @@ fun MantraNavHost( composable { backStackEntry -> val route = backStackEntry.toRoute() UnsyncedProfileScreen( - unsyncedProfilePublicKey = route.publicKey + unsyncedProfilePublicKey = route.publicKey, + nostrRepository = databaseNostrRepository, ) } composable { backStackEntry -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index eba82d06..cd4428a0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -50,9 +50,11 @@ import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.NonCancellable +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import press.mantra.compose.nostr.SignInSync import kotlinx.coroutines.sync.withPermit import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -278,6 +280,7 @@ class SynchronizationViewModel( activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) + followSignInHop(it, synchronizeNostrEventRequest) } } } @@ -292,6 +295,7 @@ class SynchronizationViewModel( activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) + followSignInHop(nostrEvent, synchronizeNostrEventRequest) } } is NostrIncomingMessage.EoseMessage -> { @@ -329,6 +333,16 @@ class SynchronizationViewModel( synchronizeNostrEventRequest.relayURL ) } + // And write down that the relay has finished, on every exit + // too. Before this a request that got EOSE with nothing stayed + // at `sent` for good, and "still searching" and "searched, + // found nothing" were the same row -- which is why the screen + // waiting on a sign-in sync could never say the second thing. + // Conditional on `sent` in the DAO: an event handler that has + // already moved the row to `processed` wins. + runCatching { + nostrRepository.synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest) + }.onFailure { logger.e("Failed to record sync completion", it) } } } } @@ -338,6 +352,44 @@ class SynchronizationViewModel( } } + /** + * Sign-in syncs this session has already followed a hop for, by account. Every + * bootstrap relay that holds the user's kind 10002 answers with it, and one hop per + * account is enough. + */ + private val signInHopsFollowed = mutableSetOf() + private val signInHopsMutex = Mutex() + + /** + * The one hop the sign-in sync follows. A level-0 request that brings back the user's + * own kind 10002 is asked again, at level 1, at the relays that list says they write + * to, less the bootstrap set already asked. That is the outbox model doing what it is + * for: the indexers know *where* the user publishes, and the user's own relays are + * where the rest of their lists are authoritative. One hop only -- a level-1 request + * never re-enters here -- because past the user's own relays lies the feed, not the + * profile. + */ + private suspend fun followSignInHop( + nostrEvent: press.mantra.compose.database.model.NostrEvent, + request: press.mantra.compose.database.model.SynchronizeNostrEventRequest, + ) { + if (request.purpose != SignInSync.PURPOSE || request.level != 0) return + if (nostrEvent.kind != AdvertisedRelayListEvent.KIND) return + val unsignedNostrEventId = request.unsignedNostrEventId ?: return + val author = request.synchronizationFilters.firstOrNull()?.authors?.firstOrNull() ?: return + if (!nostrEvent.pubKey.equals(author, ignoreCase = true)) return + + val relays = SignInSync.writeRelaysOf(nostrEvent.tags, alreadyAsked = SignInSync.bootstrapRelays) + if (relays.isEmpty()) return + val isFirst = signInHopsMutex.withLock { signInHopsFollowed.add(unsignedNostrEventId) } + if (!isFirst) return + + logger.i { "Following the sign-in relay list of $author to ${relays.size} relay(s)" } + nostrRepository.queueSynchronizeNostrEvent( + SignInSync.requests(unsignedNostrEventId, author, relays, level = 1) + ) + } + private suspend fun observePendingNegentropySynchronizeRequests( keyPair: KeyPair ): Unit = coroutineScope { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt index 37d95631..e648c10a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt @@ -8,18 +8,11 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import press.mantra.compose.nostr.SignInSync class UnqueuedProfileSynchronizationViewModel( val unsignedNostrEventId: Long, @@ -48,6 +41,11 @@ class UnqueuedProfileSynchronizationViewModel( val isActionPending: MutableState = mutableStateOf(false) + /** + * Asks the bootstrap relays -- the indexers, and ours -- for everything this pubkey + * has published about itself. The relay list that comes back is followed one hop by + * the sync pump; see [SignInSync]. + */ fun queueSynchronization() { logger.d { "queueSynchronization" } viewModelScope.launch(Dispatchers.IO) { @@ -55,30 +53,12 @@ class UnqueuedProfileSynchronizationViewModel( delay(2_000) nostrRepository.queueSynchronizeNostrEvent( - press.mantra.compose.nostr.Relays.DefaultDMRelayList.map { normalizedRelayUrl -> - press.mantra.compose.database.model.SynchronizeNostrEventRequest( - purpose = "sign-in", - synchronizationFilters = arrayOf( - press.mantra.compose.database.model.types.SynchronizationFilter( - authors = arrayOf(profilePublicKey), - kinds = arrayOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - IndexerRelayListEvent.KIND, - ChannelListEvent.KIND, - RelayFeedsListEvent.KIND - ) - ) - ), - unsignedNostrEventId = unsignedNostrEventId, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - + SignInSync.requests( + unsignedNostrEventId = unsignedNostrEventId, + publicKey = profilePublicKey, + relays = SignInSync.bootstrapRelays, + level = 0, + ) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt new file mode 100644 index 00000000..c5f041e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.nostr.SignInSync +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +import press.mantra.compose.ui.view.state.form.CreateProfileFormState + +/** + * The screen that waits for a sign-in sync, and the one decision it makes: whether the + * search is still running or has finished with nothing. + * + * `NavigationViewModel` keeps the route at `UnsyncedProfile` for as long as the account + * has sync requests and no kind 0; it cannot tell the two apart, and until Phase 5 of + * docs/nsec-sign-in.md nothing could. Now every request records when its relay has + * finished answering, and [decide] reads that: no request still in flight, and still + * no profile, is not-found. The two exits from not-found are to ask again and to set a + * profile up for the key that is already here. + */ +class UnsyncedProfileViewModel( + private val publicKey: HexKey, + private val nostrRepository: NostrRepository, + val formState: CreateProfileFormState = CreateProfileFormState(), +) : ViewModel() { + + companion object { + private const val TAG = "UnsyncedProfileViewModel" + + fun factory( + publicKey: HexKey, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + UnsyncedProfileViewModel( + publicKey = publicKey, + nostrRepository = nostrRepository, + ) + } + } + + /** + * What the account says. A kind 0 indexed or a Profile row means the navigation + * machine is about to move on, so that is still "searching" from here; a request + * still `pending` or `sent` is a relay that has not finished. Only when every + * request has finished and nothing came is the answer not-found. + */ + fun decide(account: LocalAccount?): UnsyncedProfileUIState { + val unsignedNostrEvent = account?.unsignedNostrEvent ?: return UnsyncedProfileUIState.Searching + if (account.profile != null || account.nostrEvent != null) return UnsyncedProfileUIState.Searching + val requests = account.synchronizeNostrEventRequests + if (requests.isEmpty()) return UnsyncedProfileUIState.Searching + if (requests.any { it.status in SignInSync.IN_FLIGHT_STATUSES }) return UnsyncedProfileUIState.Searching + return UnsyncedProfileUIState.NotFound(unsignedNostrEventId = unsignedNostrEvent.id) + } + } + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** True after a submit with no name; the field shows why. */ + val nameMissing: MutableState = mutableStateOf(false) + + val uiState: StateFlow = accountFlow() + .map { decide(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UnsyncedProfileUIState.Searching) + + private fun accountFlow(): Flow = flow { + nostrRepository.observeLocalAccount(publicKey).collect { emit(it) } + } + + /** Asks the bootstrap relays again. The new requests are in flight, so the screen goes back to searching on its own. */ + fun tryAgain(unsignedNostrEventId: Long) { + if (isActionPending.value) return + isActionPending.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.queueSynchronizeNostrEvent( + SignInSync.requests( + unsignedNostrEventId = unsignedNostrEventId, + publicKey = publicKey, + relays = SignInSync.bootstrapRelays, + level = 0, + ) + ) + } catch (e: Exception) { + logger.e("could not queue the sign-in sync again", e) + } finally { + isActionPending.value = false + } + } + } + + /** + * Gives the key a profile. The rows change under `observeLocalAccount`, the + * navigation machine sees an unsigned kind 0 and routes to the create flow's own + * screens, and the notary -- which already holds this key -- signs it. + */ + fun setUpProfile(unsignedNostrEventId: Long) { + if (isActionPending.value) return + val name = formState.nameField.textFieldState.text.toString().trim() + if (name.isBlank()) { + nameMissing.value = true + return + } + nameMissing.value = false + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.setUpProfileForExistingKey( + publicKey = publicKey, + placeholderUnsignedNostrEventId = unsignedNostrEventId, + name = name, + biography = formState.biographyField.textFieldState.text.toString().trim().ifBlank { null }, + ) + } catch (e: Exception) { + logger.e("could not set up a profile for the key", e) + isActionPending.value = false + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt new file mode 100644 index 00000000..1c908cf1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.view.state + +/** What the screen waiting on a sign-in sync is showing. */ +sealed interface UnsyncedProfileUIState { + /** At least one relay has not finished answering, or something has arrived and the route is about to move. */ + data object Searching : UnsyncedProfileUIState + + /** Every relay asked has finished, and none had a profile for this key. */ + data class NotFound(val unsignedNostrEventId: Long) : UnsyncedProfileUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt new file mode 100644 index 00000000..9db84af3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt @@ -0,0 +1,76 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Whom the sign-in sync asks, and how it reads a relay list to ask further. + * + * The bootstrap set is the load-bearing change of Phase 5: it used to be one relay, ours, + * which is the right answer only for a profile this app made. The hop test pins the + * outbox reading -- write relays, not read relays -- because asking a user's *read* + * relays for their own events is the mistake the inbox/outbox split exists to name. + */ +class SignInSyncTest { + + private val publicKey = "a".repeat(64) + private fun relay(url: String) = RelayUrlNormalizer.normalize(url) + + @Test + fun `the bootstrap asks every indexer and our own relay, once each`() { + val bootstrap = SignInSync.bootstrapRelays + + assertTrue(bootstrap.containsAll(Relays.DefaultIndexerRelayList), "the indexers hold everyone's kinds 0, 3 and 10002") + assertTrue(Relays.ephemeral in bootstrap, "a profile created here is found here") + assertEquals(bootstrap.size, bootstrap.toSet().size, "no relay asked twice") + assertTrue(bootstrap.size > 1, "one relay was the whole problem") + } + + @Test + fun `one request per relay, all asking for the same things about the same key`() { + val relays = listOf(relay("wss://one.example"), relay("wss://two.example")) + + val requests = SignInSync.requests(unsignedNostrEventId = 7, publicKey = publicKey, relays = relays, level = 1) + + assertEquals(relays.map { it.url }, requests.map { it.relayURL }) + requests.forEach { request -> + assertEquals(SignInSync.PURPOSE, request.purpose) + assertEquals(1, request.level) + assertEquals(7L, request.unsignedNostrEventId) + assertEquals("pending", request.status) + val filter = request.synchronizationFilters.single() + assertEquals(listOf(publicKey), filter.authors?.toList()) + assertEquals(SignInSync.KINDS.toList(), filter.kinds?.toList()) + } + } + + @Test + fun `a relay list yields its write relays, less those already asked`() { + val tags = arrayOf( + arrayOf("r", "wss://write.example", "write"), + arrayOf("r", "wss://both.example"), + arrayOf("r", "wss://read.example", "read"), + arrayOf("r", "wss://asked.example"), + arrayOf("r", "wss://both.example"), // duplicate + arrayOf("r", "not a url"), // dropped by the normaliser + arrayOf("p", "wss://tagged-wrong.example"), // not an r tag + arrayOf("r"), // no url + ) + + val relays = SignInSync.writeRelaysOf(tags, alreadyAsked = listOf(relay("wss://asked.example"))) + + assertEquals( + listOf(relay("wss://write.example"), relay("wss://both.example")), + relays, + ) + } + + @Test + fun `a relay list with only read relays yields nothing to follow`() { + val tags = arrayOf(arrayOf("r", "wss://read.example", "read")) + + assertEquals(emptyList(), SignInSync.writeRelaysOf(tags, alreadyAsked = emptyList())) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt new file mode 100644 index 00000000..c543e8f4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt @@ -0,0 +1,124 @@ +package press.mantra.compose.ui.view.model + +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.SignInSync +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * When "searching" becomes "not found". + * + * A request is `pending` when queued, `sent` when its REQ is out, `processed` if an + * event came back for it, and -- since Phase 5 -- `complete` if its relay finished with + * nothing. Only the first two mean a relay has not answered yet, and only when none is + * in either state, and still no kind 0 has arrived, may the screen say the search is + * over. Anything else is the wrong message at the wrong time: too early and the user + * sets up a second profile while the first is on its way; never and they sit under a + * spinner for good, which is what the screen did before. + */ +class UnsyncedProfileDecisionTest { + + private val publicKey = "a".repeat(64) + private val at = Instant.fromEpochSeconds(1_700_000_000) + + private val placeholder = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = GENESIS_AT, + createdAt = at, + updatedAt = at, + savedAt = at, + ) + + private fun request(status: String, relay: String) = SynchronizeNostrEventRequest( + id = "req-$relay", + purpose = SignInSync.PURPOSE, + status = status, + relayURL = "wss://$relay.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf(publicKey), kinds = SignInSync.KINDS)), + unsignedNostrEventId = 1, + createdAt = at, + updatedAt = at, + ) + + private fun account( + vararg requests: SynchronizeNostrEventRequest, + profile: Profile? = null, + nostrEvent: NostrEvent? = null, + ) = LocalAccount( + unsignedNostrEvent = placeholder, + nostrEvent = nostrEvent, + profile = profile, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = requests.toList(), + ) + + @Test + fun `the answer flips only when the last relay has finished`() { + val sequence = listOf( + account(request("pending", "one"), request("pending", "two")), + account(request("sent", "one"), request("pending", "two")), + account(request("sent", "one"), request("sent", "two")), + account(request("complete", "one"), request("sent", "two")), + account(request("complete", "one"), request("complete", "two")), + ) + + val decisions = sequence.map { UnsyncedProfileViewModel.decide(it) } + + assertEquals( + listOf( + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.NotFound(unsignedNostrEventId = 1), + ), + decisions, + ) + } + + /** An event came back for a request -- a contact list, say -- but still no kind 0: the search is still over. */ + @Test + fun `a relay that answered with something other than a profile still counts as finished`() { + val decision = UnsyncedProfileViewModel.decide(account(request("processed", "one"), request("complete", "two"))) + + assertEquals(UnsyncedProfileUIState.NotFound(unsignedNostrEventId = 1), decision) + } + + @Test + fun `a kind 0 that has arrived, indexed or not, is never not-found`() { + val event = NostrEvent( + id = "e".repeat(64), + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + createdAt = at, + sig = "f".repeat(128), + unsignedNostrEventId = 1, + ) + val profile = Profile(publicKey = publicKey, nostrEventId = event.id, createdAt = at, updatedAt = at, savedAt = at) + + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account(request("complete", "one"), nostrEvent = event))) + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account(request("complete", "one"), profile = profile))) + } + + @Test + fun `no account, or no request yet, is still searching`() { + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(null)) + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account())) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt new file mode 100644 index 00000000..04e60efa --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt @@ -0,0 +1,74 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.SignInSync +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * `markCompleteIfSent` is conditional in SQL for a reason worth pinning: the handler + * that moves a request to `processed` when an event arrives runs in its own coroutine + * and can land after the subscription has closed. A completion that overwrote it would + * turn "found" back into "found nothing". + */ +class SynchronizeNostrEventRequestCompletionJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val at = Instant.fromEpochSeconds(1_700_000_000) + private val later = Instant.fromEpochSeconds(1_700_000_100) + + private suspend fun placeholder(): Long = db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = "a".repeat(64), kind = 0, tags = emptyArray(), content = "{}", createdAt = at) + ) + + private fun request(id: String, status: String, unsignedNostrEventId: Long) = SynchronizeNostrEventRequest( + id = id, + purpose = SignInSync.PURPOSE, + status = status, + relayURL = "wss://$id.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf("a".repeat(64)), kinds = SignInSync.KINDS)), + unsignedNostrEventId = unsignedNostrEventId, + createdAt = at, + updatedAt = at, + ) + + private suspend fun statusOf(unsignedNostrEventId: Long, id: String): String? = + db.unsignedNostrEventDao().observeProfile("a".repeat(64)).first() + ?.synchronizeNostrEventRequests?.firstOrNull { it.id == id }?.status + + @Test + fun `a sent request becomes complete, a processed or pending one is left alone`() = runBlocking { + val accountId = placeholder() + val dao = db.synchronizeNostrEventRequestDao() + dao.insert( + listOf( + request("sent", "sent", accountId), + request("processed", "processed", accountId), + request("pending", "pending", accountId), + ) + ) + + val changed = listOf("sent", "processed", "pending").sumOf { dao.markCompleteIfSent(it, SignInSync.STATUS_COMPLETE, later) } + + assertEquals(1, changed) + assertEquals(SignInSync.STATUS_COMPLETE, statusOf(accountId, "sent")) + assertEquals("processed", statusOf(accountId, "processed")) + assertEquals("pending", statusOf(accountId, "pending")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt new file mode 100644 index 00000000..7aff683c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt @@ -0,0 +1,70 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The exit from a sign-in that found nothing, at the rows. + * + * `signInToProfile` plants one kind-0 row per signed-in key, stamped `GENESIS_AT`, and + * `getLocalAccounts` is *every* kind-0 row. So giving the key a profile must write over + * that row, not beside it: two kind-0 rows for one pubkey would be two accounts, and + * `observeLocalAccount` would answer with whichever the query happened to return. + */ +class SetUpProfileForExistingKeyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val publicKey = "a".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `the placeholder is written over, once, and the rest of the bootstrap arrives beside it`() = runBlocking { + repository.signInToProfile(publicKey) + val placeholder = repository.getLocalAccounts().single().unsignedNostrEvent + assertNotNull(placeholder) + assertEquals(GENESIS_AT, placeholder.signedAt) + + repository.setUpProfileForExistingKey( + publicKey = publicKey, + placeholderUnsignedNostrEventId = placeholder.id, + name = "Alan", + biography = null, + ) + + val accounts = repository.getLocalAccounts() + assertEquals(1, accounts.size, "one kind-0 row for the key, still") + val profile = accounts.single().unsignedNostrEvent + assertNotNull(profile) + assertEquals(placeholder.id, profile.id, "the same row") + assertNull(profile.signedAt, "unsigned again, so the notary signs it") + assertTrue(profile.content.contains("Alan"), "carrying the profile the user typed") + + // The five other bootstrap events, queued for the notary alongside it. + val queued = db.unsignedNostrEventDao().observeUnsignedNostrEvents(publicKey).first() + assertEquals(6, queued.size) + assertEquals(listOf(0), queued.filter { it.kind == 0 }.map { it.kind }) + } +}