feat(chat): the preview's button knows whether it can be pressed

Phase 4 of docs/npub-profile-preview.md. Phases 1-3 moved the twenty-second
dead end one screen later: a person found on an indexer, a press, and
InitiatingNewChat waiting for a key package that someone who has never opened
Curare will never have published. The preview now reads the answer before the
press. A room that already exists opens; a key package that is here enables
"Start new chat"; one that is not, once the relays have had their time,
disables it with the reason under it and a try-again that asks the DM relays
alone.

The existing-room check was a loop with a TODO inside initiateNewChat; it is
ChatRepository.findDirectMessageRoom now -- a room whose members are exactly
the two keys, or the one key when they are the same -- read by both screens and
pinned by a test on an in-memory database. The old size-one branch would have
opened any room the peer sat in alone, whoever they were.

Both halves are observed together, as the chat screen does: a key package
arriving after the profile touches a different table, and a collect on the
profile alone would never see it. A room is read once up front, because a key
package is meant to be used once and the relays need not hold a fresh one for
someone you already talk to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@d649098504
This commit is contained in:
Kgothatso Ngako
2026-09-13 13:19:51 +02:00
parent 5ba888d9f9
commit 2adc503613
12 changed files with 619 additions and 109 deletions

View File

@@ -488,4 +488,7 @@
<string name="find_profile">Find profile</string>
<string name="looking_for_this_profile_on_the_relays">Looking for this profile on the relays.</string>
<string name="start_new_chat">Start new chat</string>
<string name="you_already_have_a_chat_with_s">You already have a chat with %1$s.</string>
<string name="checking_whether_s_can_receive_messages_here">Checking whether %1$s can receive messages here.</string>
<string name="s_hasn_t_set_up_messaging_on_mantra_yet">%1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.</string>
</resources>

View File

