feat(chat): the lookup behind a profile preview, and the sync it shares with the chat screen
Phase 1 of docs/npub-profile-preview.md. No screen yet: a view model that finds the person behind a pasted npub and says when it has stopped looking, over the row the device already holds -- cached first -- and two asks of the relays. The three-kind filter the chat screen queued privately is now DirectMessagePeerSync.requests, and the kind 0 alone goes to the indexers as profileRequests: a key pasted from elsewhere has lived elsewhere, and a three-kind filter sent to an indexer comes back with the kind 0 and nothing else. "A placeholder is not a profile" was spelled out twice as a comparison against GENESIS_AT; it is Profile.isResolved() now, and both sites read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@37c6f7eabb
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<NegentropySynchronizeRequest> {
|
||||
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<SynchronizeNostrEventRequest> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HexKey> =
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Profile?> = MutableStateFlow(null),
|
||||
private val refuse: Boolean = false,
|
||||
) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
|
||||
val effects = mutableListOf<String>()
|
||||
|
||||
override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow<Profile?> = profile
|
||||
|
||||
override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>) {
|
||||
if (refuse) throw IllegalStateException("the queue is closed")
|
||||
effects += "req:" + synchronizeNostrEventRequests.map { it.purpose }.distinct().joinToString()
|
||||
}
|
||||
|
||||
override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List<NegentropySynchronizeRequest>) {
|
||||
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<Unit> {
|
||||
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<ProfilePreviewUIState.Loaded>(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),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user