Merge branch 'mantra' into claude/marmot-profile-metadata-loading-e2a067
This commit is contained in:
@@ -179,6 +179,14 @@ fun ChatRoomMessagingScreen(
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
onOpenProposals = {
|
||||
onNavigateToRoute.invoke(
|
||||
ProposalListRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,9 +80,11 @@ import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.text.ProposedEvent
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
|
||||
import press.mantra.compose.ui.view.state.ChatMessageListUIState
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -119,13 +121,33 @@ class ChatMessageListViewModel(
|
||||
private set
|
||||
|
||||
/**
|
||||
* The sessions still waiting on this member to agree to sign, by id.
|
||||
* One proposal this member still owes an answer to, as the transcript's
|
||||
* standing notice has to say it.
|
||||
*
|
||||
* Held rather than asked for, because it is read at the moment of a tap --
|
||||
* see [hidesOtherDecisions] -- and that is not a moment that can wait on a
|
||||
* query.
|
||||
* Summarised here rather than at render because the summary is parsed out of
|
||||
* stored JSON, and the notice sits in a scrolling list. Named after the first
|
||||
* event it signs, the way the proposal list names its rows -- a batch's later
|
||||
* items hang off its first, so that is what the proposal is about.
|
||||
*/
|
||||
var proposalsAwaitingYou: Set<String> by mutableStateOf(emptySet())
|
||||
data class AwaitingProposal(
|
||||
val sessionId: String,
|
||||
|
||||
/** The first of its events that could be read, if any could. */
|
||||
val lead: ProposedEvent.Summary?,
|
||||
|
||||
/** The batch's size, readable or not. */
|
||||
val eventCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* The sessions still waiting on this member to agree to sign.
|
||||
*
|
||||
* Held rather than asked for, because both its readers want it already
|
||||
* there: the transcript's standing notice renders off it, and
|
||||
* [hidesOtherDecisions] is consulted at the moment of a tap, which is not a
|
||||
* moment that can wait on a query.
|
||||
*/
|
||||
var proposalsAwaitingYou: List<AwaitingProposal> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/**
|
||||
@@ -143,7 +165,8 @@ class ChatMessageListViewModel(
|
||||
* asking to see that, not to be handed the queue.
|
||||
*/
|
||||
fun hidesOtherDecisions(sessionId: String): Boolean =
|
||||
sessionId in proposalsAwaitingYou && proposalsAwaitingYou.size > 1
|
||||
proposalsAwaitingYou.size > 1 &&
|
||||
proposalsAwaitingYou.any { it.sessionId == sessionId }
|
||||
|
||||
fun startDirectMessage(participant: Participant) {
|
||||
logger.d("Arming a direct message to ${participant.participantPublicKey}")
|
||||
@@ -208,7 +231,19 @@ class ChatMessageListViewModel(
|
||||
.collect { sessions ->
|
||||
proposalsAwaitingYou = sessions.filter {
|
||||
FrostSigningManager.isAwaitingApproval(it.session, it.items)
|
||||
}.map { it.session.id }.toSet()
|
||||
}.map { local ->
|
||||
AwaitingProposal(
|
||||
sessionId = local.session.id,
|
||||
// The first item that can be read, not the first item:
|
||||
// a batch whose lead is unreadable is still about
|
||||
// something, and the notice should say what.
|
||||
lead = local.items.firstNotNullOfOrNull { item ->
|
||||
Event.fromJsonOrNull(item.unsignedEventJson)
|
||||
?.let { ProposedEvent.summarize(it) }
|
||||
},
|
||||
eventCount = local.items.size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,6 +378,8 @@ class ChatMessageListViewModel(
|
||||
* and cannot say which one it meant.
|
||||
*/
|
||||
onOpenSigning: (sessionId: String?) -> Unit,
|
||||
/** Opens the room's proposals, all of them, whatever their state. */
|
||||
onOpenProposals: () -> Unit,
|
||||
) {
|
||||
|
||||
Column(
|
||||
@@ -401,11 +438,29 @@ class ChatMessageListViewModel(
|
||||
val answeredRequests = ChatMessage.answeredRequests(messages)
|
||||
val settledRequests = ChatMessage.settledRequests(messages)
|
||||
|
||||
// Read out here rather than inside the list, so the
|
||||
// notice appearing and disappearing is a recomposition
|
||||
// of this function and not of a lazy item that may not
|
||||
// be composed at the time.
|
||||
val awaitingYou = proposalsAwaitingYou
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
reverseLayout = true
|
||||
) {
|
||||
// First item, and the layout is reversed, so this
|
||||
// sits under the newest message and above the
|
||||
// composer -- where the reader already is.
|
||||
if (awaitingYou.isNotEmpty()) {
|
||||
item {
|
||||
ProposalsAwaitingYouNotice(
|
||||
proposals = awaitingYou,
|
||||
onClick = onOpenProposals
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (isReceiverChatMessageRelayListMissing.value) {
|
||||
Row(
|
||||
@@ -748,6 +803,108 @@ class ChatMessageListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the group is waiting on this member to sign, standing at the foot of the
|
||||
* transcript.
|
||||
*
|
||||
* A proposal announces itself as a line and then the conversation carries it
|
||||
* upward, but the decision it asks for does not expire with the scroll -- and a
|
||||
* member who has not answered is what the whole room is waiting on. So the ask
|
||||
* is restated where the reader already is, under the newest message, and is gone
|
||||
* the moment nothing is owed. Nothing to dismiss: there is no state here beyond
|
||||
* whether the group still needs an answer.
|
||||
*
|
||||
* Named after what it signs when there is one of them, because "a proposal is
|
||||
* waiting" is not something anybody can decide about. With several, the count is
|
||||
* the honest summary -- naming one of several here would say the others were not
|
||||
* there.
|
||||
*
|
||||
* It opens the room's proposals rather than the one it names, in every case. The
|
||||
* transcript's own lines are the way to one proposal; this is the standing count
|
||||
* of what is owed, and the queue is the screen that answers the question it
|
||||
* raises -- including for the one it could not name.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProposalsAwaitingYouNotice(
|
||||
proposals: List<ChatMessageListViewModel.AwaitingProposal>,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val single = proposals.singleOrNull()
|
||||
|
||||
val summary = single?.lead
|
||||
?.let { lead ->
|
||||
listOfNotNull(lead.label, lead.detail.takeIf { it.isNotBlank() })
|
||||
.joinToString(" · ")
|
||||
}
|
||||
// Two ways for a proposal to have no lead, and they are different
|
||||
// situations: a session can exist before its proposal has arrived, and a
|
||||
// proposal can arrive holding events this build cannot read. Said the
|
||||
// same way the proposal list says it.
|
||||
?: single?.let {
|
||||
if (it.eventCount == 0) {
|
||||
"Nothing has arrived to sign yet"
|
||||
} else {
|
||||
"None of its events could be read"
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Draw,
|
||||
contentDescription = null
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (single != null) {
|
||||
"Waiting for your signature"
|
||||
} else {
|
||||
"${proposals.size} proposals are waiting for your signature"
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
|
||||
if (summary != null) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The same word the transcript's own request lines use for the same
|
||||
// thing, so a member reading down the room is not asked twice in two
|
||||
// vocabularies.
|
||||
Text(
|
||||
text = "Review",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
|
||||
Icon(
|
||||
Icons.Default.ChevronRight,
|
||||
contentDescription = "Open the group's proposals"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A private message this device cannot read, as a system line.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user