feat: say at the foot of the transcript what the group is still waiting for you to sign
The transcript carries a proposal past as it happens, andb977326gave a member owed two decisions a queue to find the second one in. Neither says anything before it is tapped. A proposal announces itself as one line among everything else the room said, the conversation carries it upward, and from then on the only evidence that the group is waiting on this member is a line they would have to scroll back to -- or the Proposals button on a screen behind a kebab menu, which is a place to look rather than a thing that tells you to look. The decision does not expire with the scroll, and a member who has not answered is what the whole room is waiting on. **Where it sits.** First item of the transcript's LazyColumn, which has `reverseLayout = true`, so index 0 is at the bottom edge of the viewport -- under the newest message and directly above the composer. That is also where the list is scrolled to when a room is opened, so a member who has just been asked for something is told so without moving. `ChatMessageDao` orders `createdAt DESC` and the reversal turns it back the right way up, which is why "first item" and "under the newest message" are the same place. **Not pinned above the composer.** It scrolls with the transcript and leaves view when a reader goes back through history. Pinning it is not a move of the composable: `RenderMessages` is a Column of `Spacer(weight(1f))` then the messages column, and a Column measures its unweighted children first and in order, each against what the previous ones left. The messages column holds a LazyColumn with no height modifier, which takes the whole remaining height, so a sibling card placed after it would be measured against nothing and would not appear. Making that work wants `weight(1f)` moved onto the messages column, which changes how every part of this screen is measured rather than where one card goes. **Guarded outside `item { }` rather than inside it.** The relay-list notice beside it is written the other way round -- an item that always exists and sometimes composes nothing -- and `verticalArrangement = Arrangement.spacedBy` puts its gap between every pair of adjacent items whatever their height, so an item composing nothing still costs 10.dp. This one is not added at all when nothing is owed, so a room with no proposals carries no phantom gap at the foot of its transcript. **Read before the list builder.** `proposalsAwaitingYou` is pulled into a local above the `LazyColumn` call rather than inside its scope, so the state read is plainly a read of `RenderMessages` and the notice appearing or disappearing is a recomposition of this function. Reading it inside the builder would work -- the item provider is snapshot-aware -- but it puts the difference between "no proposals" and "one proposal" inside a lambda whose re-execution is the lazy list's business rather than this function's. **What the held state carries now.** `proposalsAwaitingYou` widens from `Set<String>` to `List<AwaitingProposal>`: the session id it already had, plus a `ProposedEvent.Summary` of what the batch is about and the batch's size. It is still filled from the same `observeSessionsForChatRoom` collector asking `FrostSigningManager.isAwaitingApproval` -- the point of b977326's version was that the transcript and the proposal list ask one question, and that is unchanged. The summary is built in the collector rather than at render because it is parsed out of stored JSON, once per item, and this is a scrolling list. `ProposalListUIState.Proposal` derives its own for the same reason and says so; doing it in the composable would re-parse every event in every open proposal on every recomposition of the room. **The first item that can be read, not the first item.** `firstNotNullOfOrNull` over `Event.fromJsonOrNull`, matching `ProposalListScreen`, whose `lead` is the first of the already-`mapNotNull`ed events. A batch is named after its first item because the rest hang off it, but a batch whose first item this build cannot parse is still about something, and falling back to "Proposal" when the second item says "New chapter" would be throwing away the name for a reason the reader cannot see. **Named when there is one, counted when there are several.** One proposal gets "Waiting for your signature" over what it signs -- "New chapter · Genesis 1 · 797 words · 31 chunks" -- because "a proposal is waiting" is not something anybody can decide about, and the whole value of the card over a dot on a menu is that it says what the group wants. Several get the count and nothing else. Naming the first of several would say the others were not there, which is exactly the fault `ProposalListScreen` was built to fix; listing them all would be building that screen a second time in the composer's space. **The two ways to have nothing to name.** A session can exist before its proposal has arrived, and a proposal can arrive holding events this build cannot read. They are different situations and the card says which, in the same words `ProposalCard` uses -- "Nothing has arrived to sign yet" against "None of its events could be read" -- so a member who taps through finds the row saying what the card said. **It opens the queue, in every case.** Not the proposal it names, even when it names exactly one. The transcript's own lines are the way to a single proposal and keep their existing routing; this is the standing count of what is owed, and the queue is the screen that answers the question it raises -- including for a proposal it could not name, where opening one session would be opening the one thing the card just admitted it could not describe. **Its own callback rather than `onOpenSigning(null)`.** That would have worked: `ChatRoomMessagingScreen` already sends a null session id to `ProposalListRoute`. But null there is a claim -- "this line predates `ChatMessage.frostSigningSessionId` and cannot say which proposal it meant" -- and the card knows precisely which proposals it is about. Reusing the branch would make the null case mean two unrelated things and leave the next reader unable to tell which callers actually do not know their session. `onOpenProposals: () -> Unit` says the one thing it does. **`hidesOtherDecisions` is untouched in meaning.** It follows the new shape -- `size > 1 && any { it.sessionId == sessionId }`, with the cheap check first -- and still answers the same two questions about a tapped transcript line. No line changes where it goes. **Not covered, deliberately.** The empty-transcript branch gets no card. A proposal writes its own lines into the room that signs, which is whatb977326established, so an awaiting proposal implies a transcript; the branch this skips is the "Break the ice" case, which cannot coexist with one. The card lives and dies with an open room.b977326closed by noting that nothing counts outstanding decisions where a reader can see them before tapping, and that is still true one level up: the home chat list says nothing, and a badge there wants this count somewhere it outlives one open room, which is its own change. `ChatRoomMessagingScreen` still calls `initiate()` inside `key(true) { }` rather than a `LaunchedEffect`, so its collectors are re-launched on recomposition. This adds no observer -- it reads the oneb977326added -- so the exposure is unchanged rather than widened. No tests. What changed is a composable and a navigation callback, and there is no UI test harness here to press a card in. The seam that does have one, `isAwaitingApproval`, is untouched and is already what the proposal list is tested through; the summary this reuses, `ProposedEvent.summarize`, is likewise already covered where it is defined. Verified: :composeApp:compileDebugKotlinAndroid succeeds; 884 tests pass, 565 jvm and 319 android, unchanged from before the change since it adds none. That the card appears exactly when a proposal is owed, and that it lands on the queue, are read from the code rather than asserted -- both want the app on a device in a group that has a key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,14 @@ fun ChatRoomMessagingScreen(
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
onOpenProposals = {
|
||||
onNavigateToRoute.invoke(
|
||||
ProposalListRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,9 +79,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
|
||||
@@ -118,13 +120,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
|
||||
|
||||
/**
|
||||
@@ -142,7 +164,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}")
|
||||
@@ -207,7 +230,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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +359,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(
|
||||
@@ -382,11 +419,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(
|
||||
@@ -729,6 +784,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