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, and b977326 gave 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 what b977326
established, 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. b977326 closed 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 one b977326 added -- 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:
Kgothatso Ngako
2026-09-06 21:20:53 +02:00
parent e8a07aadf7
commit 73cba2ae0e
2 changed files with 172 additions and 7 deletions

View File

@@ -179,6 +179,14 @@ fun ChatRoomMessagingScreen(
)
}
)
},
onOpenProposals = {
onNavigateToRoute.invoke(
ProposalListRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
)
}

View File

@@ -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.
*