feat(dialects): read the dialects an open session is already signing

DialectScaffold.pendingIn, the half of the identity rule that rows cannot
answer. Without it the feature has a race it would hit on first use: two admins
open the picker, both see the same forty missing, both propose, and the room
gets eighty dialects -- forty of them duplicates nothing will ever merge.

Readable because FrostSigningRepository.unsignedEvents gives a batch's events at
proposal time, before any signature, which is exactly when this has to be known.
The lambda is passed in rather than imported, as ProposedEvent.summarize takes a
resolver: it keeps the function pure, testable without a database and callable
from a composable.

COMPLETE and FAILED sessions are both excluded, for opposite reasons. A complete
session's dialects are rows by now, so counting them would hide them behind
"waiting for the group" rather than "already yours". A failed one left nothing
behind and has to be proposable again -- the signing screen says as much, and a
retried batch must be a fresh one anyway.

A session whose items will not parse contributes nothing rather than throwing,
and the trade is named where it is made: its dialects look selectable and may be
proposed twice, which is visible and recoverable, against a picker that will not
open at all, which leaves an admin no way to act. unsignedEventJson is written by
this device from a template it built, so that branch means a damaged database
rather than an odd proposal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-02 22:39:38 +02:00
1 parent 08dc412594
commit f47db49454
2 files changed
+344

No files matched your search

@@ -1,7 +1,11 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.language.AfricanLanguage
import press.mantra.compose.language.AfricanLanguages
import press.mantra.compose.nostr.nip30303.DialectEvent
@@ -73,6 +77,73 @@ object DialectScaffold {
return catalogue.filterNot { keyOf(it) in held }
}
/**
* The catalogue entries an open session is already signing.
*
* Without this the feature has a race it will hit on its first real use: two
* admins open the picker, both see the same forty missing, both propose, and
* the room gets eighty dialects -- forty of them duplicates that nothing will
* ever merge, because nothing makes a dialect unique.
*
* Readable because [unsignedEvents] gives a batch's events *at proposal
* time*, before any signature, which is exactly when this has to be known.
* It is passed in rather than imported for the reason
* `ProposedEvent.summarize` takes a resolver: it keeps this testable without
* a database and callable from a composable.
*
* [FrostSigningStage.COMPLETE] and [FrostSigningStage.FAILED] are both
* excluded, for opposite reasons. A complete session's dialects are rows by
* now, so counting them here would hide them behind the wrong explanation --
* "waiting for the group" rather than "already yours". A failed session left
* nothing behind and must be proposable again; the signing screen tells a
* member as much, and a batch retried has to be a fresh one anyway.
*
* A session whose items cannot be read contributes nothing rather than
* throwing. That is a trade and worth naming: the cost is that its dialects
* look selectable and may be proposed twice, and the alternative is a picker
* that will not open at all. A duplicate dialect is visible and recoverable;
* a dead screen leaves an admin with no way to act. `unsignedEventJson` is
* written by this device from a template it built, so reaching this branch
* means the database is damaged rather than the proposal being odd.
*/
fun pendingIn(
sessions: List<LocalFrostSigningSession>,
unsignedEvents: (List<FrostSigningItem>) -> List<Event>,
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
): List<AfricanLanguage> {
val inFlight = sessions
.filter { it.session.stage in OPEN_STAGES }
.flatMap { session ->
runCatching { unsignedEvents(session.items) }.getOrDefault(emptyList())
}
.mapNotNullTo(mutableSetOf(), ::keyOfDialectEvent)
return catalogue.filter { keyOf(it) in inFlight }
}
/** The stages where a session is still going, and so is still a claim on a dialect. */
private val OPEN_STAGES = setOf(
FrostSigningStage.COLLECTING_NONCES,
FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES,
)
/**
* [event] as a dialect's [Key], or null when it is not a dialect or is
* missing one of the two tags the identity is made of.
*/
private fun keyOfDialectEvent(event: Event): Key? {
if (event.kind != DialectEvent.KIND) return null
val dialect = DialectEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
)
val code = dialect.language() ?: return null
val country = dialect.country() ?: return null
return keyOf(code, country)
}
/**
* One dialect event per entry, in the order given.
*