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 <noreply@anthropic.com>
Pulled-From: curated/curated@d61d3560a0
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-13 12:00:19 +02:00
1 parent 2e31daf117
commit 347cf26d83
15 files changed
+940 -78

No files matched your search

@@ -590,4 +590,9 @@
<string name="twelve_words_or_nsec1">Twelve words, or nsec1…</string>
<string name="use_a_different_key">Use a different key</string>
<string name="you_are_signing_in_as">You are signing in as</string>
<!-- Sign in: the search that found nothing, docs/nsec-sign-in.md, phase 5. -->
<string name="enter_a_name_for_the_profile">Enter a name for the profile.</string>
<string name="it_may_be_new_or_it_may_live_on_relays_we">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.</string>
<string name="set_up_a_profile">Set up a profile</string>
<string name="we_could_not_find_a_profile_for_this_key">We could not find a profile for this key on the relays we asked.</string>
</resources>
@@ -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<press.mantra.compose.database.model.SynchronizeNostrEventRequest>)
}
@@ -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<UnsignedNostrEvent> {
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(
@@ -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<NormalizedRelayUrl> =
(Relays.DefaultIndexerRelayList + Relays.ephemeral).distinct()
fun requests(
unsignedNostrEventId: Long,
publicKey: HexKey,
relays: Collection<NormalizedRelayUrl>,
level: Int,
): List<SynchronizeNostrEventRequest> = 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<Array<String>>, alreadyAsked: Collection<NormalizedRelayUrl>): List<NormalizedRelayUrl> =
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()
}
@@ -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<HexKey>,
@@ -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<HexKey>,
@@ -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")
}
@@ -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,
)
}
}
}
}
@@ -701,7 +701,8 @@ fun MantraNavHost(
composable<UnsyncedProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<UnsyncedProfileRoute>()
UnsyncedProfileScreen(
unsyncedProfilePublicKey = route.publicKey
unsyncedProfilePublicKey = route.publicKey,
nostrRepository = databaseNostrRepository,
)
}
composable<UnqueuedProfileSynchronizationRoute> { backStackEntry ->
@@ -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<Long>()
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 {
@@ -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<Boolean> = 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,
)
)
}
@@ -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<Boolean> = mutableStateOf(false)
/** True after a submit with no name; the field shows why. */
val nameMissing: MutableState<Boolean> = mutableStateOf(false)
val uiState: StateFlow<UnsyncedProfileUIState> = accountFlow()
.map { decide(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UnsyncedProfileUIState.Searching)
private fun accountFlow(): Flow<LocalAccount?> = 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
}
}
}
}
@@ -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
}