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
|
||||
}
|
||||
Reference in New Issue
Block a user