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:
1 parent
5ba888d9f9
commit
2adc503613
12 files changed
+619
-109
No files matched your search
+12
@@ -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()
|
||||
}
|
||||
|
||||
+102
-16
@@ -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 = {},
|
||||
)
|
||||
|
||||
+1
@@ -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.
|
||||
|
||||
+16
-37
@@ -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
|
||||
|
||||
+92
-22
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user