@@ -157,6 +157,18 @@ class DatabaseChatRepository(
override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant> {
return database.participantDao().findParticipantByPublicKey(publicKey)
}
override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? {
val theTwoOfUs = setOf(userPublicKey, peerPublicKey)
for (participant in database.participantDao().findParticipantByPublicKey(peerPublicKey)) {
val localChatRoom = database.chatRoomDao().findChatRoomById(participant.chatRoomId) ?: continue
val members = localChatRoom.localParticipants.map { it.participant.participantPublicKey }.toSet()
if (members == theTwoOfUs) return localChatRoom
}
return null
}
override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List<LocalChatMessage> {
return database.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
}

View File

@@ -116,6 +116,16 @@ interface ChatRepository {
suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant>
/**
* The room that is a conversation between [userPublicKey] and [peerPublicKey]
* alone, if there is one: its members are exactly the two of them, or the one of
* them when the two keys are the same. A group of two is not told apart from a
* direct message here -- the schema does not record which a room is -- so this is
* the rule `ChatRoomMessagingViewModel.initiateNewChat` has applied since it was
* written, in one place with its name on it. See docs/npub-profile-preview.md.
*/
suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom?
suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List<LocalChatMessage>
suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow<List<LocalChatMessage>>
@@ -300,6 +310,10 @@ interface ChatRepository {
TODO("Not yet implemented")
}
override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? {
TODO("Not yet implemented")
}
override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List<LocalChatMessage> {
return emptyList()
}

View File

@@ -2,6 +2,7 @@ package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -14,6 +15,8 @@ import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.PersonSearch
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -25,19 +28,25 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.checking_whether_s_can_receive_messages_here
import mantra.composeapp.generated.resources.looking_for_this_profile_on_the_relays
import mantra.composeapp.generated.resources.open_chat
import mantra.composeapp.generated.resources.profile
import mantra.composeapp.generated.resources.s_hasn_t_set_up_messaging_on_mantra_yet
import mantra.composeapp.generated.resources.start_new_chat
import mantra.composeapp.generated.resources.try_again
import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key
import mantra.composeapp.generated.resources.you_already_have_a_chat_with_s
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.database.model.Profile
import press.mantra.compose.extensions.hexToNpubHrp
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
@@ -56,6 +65,7 @@ import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.view.model.ProfilePreviewViewModel
import press.mantra.compose.ui.view.state.ProfilePreviewUIState
import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness
/**
* The person behind a pasted npub, and the one thing to do about them.
@@ -64,15 +74,20 @@ import press.mantra.compose.ui.view.state.ProfilePreviewUIState
* profile block is the confirmation the flow never had, and "Start new chat" hands
* over to `ChatRoomMessagingRoute` exactly as the dialog used to -- the room is made
* where it has always been made, and this screen is popped under it, as the profile
* detail is under a chat started from it. See docs/npub-profile-preview.md.
* detail is under a chat started from it.
*
* The button reads the state's readiness: a chat that exists opens, one that can be
* started starts, and one that cannot yet says why under a disabled button rather
* than twenty seconds into the next screen. See docs/npub-profile-preview.md.
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfilePreviewScreen(
activeUserPublicKey: HexKey,
profilePublicKey: HexKey,
initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
onStartChat: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
@@ -82,6 +97,7 @@ fun ProfilePreviewScreen(
profilePublicKey = profilePublicKey,
initialProfilePreviewUIState = initialProfilePreviewUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
),
)
@@ -128,8 +144,19 @@ fun ProfilePreviewScreen(
// A message is a gift wrap, sealed with the sender's key. Not for a
// read-only identity, which is shown the person and nothing to do.
if (LocalCanSign.current) {
StartChatButton(
onClick = {
ChatAction(
name = state.profile.humanReadableNameOrPubkey(),
readiness = state.readiness,
onOpenChat = { chatRoomId, relayHint ->
onStartChat(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
)
)
},
onStartChat = {
onStartChat(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
@@ -138,6 +165,7 @@ fun ProfilePreviewScreen(
)
)
},
onRetry = { profilePreviewViewModel.retry() },
)
}
}
@@ -236,26 +264,82 @@ private fun Npub(npub: String) {
}
/**
* The one thing to do about the person, and whether it can be done yet.
*
* A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite
* confirmation uses: Phase 4 of the plan disables this with a reason beside it, and a
* FAB has no disabled state in M3 or in the API.
* confirmation uses, because a FAB has no disabled state in M3 or in the API, and a
* disabled button with the reason under it is what the action needs while the relays
* are still answering: the action exists and is unavailable, which is what M3's
* disabled state is for, and a screen that lost its only button would read as broken.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StartChatButton(onClick: () -> Unit) {
Button(
private fun ChatAction(
name: String,
readiness: Readiness,
onOpenChat: (chatRoomId: String, relayHint: String?) -> Unit,
onStartChat: () -> Unit,
onRetry: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
) {
Icon(
Icons.Default.Mail,
// The label is beside it.
contentDescription = Decorative,
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap))
Text(stringResource(Res.string.start_new_chat))
Button(
modifier = Modifier.fillMaxWidth(),
enabled = readiness is Readiness.ExistingChat || readiness == Readiness.CanStartChat,
onClick = {
when (readiness) {
is Readiness.ExistingChat -> onOpenChat(readiness.chatRoomId, readiness.relayHint)
else -> onStartChat()
}
},
) {
Icon(
Icons.Default.Mail,
// The label is beside it.
contentDescription = Decorative,
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap))
Text(
stringResource(
if (readiness is Readiness.ExistingChat) Res.string.open_chat else Res.string.start_new_chat
)
)
}
// A supporting line, not a second message.
when (readiness) {
is Readiness.ExistingChat -> SupportingLine(stringResource(Res.string.you_already_have_a_chat_with_s, name))
Readiness.CanStartChat -> Unit
Readiness.Checking -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
) {
LoadingIndicator()
SupportingLine(stringResource(Res.string.checking_whether_s_can_receive_messages_here, name))
}
}
Readiness.NotYetOnMantra -> {
SupportingLine(stringResource(Res.string.s_hasn_t_set_up_messaging_on_mantra_yet, name))
TextButton(onClick = onRetry) {
Text(stringResource(Res.string.try_again))
}
}
}
}
}
@Composable
private fun SupportingLine(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@ConformancePreviews
@Composable
private fun ProfilePreviewScreenPreview() {
@@ -272,8 +356,10 @@ private fun ProfilePreviewScreenPreview() {
about = "I am the stone that the builder refused.",
nostrEventId = "nostrEventId",
),
readiness = Readiness.CanStartChat,
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onStartChat = {},
onNavigateBack = {},
)

View File

@@ -1459,6 +1459,7 @@ fun MantraNavHost(
activeUserPublicKey = route.activeUserPublicKey,
profilePublicKey = route.profilePublicKey,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
// The room replaces the preview, as it replaces the profile detail
// above: back from a chat is the list, not the person it was started
// from.

View File

@@ -67,44 +67,23 @@ class ChatRoomMessagingViewModel(
// Get ChatRoomById
viewModelScope.launch(Dispatchers.IO) {
// Check if we have a chat with activeUserPublicKey and chatRoomId (pubkey) as participants
val participants = chatRepository.getAllParticipantsWithPubKey(chatRoomId)
participants.forEach { participant ->
chatRepository.getChatRoomByIdentifier(participant.chatRoomId)?.let { localChatRoom ->
// TODO: Check that chatRoom is not a group chat...
if (localChatRoom.localParticipants.size == 1) {
// This is a chat with self...
// TODO: navigateToTheChat...
viewModelScope.launch(Dispatchers.Main) {
onNavigateToChat.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = localChatRoom.chatRoom.id,
relayHint = relayHint
)
)
}
return@launch
} else if (localChatRoom.localParticipants.size == 2) {
val activeUserParticipant = localChatRoom.localParticipants.find { localParticipant -> localParticipant.participant.participantPublicKey == activeUserPublicKey }
if (activeUserParticipant != null) {
// We have found the chat we are looking for...
viewModelScope.launch(Dispatchers.Main) {
onNavigateToChat.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = localChatRoom.chatRoom.id,
relayHint = relayHint
)
)
}
return@launch
}
}
// A conversation with this person that already exists is the one to open. The
// rule -- the two of us and nobody else, or yourself alone -- is the
// repository's now, and the profile preview reads the same one.
chatRepository.findDirectMessageRoom(
userPublicKey = activeUserPublicKey,
peerPublicKey = chatRoomId
)?.let { localChatRoom ->
viewModelScope.launch(Dispatchers.Main) {
onNavigateToChat.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = localChatRoom.chatRoom.id,
relayHint = relayHint
)
)
}
return@launch
}
// Observe BOTH halves of what we need. Watching only the profile meant a key

View File

@@ -14,34 +14,47 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.MarmotKeyPackage
import press.mantra.compose.database.model.Profile
import press.mantra.compose.nostr.DirectMessagePeerSync
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.ProfilePreviewUIState
import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness
import kotlin.concurrent.Volatile
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Finds the person behind a pasted npub, and says when it has stopped looking.
* Finds the person behind a pasted npub, and says whether a chat with them can start.
*
* Cached first: the observed row is whatever the device already holds, so a person
* Cached first: the observed rows are 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.
* Both halves are observed together, as `ChatRoomMessagingViewModel` does, because a
* key package arriving after the profile touches a different table and a collect on
* the profile alone would never see it. A room that already exists is read once, up
* front: a key package is meant to be used once, and the relays need not hold a fresh
* one for a person you already have a room with.
*
* One timeout, the one both DM view models use, and it only changes what is shown:
* nothing found becomes not-found, and a person found without a key package becomes
* not-yet-on-Mantra. The collect stays live past it, so whatever arrives on the
* twenty-first second still lands. See docs/npub-profile-preview.md.
*/
class ProfilePreviewViewModel(
val activeUserPublicKey: HexKey,
val profilePublicKey: HexKey,
initialProfilePreviewUIState: ProfilePreviewUIState,
private val nostrRepository: NostrRepository,
private val chatRepository: ChatRepository,
/** 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() {
@@ -54,49 +67,104 @@ class ProfilePreviewViewModel(
private var started = false
private var lookupTimeout: Job? = null
/** Set when the timeout fires; cleared by a retry. Written and read on different coroutines. */
@Volatile
private var relaysHadTheirTime = false
/** Once. A recomposition that calls this again must not open a second collect. */
fun initiate() {
if (started) return
started = true
observeProfile()
observe()
askTheRelays()
}
/** Asks again. The observation is still live, so the answer arrives through the same collect. */
/**
* Asks again. The observation is still live, so the answer arrives through the same
* collect. With a person on screen it is the key package being asked for, and the
* DM relays alone are asked; without one, everything is.
*/
fun retry() {
profilePreviewUIState = ProfilePreviewUIState.Loading
askTheRelays()
relaysHadTheirTime = false
when (val state = profilePreviewUIState) {
is ProfilePreviewUIState.Loaded -> {
if (state.readiness == Readiness.NotYetOnMantra) {
profilePreviewUIState = state.copy(readiness = Readiness.Checking)
}
askTheRelays(profileToo = false)
}
else -> {
profilePreviewUIState = ProfilePreviewUIState.Loading
askTheRelays(profileToo = true)
}
}
}
private fun observeProfile() {
private fun observe() {
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.observeProfileWithPublicKey(profilePublicKey)
val existingRoom = chatRepository.findDirectMessageRoom(
userPublicKey = activeUserPublicKey,
peerPublicKey = profilePublicKey,
)?.let { localChatRoom ->
Readiness.ExistingChat(
chatRoomId = localChatRoom.chatRoom.id,
relayHint = localChatRoom.localParticipants
.firstOrNull { it.participant.participantPublicKey == profilePublicKey }
?.participant?.relayHint,
)
}
combine(
nostrRepository.observeProfileWithPublicKey(profilePublicKey),
chatRepository.observeMarmotKeyPackageForPublicKey(profilePublicKey),
) { profile, keyPackage ->
// 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() } }
profile?.takeIf { it.isResolved() } to keyPackage
}
.distinctUntilChanged()
.collect { profile ->
.collect { (profile, keyPackage) ->
if (profile == null) return@collect
lookupTimeout?.cancel()
profilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile)
show(profile, keyPackage, existingRoom)
}
}
}
private fun askTheRelays() {
private fun show(profile: Profile, keyPackage: MarmotKeyPackage?, existingRoom: Readiness.ExistingChat?) {
val readiness = when {
existingRoom != null -> existingRoom
keyPackage != null -> Readiness.CanStartChat
relaysHadTheirTime -> Readiness.NotYetOnMantra
else -> Readiness.Checking
}
// The clock stops only when there is nothing left to wait for. A profile on its
// own cancels nothing: the key package is still owed.
if (readiness != Readiness.Checking) lookupTimeout?.cancel()
profilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile, readiness = readiness)
}
private fun askTheRelays(profileToo: Boolean = true) {
lookupTimeout?.cancel()
lookupTimeout = viewModelScope.launch(Dispatchers.IO) {
delay(relayLookupTimeout)
if (profilePreviewUIState is ProfilePreviewUIState.Loading) {
profilePreviewUIState = ProfilePreviewUIState.NotFound
relaysHadTheirTime = true
when (val state = profilePreviewUIState) {
ProfilePreviewUIState.Loading -> profilePreviewUIState = ProfilePreviewUIState.NotFound
is ProfilePreviewUIState.Loaded ->
if (state.readiness == Readiness.Checking) {
profilePreviewUIState = state.copy(readiness = Readiness.NotYetOnMantra)
}
else -> Unit
}
}
viewModelScope.launch(Dispatchers.IO) {
try {
nostrRepository.queueSynchronizeNostrEvent(
DirectMessagePeerSync.profileRequests(profilePublicKey)
)
if (profileToo) {
nostrRepository.queueSynchronizeNostrEvent(
DirectMessagePeerSync.profileRequests(profilePublicKey)
)
}
nostrRepository.queueNegentropySynchronizeRequest(
DirectMessagePeerSync.requests(profilePublicKey)
)
@@ -119,6 +187,7 @@ class ProfilePreviewViewModel(
profilePublicKey: HexKey,
initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ProfilePreviewViewModel(
@@ -126,6 +195,7 @@ class ProfilePreviewViewModel(
profilePublicKey = profilePublicKey,
initialProfilePreviewUIState = initialProfilePreviewUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
)
}
}

View File

@@ -14,6 +14,7 @@ sealed interface ProfilePreviewUIState {
data class Loaded(
val profile: Profile,
val readiness: Readiness,
) : ProfilePreviewUIState
/**
@@ -24,4 +25,28 @@ sealed interface ProfilePreviewUIState {
/** The queue itself refused: nothing was asked, and asking again is the retry. */
data object Error : ProfilePreviewUIState
/**
* Whether pressing the button would get anywhere, read here rather than twenty
* seconds into the next screen.
*
* A fact about a *loaded* profile: a room does not make a person known, and a
* member still "LOADING..." waits on their kind 0 here like anyone else.
*/
sealed interface Readiness {
/** A conversation with this person exists; the button opens it. */
data class ExistingChat(
val chatRoomId: String,
val relayHint: String?,
) : Readiness
/** Their key package is here; `initiateNewChat` will create the room without waiting. */
data object CanStartChat : Readiness
/** Asked, not answered yet. */
data object Checking : Readiness
/** The relays have had their time and no key package came. */
data object NotYetOnMantra : Readiness
}
}

View File

@@ -0,0 +1,129 @@
package press.mantra.compose.database.repository
import androidx.room3.Room
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.Profile
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* What counts as a conversation between two keys.
*
* The rule `ChatRoomMessagingViewModel.initiateNewChat` applied inline since it was
* written -- the two of us and nobody else, or yourself alone -- now has a name and a
* test, because the profile preview reads it too and the two must agree.
*/
class FindDirectMessageRoomJvmTest {
private val db: MantraDatabase = getRoomDatabase(
Room.inMemoryDatabaseBuilder<MantraDatabase>()
)
private val scope = CoroutineScope(Job())
private val repository = DatabaseChatRepository(db, scope)
private val me = "a".repeat(64)
private val alice = "b".repeat(64)
private val bob = "c".repeat(64)
@AfterTest
fun closeDb() {
scope.cancel()
db.close()
}
@Test
fun `a room with exactly the two of us is the conversation`() = runBlocking {
seedPeople(me, alice, bob)
seedRoom("group", me, alice, bob)
seedRoom("dm", me, alice)
assertEquals("dm", repository.findDirectMessageRoom(me, alice)?.chatRoom?.id)
}
/** A group of two is not told apart from a direct message; a group of three is not one. */
@Test
fun `a room with a third member is not`() = runBlocking {
seedPeople(me, alice, bob)
seedRoom("group", me, alice, bob)
assertNull(repository.findDirectMessageRoom(me, alice))
}
/** The peer in a room I am not in is not a conversation with me. */
@Test
fun `a room the peer has with somebody else is not`() = runBlocking {
seedPeople(me, alice, bob)
seedRoom("theirs", alice, bob)
assertNull(repository.findDirectMessageRoom(me, alice))
}
@Test
fun `a room with myself alone is the conversation with myself`() = runBlocking {
seedPeople(me, alice)
seedRoom("notes", me)
seedRoom("dm", me, alice)
assertEquals("notes", repository.findDirectMessageRoom(me, me)?.chatRoom?.id)
assertEquals("dm", repository.findDirectMessageRoom(me, alice)?.chatRoom?.id)
}
@Test
fun `nobody in any room is nothing`() = runBlocking {
seedPeople(me, alice)
assertNull(repository.findDirectMessageRoom(me, alice))
}
// --- fixtures -----------------------------------------------------------------------
/** A participant row needs a profile, and a profile needs the event it was read off. */
private suspend fun seedPeople(vararg publicKeys: HexKey) {
publicKeys.forEach { publicKey ->
val nostrEventId = publicKey.take(8).repeat(8)
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = publicKey,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(Profile(publicKey = publicKey, userName = publicKey.take(4), nostrEventId = nostrEventId))
}
}
private suspend fun seedRoom(id: String, vararg members: HexKey) {
db.chatRoomDao().upsert(
ChatRoom(
id = id,
userPublicKey = me,
subject = null,
description = null,
mlsGroupState = null,
)
)
db.participantDao().upsert(
members.map { publicKey ->
Participant(
participantPublicKey = publicKey,
chatRoomId = id,
relayHint = null,
)
}
)
}
}

View File

@@ -7,14 +7,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runDesktopComposeUiTest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import press.mantra.compose.database.model.MarmotKeyPackage
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.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.extensions.hexToNpubHrp
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
@@ -23,6 +28,7 @@ import press.mantra.compose.ui.composable.widgets.LocalCanSign
import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
import press.mantra.compose.ui.theme.MantraTheme
import press.mantra.compose.ui.view.state.ProfilePreviewUIState
import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -47,7 +53,7 @@ class ProfilePreviewScreenJvmTest {
@Test
fun `a found profile is drawn whole, with the npub that was pasted`() = render(
ProfilePreviewUIState.Loaded(profile = alice)
ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat)
) {
onNodeWithText("Alice").assertIsDisplayed()
onNodeWithText("alice@example.com").assertIsDisplayed()
@@ -59,7 +65,7 @@ class ProfilePreviewScreenJvmTest {
fun `starting a chat hands over the route the dialog used to push, and creates nothing here`() {
var opened: Route? = null
render(ProfilePreviewUIState.Loaded(profile = alice), onStartChat = { opened = it }) {
render(ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat), onStartChat = { opened = it }) {
onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed().performClick()
}
@@ -76,7 +82,7 @@ class ProfilePreviewScreenJvmTest {
/** A message is a gift wrap sealed with the sender's key; a read-only identity is shown the person and nothing to do. */
@Test
fun `a read-only identity sees the person and no button`() = render(
ProfilePreviewUIState.Loaded(profile = alice),
ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat),
canSign = false,
) {
onNodeWithText("Alice").assertIsDisplayed()
@@ -84,10 +90,64 @@ class ProfilePreviewScreenJvmTest {
}
/** Arriving loading is the one case that runs `initiate`, so this render is given relays that never answer. */
// --- whether the button can be pressed ---
@Test
fun `a room that exists is opened, not started again`() {
var opened: Route? = null
render(
ProfilePreviewUIState.Loaded(
profile = alice,
readiness = Readiness.ExistingChat(chatRoomId = "room", relayHint = "wss://hint.example"),
),
onStartChat = { opened = it },
) {
onNodeWithText("You already have a chat with Alice.").assertIsDisplayed()
onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist()
onNodeWithText("Open chat", useUnmergedTree = true).assertIsDisplayed().performClick()
}
assertEquals(
ChatRoomMessagingRoute(activeUserPublicKey = activeUserPublicKey, chatRoomId = "room", relayHint = "wss://hint.example"),
opened,
)
}
@Test
fun `while the key package is still owed the button waits, and says so`() = render(
ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.Checking)
) {
onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed()
onNodeWithText("Start new chat").assertIsNotEnabled()
onNodeWithText("Checking whether Alice can receive messages here.").assertIsDisplayed()
}
@Test
fun `a person not yet on Mantra cannot be written to, and the reason is under the button`() = render(
ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.NotYetOnMantra)
) {
onNodeWithText("Start new chat").assertIsNotEnabled()
onNodeWithText("Alice hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.").assertIsDisplayed()
onNodeWithText("Try again").assertIsDisplayed()
}
@Test
fun `a person whose key package is here can be written to at once`() = render(
ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat)
) {
onNodeWithText("Start new chat").assertIsEnabled()
onNodeWithText("Checking whether", substring = true).assertDoesNotExist()
onNodeWithText("hasn't set up messaging", substring = true).assertDoesNotExist()
}
// --- the other states ---
@Test
fun `while the relays are asked the npub is already on screen`() = render(
ProfilePreviewUIState.Loading,
nostrRepository = NothingArrives,
chatRepository = NoRooms,
) {
onNodeWithText(npub).assertIsDisplayed()
onNodeWithText("Looking for this profile on the relays.").assertIsDisplayed()
@@ -118,6 +178,12 @@ class ProfilePreviewScreenJvmTest {
override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List<NegentropySynchronizeRequest>) = Unit
}
/** No room with the person, and no key package for them. */
private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: String): Flow<MarmotKeyPackage?> = flowOf(null)
override suspend fun findDirectMessageRoom(userPublicKey: String, peerPublicKey: String): LocalChatRoom? = null
}
/**
* The screen at a phone's width. The state is passed in, so the NO_OP repository is
* never asked for anything: `initiate` only runs for a screen that arrives loading.
@@ -126,6 +192,7 @@ class ProfilePreviewScreenJvmTest {
uiState: ProfilePreviewUIState,
canSign: Boolean = true,
nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository: ChatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onStartChat: (Route) -> Unit = {},
assertions: ComposeUiTest.() -> Unit,
) = runDesktopComposeUiTest(width = 400, height = 1200) {
@@ -139,6 +206,7 @@ class ProfilePreviewScreenJvmTest {
profilePublicKey = profilePublicKey,
initialProfilePreviewUIState = uiState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
onStartChat = onStartChat,
onNavigateBack = {},
)

View File

@@ -291,8 +291,12 @@ class ReadOnlyEntrancesJvmTest {
ProfilePreviewScreen(
activeUserPublicKey = publicKey,
profilePublicKey = publicKey,
initialProfilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile.profile),
initialProfilePreviewUIState = ProfilePreviewUIState.Loaded(
profile = profile.profile,
readiness = ProfilePreviewUIState.Readiness.CanStartChat,
),
nostrRepository = FixedProfile,
chatRepository = NoRooms,
onStartChat = {},
onNavigateBack = {},
)

View File

@@ -12,12 +12,19 @@ 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.ChatRoom
import press.mantra.compose.database.model.MarmotKeyPackage
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.SynchronizeNostrEventRequest
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalParticipant
import press.mantra.compose.nostr.DirectMessagePeerSync
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.ProfilePreviewUIState
import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -27,13 +34,14 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* What the preview shows while the relays are asked, and when it stops waiting.
* What the preview shows while the relays are asked, when it stops waiting, and
* whether the button it ends on can be pressed.
*
* 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.
* The repository fakes record every ask and hold the profile row and the key package
* in `MutableStateFlow`s, so the test can play them arriving in either order, 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 {
@@ -61,7 +69,18 @@ class ProfilePreviewViewModelJvmTest {
}
}
private val bothAsks = listOf("req:${DirectMessagePeerSync.PROFILE_PURPOSE}", "neg:${DirectMessagePeerSync.PURPOSE}")
/** The key package the device holds for the peer, and the room it already has with them. */
private class Rooms(
val keyPackage: MutableStateFlow<MarmotKeyPackage?> = MutableStateFlow(null),
private val existingRoom: LocalChatRoom? = null,
) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow<MarmotKeyPackage?> = keyPackage
override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? = existingRoom
}
private val profileAsk = "req:${DirectMessagePeerSync.PROFILE_PURPOSE}"
private val chatAsk = "neg:${DirectMessagePeerSync.PURPOSE}"
private val bothAsks = listOf(profileAsk, chatAsk)
@BeforeTest
fun setUp() {
@@ -73,14 +92,17 @@ class ProfilePreviewViewModelJvmTest {
Dispatchers.resetMain()
}
private fun viewModel(recorder: Recorder) = ProfilePreviewViewModel(
private fun viewModel(recorder: Recorder, rooms: Rooms = Rooms()) = ProfilePreviewViewModel(
activeUserPublicKey = me,
profilePublicKey = alice,
initialProfilePreviewUIState = ProfilePreviewUIState.Loading,
nostrRepository = recorder,
chatRepository = rooms,
relayLookupTimeout = 300.milliseconds,
)
// --- finding the person ---------------------------------------------------------------
@Test
fun `initiating asks both sets of relays once, and waits`() = runBlocking {
val recorder = Recorder()
@@ -119,29 +141,11 @@ class ProfilePreviewViewModelJvmTest {
viewModel.initiate()
await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded }
assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState)
assertEquals(loaded(Readiness.Checking), 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()
@@ -187,6 +191,91 @@ class ProfilePreviewViewModelJvmTest {
await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error }
}
// --- whether the button can be pressed -----------------------------------------------
/**
* A key package is meant to be used once, and the relays need not hold a fresh one
* for a person you already have a room with: without this row, someone you talk to
* every day would read "hasn't set up messaging".
*/
@Test
fun `a room that exists opens, key package or not`() = runBlocking<Unit> {
val recorder = Recorder(profile = MutableStateFlow(resolved(alice)))
val viewModel = viewModel(recorder, Rooms(existingRoom = roomWith(alice)))
viewModel.initiate()
await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded }
assertEquals(loaded(Readiness.ExistingChat(chatRoomId = "room", relayHint = "wss://hint.example")), viewModel.profilePreviewUIState)
// Nothing left to wait for, so the timeout has nothing to say.
delay(400)
assertIs<Readiness.ExistingChat>(assertIs<ProfilePreviewUIState.Loaded>(viewModel.profilePreviewUIState).readiness)
}
@Test
fun `the profile first, then the key package, and the clock stops`() = runBlocking {
val recorder = Recorder()
val rooms = Rooms()
val viewModel = viewModel(recorder, rooms)
viewModel.initiate()
recorder.profile.value = resolved(alice)
await { viewModel.profilePreviewUIState == loaded(Readiness.Checking) }
rooms.keyPackage.value = keyPackage(alice)
await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) }
delay(400)
assertEquals(loaded(Readiness.CanStartChat), viewModel.profilePreviewUIState)
}
@Test
fun `the key package first, then the profile, is ready at once`() = runBlocking {
val recorder = Recorder()
val rooms = Rooms(keyPackage = MutableStateFlow(keyPackage(alice)))
val viewModel = viewModel(recorder, rooms)
viewModel.initiate()
delay(50)
assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState)
recorder.profile.value = resolved(alice)
await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) }
}
@Test
fun `a person with no key package is not yet on Mantra after the timeout, and a late one still counts`() = runBlocking {
val recorder = Recorder(profile = MutableStateFlow(resolved(alice)))
val rooms = Rooms()
val viewModel = viewModel(recorder, rooms)
viewModel.initiate()
await { viewModel.profilePreviewUIState == loaded(Readiness.Checking) }
await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) }
rooms.keyPackage.value = keyPackage(alice)
await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) }
}
/** The person is found; it is the key package being asked for again, and the DM relays alone are asked. */
@Test
fun `try again with a person on screen asks the DM relays alone and keeps the person`() = runBlocking {
val recorder = Recorder(profile = MutableStateFlow(resolved(alice)))
val viewModel = viewModel(recorder, Rooms())
viewModel.initiate()
await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) }
assertEquals(bothAsks, recorder.effects)
viewModel.retry()
assertEquals(loaded(Readiness.Checking), viewModel.profilePreviewUIState)
await { recorder.effects.size == 3 }
assertEquals(bothAsks + chatAsk, recorder.effects)
await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) }
}
// --- fixtures -----------------------------------------------------------------------
private suspend fun await(condition: () -> Boolean) {
@@ -195,6 +284,8 @@ class ProfilePreviewViewModelJvmTest {
}
}
private fun loaded(readiness: Readiness) = ProfilePreviewUIState.Loaded(profile = resolved(alice), readiness = readiness)
/** What every mint site writes: the "LOADING..." name, stamped `GENESIS_AT`. */
private fun placeholder(publicKey: HexKey) = Profile(
publicKey = publicKey,
@@ -203,12 +294,40 @@ class ProfilePreviewViewModelJvmTest {
createdAt = GENESIS_AT,
)
private val at = Instant.fromEpochSeconds(1_700_000_000)
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),
createdAt = at,
updatedAt = at,
savedAt = at,
)
private fun keyPackage(publicKey: HexKey) = MarmotKeyPackage(
id = "keypackage",
publicKey = publicKey,
tlsEncodedMarmotKeyPackage = ByteArray(0),
)
private fun roomWith(peer: HexKey) = LocalChatRoom(
chatRoom = ChatRoom(
id = "room",
userPublicKey = me,
subject = null,
description = null,
mlsGroupState = "state",
),
localParticipants = listOf(me, peer).map { publicKey ->
LocalParticipant(
participant = Participant(
participantPublicKey = publicKey,
chatRoomId = "room",
relayHint = "wss://hint.example",
),
profile = null,
)
},
)
}