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