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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user