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