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:
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>
|
||||
+9
@@ -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>)
|
||||
}
|
||||
+49
-3
@@ -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")
|
||||
}
|
||||
|
||||
+192
-42
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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 ->
|
||||
|
||||
+52
@@ -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 {
|
||||
|
||||
+12
-32
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+141
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user