diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt index f7d1ba98..ecaa861c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt @@ -24,6 +24,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRout import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.database.GENESIS_AT import kotlin.time.Clock import kotlin.time.Instant import press.mantra.compose.ui.theme.ConformancePreviews @@ -72,6 +73,14 @@ data class Profile( press.mantra.compose.database.model.traits.UserViewableEntity, press.mantra.compose.database.model.traits.BroadcastableEntity, press.mantra.compose.database.model.traits.SoftDeletableEntity { + /** + * Read off a kind 0, as against the "LOADING..." placeholder minted when a pubkey + * is first seen (`NostrDao.indexNostrEvent`), which is stamped [GENESIS_AT] so that + * the two can be told apart. A missing row and a placeholder row mean the same + * thing to a reader: nobody has read a kind 0 for that pubkey. + */ + fun isResolved(): Boolean = createdAt > GENESIS_AT + fun humanReadableNameOrPubkey(): String { return if (!displayName.isNullOrBlank()) { displayName diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt new file mode 100644 index 00000000..a77c4819 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt @@ -0,0 +1,96 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * Asking the relays for what a direct message with one person needs. + * + * Two asks, because two sets of relays hold two different things. The DM relays + * hold the key package and the DM relay list, and the kind 0 of anyone who has + * published one here; the indexer relays hold everyone's kind 0 and nothing else a + * chat needs. A three-kind filter to the indexers comes back with the kind 0 alone; + * a kind-0-only filter to our relay finds a person who has never opened Mantra + * nowhere. + * + * Both at level 0, for the reason `MemberProfileSync` gives: it marks a request as + * one somebody is waiting on, and a kind 0 arriving at level 0 over a placeholder + * queues the rest of that person's profile kinds off the back of it + * (`NostrDao.indexNostrEvent`). From an indexer that follow-up asks the indexer for + * key packages it does not hold, which is harmless and is why [requests] is queued + * explicitly rather than relied on. + * + * See docs/npub-profile-preview.md. + */ +object DirectMessagePeerSync { + /** The purpose the chat screen has queued under since it was written; kept so nothing reading the queue by purpose changes. */ + const val PURPOSE = "initiate-chat" + + /** The kind 0 on its own, for the preview that shows a person before a chat is started. */ + const val PROFILE_PURPOSE = "profile-preview" + + /** + * What a chat needs: the peer's metadata, their MLS key package and their DM + * relay list. + * + * No `limit` on the filter built from these: it is ignored on the negentropy path + * but honoured on the REQ fallback, where `limit = 1` returned a single newest + * event across all three kinds -- almost always the kind 0 -- so the key package + * never arrived. + */ + val KINDS = arrayOf( + MetadataEvent.KIND, + KeyPackageEvent.KIND, + ChatMessageRelayListEvent.KIND, + ) + + /** + * One negentropy request per DM relay for the three kinds, authored by + * [peerPublicKey]. The id is `computeId`'s, bucketed by minute, so a retry inside + * the same minute re-arms the row rather than adding one. + */ + fun requests(peerPublicKey: HexKey): List { + val synchronizationFilter = SynchronizationFilter( + kinds = KINDS, + authors = arrayOf(peerPublicKey), + ) + + return Relays.DefaultDMRelayList.map { relay -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = relay.url, + synchronizationFilter = synchronizationFilter, + ), + purpose = PURPOSE, + synchronizationFilter = synchronizationFilter, + relayURL = relay.url, + level = 0, + ) + } + } + + /** + * The kind 0 alone, as a REQ to `SignInSync.bootstrapRelays` -- the indexers plus + * our own -- which is where a key pasted from elsewhere has lived. + */ + fun profileRequests(peerPublicKey: HexKey): List { + val synchronizationFilter = SynchronizationFilter( + kinds = arrayOf(MetadataEvent.KIND), + authors = arrayOf(peerPublicKey), + ) + + return SignInSync.bootstrapRelays.map { relay -> + SynchronizeNostrEventRequest( + purpose = PROFILE_PURPOSE, + synchronizationFilters = arrayOf(synchronizationFilter), + relayURL = relay.url, + level = 0, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt index f245e9a6..01b40b9d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt @@ -2,7 +2,6 @@ package press.mantra.compose.nostr import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter @@ -11,7 +10,7 @@ import press.mantra.compose.database.model.types.SynchronizationFilter * Asking the relays who a room's members are, for the members this device cannot name. * * A pubkey gets a Profile row the moment it is first seen, and that row is a - * placeholder: the "LOADING..." display name, stamped [GENESIS_AT] so it is + * placeholder: the "LOADING..." display name, stamped `GENESIS_AT` so it is * distinguishable from a profile actually read off a kind:0. What replaces it is a * kind:0, and every path that mints a placeholder asks for one -- exactly once, on * whichever relay that pubkey happened to arrive on. @@ -55,7 +54,7 @@ object MemberProfileSync { ): List = rooms .flatMap { it.localParticipants } - .filter { participant -> participant.profile?.let { it.createdAt > GENESIS_AT } != true } + .filter { participant -> participant.profile?.isResolved() != true } .map { it.participant.participantPublicKey } .filter { it != activeUserPublicKey } .distinct() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt index 07937d03..37fce883 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt @@ -9,20 +9,14 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import press.mantra.compose.database.GENESIS_AT -import press.mantra.compose.database.model.NegentropySynchronizeRequest -import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.DirectMessagePeerSync import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay @@ -133,7 +127,7 @@ class ChatRoomMessagingViewModel( ) { profile, keyPackage -> profile to keyPackage } .distinctUntilChanged() .collect { (profile, keyPackageEvent) -> - val profileIsResolved = profile != null && profile.createdAt > GENESIS_AT + val profileIsResolved = profile?.isResolved() == true if (!profileIsResolved || keyPackageEvent == null) { // Whatever is missing, ask the relays for it. This used to be queued @@ -177,35 +171,13 @@ class ChatRoomMessagingViewModel( /** * Ask the DM relays for everything needed to start a chat with [chatRoomId] (a pubkey here): - * the peer's metadata, their MLS key package and their DM relay list. + * the peer's metadata, their MLS key package and their DM relay list. The filter and + * the reasons behind it live in `DirectMessagePeerSync`, which the profile preview + * queues too. */ private suspend fun scheduleProfileAndKeyPackageSync() { - val synchronizationFilter = SynchronizationFilter( - kinds = arrayOf( - MetadataEvent.KIND, - KeyPackageEvent.KIND, - ChatMessageRelayListEvent.KIND - ), - authors = arrayOf( - chatRoomId - ) - // No `limit`: it is ignored on the negentropy path but honoured on the REQ - // fallback, where `limit = 1` returned a single newest event across all three - // kinds — almost always the kind-0 — so the key package never arrived. - ) nostrRepository.queueNegentropySynchronizeRequest( - Relays.DefaultDMRelayList.map { relay -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relay.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "initiate-chat", - synchronizationFilter = synchronizationFilter, - relayURL = relay.url, - level = 0 - ) - } + DirectMessagePeerSync.requests(chatRoomId) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt new file mode 100644 index 00000000..f7b299a5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt @@ -0,0 +1,133 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Finds the person behind a pasted npub, and says when it has stopped looking. + * + * Cached first: the observed row is whatever the device already holds, so a person + * already known is on screen before any relay answers, and the relays refresh them + * behind it. The relays are asked twice over -- the kind 0 of the indexers, which hold + * everyone's, and the three kinds a chat needs of the DM relays -- through + * [DirectMessagePeerSync], so that pressing the button on the screen after this one + * finds the key package already here. + * + * The timeout is the one both DM view models use, and it only changes what is shown: + * the collect stays live past it, so a kind 0 that arrives on the twenty-first second + * still loads. See docs/npub-profile-preview.md, Phase 1. + */ +class ProfilePreviewViewModel( + val activeUserPublicKey: HexKey, + val profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState, + private val nostrRepository: NostrRepository, + /** How long the relays get before the screen says it came up empty. A parameter so a test need not wait it out. */ + private val relayLookupTimeout: Duration = RELAY_LOOKUP_TIMEOUT, +) : ViewModel() { + + var profilePreviewUIState: ProfilePreviewUIState by mutableStateOf(initialProfilePreviewUIState) + private set + + private val logger = Logger.withTag(TAG) + + private var started = false + private var lookupTimeout: Job? = null + + /** Once. A recomposition that calls this again must not open a second collect. */ + fun initiate() { + if (started) return + started = true + observeProfile() + askTheRelays() + } + + /** Asks again. The observation is still live, so the answer arrives through the same collect. */ + fun retry() { + profilePreviewUIState = ProfilePreviewUIState.Loading + askTheRelays() + } + + private fun observeProfile() { + viewModelScope.launch(Dispatchers.IO) { + nostrRepository.observeProfileWithPublicKey(profilePublicKey) + // A placeholder is not a person. Mapping it to null here is what keeps + // "LOADING..." off a screen whose one job is to say who this is. + .map { profile -> profile?.takeIf { it.isResolved() } } + .distinctUntilChanged() + .collect { profile -> + if (profile == null) return@collect + lookupTimeout?.cancel() + profilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile) + } + } + } + + private fun askTheRelays() { + lookupTimeout?.cancel() + lookupTimeout = viewModelScope.launch(Dispatchers.IO) { + delay(relayLookupTimeout) + if (profilePreviewUIState is ProfilePreviewUIState.Loading) { + profilePreviewUIState = ProfilePreviewUIState.NotFound + } + } + + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.queueSynchronizeNostrEvent( + DirectMessagePeerSync.profileRequests(profilePublicKey) + ) + nostrRepository.queueNegentropySynchronizeRequest( + DirectMessagePeerSync.requests(profilePublicKey) + ) + } catch (e: Exception) { + logger.e("could not ask the relays about $profilePublicKey", e) + lookupTimeout?.cancel() + profilePreviewUIState = ProfilePreviewUIState.Error + } + } + } + + companion object { + private const val TAG = "ProfilePreviewViewModel" + + /** How long to wait on the relays before telling the user we came up empty. */ + val RELAY_LOOKUP_TIMEOUT = 20.seconds + + fun factory( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProfilePreviewViewModel( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = initialProfilePreviewUIState, + nostrRepository = nostrRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt new file mode 100644 index 00000000..73942a91 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt @@ -0,0 +1,27 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.Profile + +/** + * The person behind a pasted npub, before a chat with them is started. + * + * `Loaded` needs a profile read off a kind 0 -- `Profile.isResolved()` -- and never + * the "LOADING..." placeholder a pubkey gets on first sight, because the screen's one + * job is to show who this is. See docs/npub-profile-preview.md. + */ +sealed interface ProfilePreviewUIState { + data object Loading : ProfilePreviewUIState + + data class Loaded( + val profile: Profile, + ) : ProfilePreviewUIState + + /** + * Every relay asked has had its time, and no kind 0 came. Not an error: a search + * that found nothing, and one a late answer can still overturn. + */ + data object NotFound : ProfilePreviewUIState + + /** The queue itself refused: nothing was asked, and asking again is the retry. */ + data object Error : ProfilePreviewUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt new file mode 100644 index 00000000..1cafb423 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt @@ -0,0 +1,90 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What a direct message with one person asks the relays for, and whom it asks. + * + * Two request lists because two sets of relays hold two different things; the test + * pins each list's shape and the one relay the two sets share, so that a change to + * either relay set is noticed here rather than as a chat that never starts. + */ +class DirectMessagePeerSyncTest { + + private val alice = "b".repeat(64) + + // --- the DM relays: everything a chat needs ---------------------------------------- + + @Test + fun `one request per DM relay, asking for the three kinds a chat needs`() { + val requests = DirectMessagePeerSync.requests(alice) + + assertEquals(Relays.DefaultDMRelayList.size, requests.size) + assertEquals(Relays.DefaultDMRelayList.map { it.url }.sorted(), requests.map { it.relayURL }.sorted()) + + requests.forEach { request -> + assertEquals(DirectMessagePeerSync.PURPOSE, request.purpose) + // Level 0: somebody is waiting on this one. + assertEquals(0, request.level) + assertEquals( + listOf(MetadataEvent.KIND, KeyPackageEvent.KIND, ChatMessageRelayListEvent.KIND), + request.synchronizationFilter.kinds?.toList(), + ) + assertEquals(listOf(alice), request.synchronizationFilter.authors?.toList()) + // `limit = 1` across three kinds returned the kind 0 alone on the REQ fallback, + // and the key package never arrived. + assertNull(request.synchronizationFilter.limit) + } + } + + /** `computeId` buckets by minute, and the id is what lets a retry re-arm the row rather than add one. */ + @Test + fun `the id is the one computeId gives the relay and the filter`() { + DirectMessagePeerSync.requests(alice).forEach { request -> + assertEquals( + NegentropySynchronizeRequest.computeId(request.relayURL, request.synchronizationFilter), + request.id, + ) + } + } + + // --- the indexers: the kind 0 alone ------------------------------------------------- + + @Test + fun `one request per bootstrap relay, asking for the kind 0 alone`() { + val requests = DirectMessagePeerSync.profileRequests(alice) + + assertEquals(SignInSync.bootstrapRelays.size, requests.size) + assertEquals(SignInSync.bootstrapRelays.map { it.url }.sorted(), requests.map { it.relayURL }.sorted()) + + requests.forEach { request -> + assertEquals(DirectMessagePeerSync.PROFILE_PURPOSE, request.purpose) + assertEquals(0, request.level) + // A peer has no unsigned event of ours to hang off, unlike a sign-in. + assertNull(request.unsignedNostrEventId) + + val filter = request.synchronizationFilters.single() + assertEquals(listOf(MetadataEvent.KIND), filter.kinds?.toList()) + assertEquals(listOf(alice), filter.authors?.toList()) + } + } + + /** + * Our own relay is the one place both lists ask, on purpose: a profile created here + * is found here, and a chat can only be started here. Anything else the two share + * is a relay set that has drifted. + */ + @Test + fun `the two relay sets share our own relay and nothing else`() { + val dmRelays = DirectMessagePeerSync.requests(alice).map { it.relayURL }.toSet() + val profileRelays = DirectMessagePeerSync.profileRequests(alice).map { it.relayURL }.toSet() + + assertEquals(setOf(Relays.ephemeral.url), dmRelays intersect profileRelays) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt new file mode 100644 index 00000000..a319d8c3 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt @@ -0,0 +1,214 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * What the preview shows while the relays are asked, and when it stops waiting. + * + * The repository fake records every ask and holds the profile row in a + * `MutableStateFlow`, so the test can play the row arriving before the ask, after + * it, after the timeout, or never. The view model launches on `Dispatchers.IO`, as + * the others do, so the timeout is real time cut to a few hundred milliseconds + * through its constructor, and the assertions poll. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfilePreviewViewModelJvmTest { + + private val me = "a".repeat(64) + private val alice = "b".repeat(64) + + /** Records the asks; the profile flow is the row the device holds. */ + private class Recorder( + val profile: MutableStateFlow = MutableStateFlow(null), + private val refuse: Boolean = false, + ) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = profile + + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { + if (refuse) throw IllegalStateException("the queue is closed") + effects += "req:" + synchronizeNostrEventRequests.map { it.purpose }.distinct().joinToString() + } + + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { + if (refuse) throw IllegalStateException("the queue is closed") + effects += "neg:" + negentropySynchronizeRequests.map { it.purpose }.distinct().joinToString() + } + } + + private val bothAsks = listOf("req:${DirectMessagePeerSync.PROFILE_PURPOSE}", "neg:${DirectMessagePeerSync.PURPOSE}") + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(recorder: Recorder) = ProfilePreviewViewModel( + activeUserPublicKey = me, + profilePublicKey = alice, + initialProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository = recorder, + relayLookupTimeout = 300.milliseconds, + ) + + @Test + fun `initiating asks both sets of relays once, and waits`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + + assertEquals(bothAsks, recorder.effects) + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + + // A recomposition calling it again opens no second collect and asks nothing more. + viewModel.initiate() + delay(50) + assertEquals(bothAsks, recorder.effects) + } + + @Test + fun `a placeholder row is not a person`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(placeholder(alice))) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + delay(50) + + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + } + + /** Cached first: a person already known is on screen before any relay answers. */ + @Test + fun `a row already resolved loads at once, and the relays are still asked`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + + assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState) + await { recorder.effects.size == 2 } + assertEquals(bothAsks, recorder.effects) + } + + @Test + fun `a kind 0 arriving from the relays loads`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + recorder.profile.value = placeholder(alice) + recorder.profile.value = resolved(alice) + + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState) + + // Loaded is loaded: the timeout, had it still been armed, must not take it back. + delay(400) + assertIs(viewModel.profilePreviewUIState) + } + + @Test + fun `nothing arriving is not found, and a late kind 0 still loads`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + + recorder.profile.value = resolved(alice) + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + } + + @Test + fun `try again from not found asks both sets of relays again`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + + viewModel.retry() + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + await { recorder.effects.size == 4 } + assertEquals(bothAsks + bothAsks, recorder.effects) + + // And the clock was restarted with it. + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + } + + @Test + fun `a queue that refuses is an error, and try again asks again`() = runBlocking { + val recorder = Recorder(refuse = true) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error } + + // The timeout was cancelled with it: an error does not later become "not found". + delay(400) + assertEquals(ProfilePreviewUIState.Error, viewModel.profilePreviewUIState) + + viewModel.retry() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error } + } + + // --- fixtures ----------------------------------------------------------------------- + + private suspend fun await(condition: () -> Boolean) { + withTimeout(5_000) { + while (!condition()) delay(10) + } + } + + /** What every mint site writes: the "LOADING..." name, stamped `GENESIS_AT`. */ + private fun placeholder(publicKey: HexKey) = Profile( + publicKey = publicKey, + displayName = "LOADING...", + nostrEventId = "placeholder", + createdAt = GENESIS_AT, + ) + + private fun resolved(publicKey: HexKey) = Profile( + publicKey = publicKey, + displayName = "Alice", + nostrEventId = "kind0", + createdAt = Instant.fromEpochSeconds(1_700_000_000), + updatedAt = Instant.fromEpochSeconds(1_700_000_000), + savedAt = Instant.fromEpochSeconds(1_700_000_000), + ) +}