diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 3b45342b..45be57b2 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -488,4 +488,7 @@
Find profile
Looking for this profile on the relays.
Start new chat
+ You already have a chat with %1$s.
+ Checking whether %1$s can receive messages here.
+ %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
index d0143ec3..6b71b5e8 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
@@ -157,6 +157,18 @@ class DatabaseChatRepository(
override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List {
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 {
return database.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId)
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
index 9ea861ce..42794c40 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
@@ -116,6 +116,16 @@ interface ChatRepository {
suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List
+ /**
+ * 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
suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow>
@@ -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 {
return emptyList()
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt
index 85ea825c..27d0cc46 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt
@@ -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 = {},
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 4ba2210d..92f022d0 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -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.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt
index 37fce883..a0d00b5e 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt
index f7b299a5..3c24ff6f 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt
@@ -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,
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt
index 73942a91..f61064f0 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt
@@ -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
+ }
}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt
new file mode 100644
index 00000000..80cb7557
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt
@@ -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()
+ )
+ 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,
+ )
+ }
+ )
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt
index 596b548c..0f1dc539 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt
@@ -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) = 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 = 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 = {},
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt
index f59dd64d..1cfab53d 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt
@@ -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 = {},
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt
index a319d8c3..1d120e26 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt
@@ -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 = MutableStateFlow(null),
+ private val existingRoom: LocalChatRoom? = null,
+ ) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
+ override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow = 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 {
- val recorder = Recorder()
- val viewModel = viewModel(recorder)
-
- viewModel.initiate()
- await { recorder.effects.size == 2 }
- recorder.profile.value = placeholder(alice)
- recorder.profile.value = resolved(alice)
-
- await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded }
- assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState)
-
- // Loaded is loaded: the timeout, had it still been armed, must not take it back.
- delay(400)
- assertIs(viewModel.profilePreviewUIState)
- }
-
@Test
fun `nothing arriving is not found, and a late kind 0 still loads`() = runBlocking {
val recorder = Recorder()
@@ -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 {
+ 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(assertIs(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,
+ )
+ },
)
}