From 347cf26d83ffdd8d57cbdf80a1e88ac95e69b1b3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 12:16:06 +0200 Subject: [PATCH] feat(sign-in): ask the relays that would know, and say when none of them did Phase 5 of docs/nsec-sign-in.md. Not nsec-specific: a restored recovery phrase whose profile was never published, or a device offline at sign-in, sat on the same spinner. An imported nsec is the first path that hits it routinely -- many nostr keys are made in a client that never wrote a kind 0. Ask the relays that would know. The sign-in sync fanned its REQ out over Relays.DefaultDMRelayList, which is listOf(ephemeral): our own relay, alone. The right answer for a profile this app created; an identity that has lived on Damus for three years has never heard of it. SignInSync now holds the one definition three call sites want -- the kinds, the request builder and the bootstrap set, which is every indexer relay plus ours. Indexer relays exist to hold everyone's kinds 0, 3 and 10002; that is exactly what the sync asks for. Then follow the answer, once. When a level-0 sign-in request brings back the user's own kind 10002, the sync pump queues the same request at level 1 at the relays that list says they write to, less the bootstrap set already asked. 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. Write relays, not read relays -- asking a user's inbox for their own events is the mistake the split exists to name. One hop per account per session, because every bootstrap relay that holds the list answers with it; and a level-1 request never re-enters, because past the user's own relays lies the feed, not the profile. Say when none of them did. A request went pending -> sent when its REQ was dispatched and sent -> processed only if an event arrived for it; a relay that answered with EOSE and nothing else left its row at `sent` for good, so "still searching" and "searched, found nothing" were the same row and the screen waiting on the sync had no way to say the second thing. The pump's finally block -- every exit: EOSE, CLOSED, the bounded timeout -- now records `complete`, conditionally in SQL on the row still being `sent`, because the event handler that writes `processed` runs in its own coroutine and can land after the subscription has closed, and a completion that overwrote it would turn "found" back into "found nothing". UnsyncedProfileViewModel reads that. Its one decision, `decide`, is a pure function of the account: a kind 0 indexed or a Profile row means the route is about to move, so still searching; any 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. The screen's not-found state offers two exits. Try again re-queues the bootstrap, and the new in-flight requests put the screen back to searching on their own. Set up a profile is the six-event bootstrap a fresh key gets, without a fresh key: setUpProfileForExistingKey writes the kind 0 *over* the placeholder row, under its own id with signedAt back to null, rather than beside it -- getLocalAccounts is every kind-0 row, and two for one pubkey would be two accounts disagreeing about which is this one. The rows change under observeLocalAccount, NavigationViewModel sees an unsigned kind 0, and the notary, which already holds this key, signs it. createNewProfile now builds its events through the same bootstrapUnsignedEvents. Tests. SignInSyncTest pins the bootstrap set (every indexer, ours, no duplicates, more than one) and the outbox reading of a relay list: write and unmarked relays in, read relays and malformed tags out, already-asked removed. UnsyncedProfileDecisionTest walks pending -> sent -> complete and asserts the answer flips only on the last, that a request answered with something other than a profile still counts as finished, that an arrived kind 0 is never not-found. SynchronizeNostrEventRequestCompletionJvmTest pins the conditional update against a real Room database: sent becomes complete, processed and pending are left alone. SetUpProfileForExistingKeyJvmTest runs signInToProfile then the set-up against the repository and asserts one kind-0 row, the same id, unsigned, carrying the name, with five events beside it. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (895 tests) and :composeApp:m3Audit. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@d61d3560a090c35b8002fa73d0e24b863002e33e --- .../composeResources/values/strings.xml | 5 + .../dao/SynchronizeNostrEventRequestDao.kt | 9 + .../repository/DatabaseNostrRepository.kt | 52 +++- .../press/mantra/compose/nostr/SignInSync.kt | 91 +++++++ .../compose/repository/NostrRepository.kt | 33 +++ .../ui/composable/UnsyncedProfileScreen.kt | 234 ++++++++++++++---- .../ui/composable/navigation/MantraNavHost.kt | 3 +- .../ui/view/model/SynchronizationViewModel.kt | 52 ++++ ...UnqueuedProfileSynchronizationViewModel.kt | 44 +--- .../ui/view/model/UnsyncedProfileViewModel.kt | 141 +++++++++++ .../ui/view/state/UnsyncedProfileUIState.kt | 10 + .../mantra/compose/nostr/SignInSyncTest.kt | 76 ++++++ .../view/model/UnsyncedProfileDecisionTest.kt | 124 ++++++++++ ...onizeNostrEventRequestCompletionJvmTest.kt | 74 ++++++ .../SetUpProfileForExistingKeyJvmTest.kt | 70 ++++++ 15 files changed, 940 insertions(+), 78 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt 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 }) + } +}