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:
Kgothatso Ngako
2026-09-13 12:50:15 +02:00
parent 69aa43a108
commit f84ae13394
8 changed files with 577 additions and 37 deletions

View File

@@ -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

View File

@@ -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,
)
}
}
}

View File

@@ -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()

View File

@@ -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)
)
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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
}