feat(dialects): a catalogue of the languages most spoken in Africa

Fifty-six entries -- fifty-three African languages and three European ones --
for populating a group's dialects without typing each into a three-field form.
Nothing reads it yet; the plan it belongs to is docs/african-dialect-catalogue.md.

The unit is a language *in a country* rather than a language, because that is
what a dialect is here: `MantraDialect` is (name, country, code), so Kiswahili
is three entries and that is the point rather than a duplication.

(code, country) is the identity and the name is deliberately not part of it.
Nothing anywhere makes a dialect unique -- the id is a content hash including
createdAt, there is no index, and the rows are quorum-signed so they cannot be
rewritten -- so duplication has to be prevented at proposal time, and of the
three fields the name is the only one that is a matter of spelling.

Codes are ISO 639-1 where the language has one and 639-3 otherwise, rather than
639-3 throughout: a uniform `sot` would disagree with the `st` that every room
which used the form has already signed.

Two decisions a reviewer should look at. `lua` names Tshiluba (Luba-Kasai);
639-1's `lu` is Luba-Katanga, a different language of the same country, and
taking the two-letter code because it was there would have named the wrong one.
And the three European languages are a list of their own rather than lines
buried in the African one, so dropping them is one edit.

It lives outside ui/ on purpose. m3-audit scans ui/ and strings.xml for string
literals and title case, and "Northern Sotho" is title case for the same reason
m3-title-case.py keeps a SAMPLE set of proper nouns -- reference data out of
reach is cheaper and more honest than an m3-string-exempt marker on a file that
is not UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 22:34:29 +02:00
parent 5105d8bd3c
commit 9d34a2cc99
3 changed files with 794 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
package press.mantra.compose.language
/**
* A language the catalogue offers, in one of the countries it is spoken in.
*
* The unit is the pair rather than the language. A dialect in this app is
* `(name, country, code)` -- see `MantraDialect` -- so Kiswahili is three
* entries, one for Kenya, one for Tanzania and one for the DR Congo, and that is
* the point rather than a duplication: a group translating for Kenyan readers is
* not translating for Tanzanian ones. The whole reason the kind is called a
* dialect is that it is finer than a language.
*
* [code] and [country] together are the identity. [name] is deliberately not
* part of it: a member typing "Sesotho" into the form and a catalogue saying
* "Sesotho" agree today and disagree the moment one of them carries a diacritic,
* and of the three fields the name is the only one that is a matter of spelling
* rather than of fact. See `DialectScaffold`.
*/
data class AfricanLanguage(
/**
* What the dialect row will be called.
*
* The name a speaker would recognise, which means the endonym wherever that
* is also the usual name in English -- Sesotho, isiZulu, Kiswahili -- and the
* English name where it is not. These are judgment calls and a reviewer is
* welcome to overrule any of them; nothing matches on this field.
*/
val name: String,
/**
* ISO 639-1 where the language has one, ISO 639-3 otherwise.
*
* Not 639-3 throughout, which would be more uniform and worse here: it would
* make this catalogue's `sot` disagree with the `st` that every room which
* used the hand-typed form has already signed, and those rows cannot be
* rewritten -- a quorum signed them. The form's placeholder has always said
* two letters (`eg. st`).
*/
val code: String,
val country: String,
)
/**
* The languages most spoken in Africa, for populating a group's dialects without
* typing each one into a three-field form.
*
* Reference data, deliberately outside `ui/`. `docs/scripts/m3-audit.sh` scans
* `ui/` and `strings.xml` for string literals and title case, and "Northern
* Sotho" is title case for exactly the reason `m3-title-case.py` keeps a
* `SAMPLE` set of proper nouns at all. Keeping the catalogue out of `ui/` is
* cheaper and more honest than an `m3-string-exempt` marker on a file that is
* not UI.
*
* Also not a composeResource: there is no `files/` directory under
* `composeResources`, a bundled JSON would need a parser and a failure mode, and
* a Kotlin list is compile-checked, greppable and testable.
*
* **[ALL] has to stay under `FrostSigningManager.MAX_BATCH_SIZE`** or a wave
* stops being one signing session. `DialectCatalogueSizeTest` is the tripwire,
* because somebody adding a language has no reason to be thinking about FROST.
*
* Not exhaustive and not trying to be. It is the cross-border lingua francas and
* the high-population national languages; a group wanting a language this does
* not list still has the form.
*/
object AfricanLanguages {
/**
* Ordered by country, then by how widely the language is spoken there, so a
* reader scanning for theirs scans once. `AfricanLanguagesTest` holds it to
* that, which is why the European languages are a list of their own: they
* are appended, and appending them would break the grouping.
*/
val african: List<AfricanLanguage> = listOf(
AfricanLanguage("Kabyle", "kab", "Algeria"),
AfricanLanguage("Umbundu", "umb", "Angola"),
AfricanLanguage("Kimbundu", "kmb", "Angola"),
AfricanLanguage("Setswana", "tn", "Botswana"),
AfricanLanguage("Mooré", "mos", "Burkina Faso"),
AfricanLanguage("Kirundi", "rn", "Burundi"),
AfricanLanguage("Sango", "sg", "Central African Republic"),
// `lua` is Luba-Kasai, which is what Tshiluba means. ISO 639-1's `lu` is
// Luba-Katanga, a different language of the same country, and taking the
// two-letter code because it was there would have named the wrong one.
AfricanLanguage("Lingala", "ln", "Democratic Republic of the Congo"),
AfricanLanguage("Kiswahili", "sw", "Democratic Republic of the Congo"),
AfricanLanguage("Tshiluba", "lua", "Democratic Republic of the Congo"),
AfricanLanguage("Kikongo", "kg", "Democratic Republic of the Congo"),
AfricanLanguage("Arabic", "ar", "Egypt"),
AfricanLanguage("Tigrinya", "ti", "Eritrea"),
AfricanLanguage("siSwati", "ss", "Eswatini"),
AfricanLanguage("Amharic", "am", "Ethiopia"),
AfricanLanguage("Oromo", "om", "Ethiopia"),
AfricanLanguage("Akan", "ak", "Ghana"),
AfricanLanguage("Ewe", "ee", "Ghana"),
AfricanLanguage("Ga", "gaa", "Ghana"),
AfricanLanguage("Kiswahili", "sw", "Kenya"),
AfricanLanguage("Gikuyu", "ki", "Kenya"),
AfricanLanguage("Dholuo", "luo", "Kenya"),
AfricanLanguage("Sesotho", "st", "Lesotho"),
AfricanLanguage("Malagasy", "mg", "Madagascar"),
AfricanLanguage("Chichewa", "ny", "Malawi"),
AfricanLanguage("Bambara", "bm", "Mali"),
AfricanLanguage("Arabic", "ar", "Morocco"),
AfricanLanguage("Tamazight", "zgh", "Morocco"),
AfricanLanguage("Emakhuwa", "vmw", "Mozambique"),
AfricanLanguage("Hausa", "ha", "Niger"),
AfricanLanguage("Hausa", "ha", "Nigeria"),
AfricanLanguage("Yoruba", "yo", "Nigeria"),
AfricanLanguage("Igbo", "ig", "Nigeria"),
AfricanLanguage("Kinyarwanda", "rw", "Rwanda"),
AfricanLanguage("Wolof", "wo", "Senegal"),
AfricanLanguage("Fulfulde", "ff", "Senegal"),
AfricanLanguage("Krio", "kri", "Sierra Leone"),
AfricanLanguage("Somali", "so", "Somalia"),
// `nr` is South Ndebele and `nd` is North Ndebele, and both are called
// isiNdebele by their speakers. The country tells them apart, which is
// what a dialect being a language in a place is for.
AfricanLanguage("isiZulu", "zu", "South Africa"),
AfricanLanguage("isiXhosa", "xh", "South Africa"),
AfricanLanguage("Afrikaans", "af", "South Africa"),
AfricanLanguage("Sepedi", "nso", "South Africa"),
AfricanLanguage("Setswana", "tn", "South Africa"),
AfricanLanguage("Xitsonga", "ts", "South Africa"),
AfricanLanguage("siSwati", "ss", "South Africa"),
AfricanLanguage("Tshivenda", "ve", "South Africa"),
AfricanLanguage("isiNdebele", "nr", "South Africa"),
AfricanLanguage("Arabic", "ar", "Sudan"),
AfricanLanguage("Kiswahili", "sw", "Tanzania"),
AfricanLanguage("Luganda", "lg", "Uganda"),
AfricanLanguage("Bemba", "bem", "Zambia"),
AfricanLanguage("Shona", "sn", "Zimbabwe"),
AfricanLanguage("isiNdebele", "nd", "Zimbabwe"),
)
/**
* Not African languages, and among the most spoken on the continent.
*
* Included because a catalogue that cannot offer a group the language it
* actually publishes in is a catalogue that group will not use, and one
* country each because these are the countries the most speakers are in
* rather than everywhere the language is official.
*
* Leaving them out would make this a catalogue of African languages instead,
* which is a defensible thing to want -- so they are a list of their own
* rather than three lines buried in [african], and dropping them is one edit
* to [ALL].
*/
val european: List<AfricanLanguage> = listOf(
AfricanLanguage("French", "fr", "Democratic Republic of the Congo"),
AfricanLanguage("Portuguese", "pt", "Angola"),
AfricanLanguage("English", "en", "Nigeria"),
)
/** Every entry the picker offers. */
val ALL: List<AfricanLanguage> = african + european
}

View File

@@ -0,0 +1,126 @@
package press.mantra.compose.language
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The catalogue, held to the shape the rest of the feature reads it with.
*
* Nothing here is about which languages are in it -- that is a judgment call and
* a reviewer's to make. It is about the invariants `DialectScaffold` matches on,
* every one of which fails silently rather than loudly if it is broken: a
* duplicated pair proposes the same dialect twice in one wave and the two do not
* even collide, because their ids differ by `createdAt`.
*/
class AfricanLanguagesTest {
@Test
fun everyCodeIsAnIsoCodeShape() {
AfricanLanguages.ALL.forEach { language ->
assertEquals(
language.code.lowercase(),
language.code,
"${language.name}'s code is not lowercase, and matching lowercases both sides"
)
assertTrue(
language.code.length == 2 || language.code.length == 3,
"${language.name}'s code ${language.code} is neither ISO 639-1 nor 639-3"
)
assertTrue(
language.code.all { it in 'a'..'z' },
"${language.name}'s code ${language.code} is not letters"
)
}
}
@Test
fun everyEntryIsNamedAndPlaced() {
AfricanLanguages.ALL.forEach { language ->
assertTrue(language.name.isNotBlank(), "an entry for ${language.code} has no name")
assertTrue(
language.country.isNotBlank(),
"${language.name} is not in a country, and a dialect is a language in a place"
)
assertEquals(
language.name.trim(),
language.name,
"${language.name} has whitespace around it, which will end up in a signed event"
)
assertEquals(
language.country.trim(),
language.country,
"${language.name}'s country has whitespace around it"
)
}
}
/**
* The identity rule. Two entries sharing a code and a country are one
* dialect proposed twice, and `DialectScaffold.missingFrom` cannot tell them
* apart because there is nothing to tell apart.
*/
@Test
fun codeAndCountryTogetherAreUnique() {
val duplicates = AfricanLanguages.ALL
.groupBy { it.code to it.country }
.filterValues { it.size > 1 }
assertEquals(
emptyMap(),
duplicates,
"these (code, country) pairs appear more than once"
)
}
/**
* A language in two countries is two entries, which is the whole model. Here
* as an assertion rather than a comment because the uniqueness test above
* reads as though it forbids this, and it does not.
*/
@Test
fun aLanguageMayAppearInSeveralCountries() {
val swahili = AfricanLanguages.ALL.filter { it.code == "sw" }
assertTrue(swahili.size > 1, "Kiswahili is spoken in more than one country")
assertEquals(
swahili.size,
swahili.map { it.country }.distinct().size,
"Kiswahili's entries do not name distinct countries"
)
}
/**
* Grouped by country, as the list says it is.
*
* Contiguity rather than alphabetical order: what a reader needs is for
* their country's languages to be together, and `african` is the list that
* promises it -- the European entries are appended and would break it.
*/
@Test
fun theAfricanEntriesAreGroupedByCountry() {
val countries = AfricanLanguages.african.map { it.country }
val firstSeen = countries.distinct()
assertEquals(
firstSeen,
countries.zipWithNext()
.filter { (previous, next) -> previous != next }
.map { (_, next) -> next }
.let { listOf(countries.first()) + it },
"a country's languages are split apart in the list"
)
}
@Test
fun allIsTheTwoListsAndNothingElse() {
assertEquals(
AfricanLanguages.african + AfricanLanguages.european,
AfricanLanguages.ALL
)
assertTrue(
AfricanLanguages.african.isNotEmpty(),
"a catalogue of African languages with no African languages in it"
)
}
}

View File

@@ -0,0 +1,482 @@
# A catalogue of African languages, and dialects proposed in waves
How a group fills its dialect list from a bundled catalogue of the languages most
spoken in Africa instead of typing each one into a three-field form, and how
admins top that list up later — proposing only what is still missing, in waves,
without two of them proposing the same wave twice.
**Not built.** This is the plan. Seven phases, one commit each.
Two facts shape every phase and are stated before them, because both are easy to
design around and expensive to discover afterwards.
## A dialect is a language *in a place*
`MantraDialect` is `(name, country, language)` — "Sesotho", "Lesotho", `st`. The
`language` column holds an ISO 639 code; the form's placeholder has always said
so (`eg. st`). So the catalogue's unit is not a language but a language paired
with a country, and Swahili-in-Kenya and Swahili-in-Tanzania are two entries.
That is the feature rather than a duplication: a group translating for Kenyan
readers is not translating for Tanzanian ones, and the whole reason this kind is
called a dialect is that it is finer than a language.
## Nothing anywhere makes a dialect unique
A dialect's id is `EventHasher.hashId(pubKey, tags, content, createdAt, kind)`
over the room's key. Two proposals of Sesotho-in-Lesotho a minute apart hash
differently, are two `MantraDialect` rows, and neither is wrong by any rule the
schema knows. There is no unique index, and adding one would reject a legitimate
re-signing as readily as a duplicate.
So **duplication is prevented at proposal time or not at all**, and the handle
has to be `(code, country)`. Not the name: a member typing "Sesotho" and a
catalogue saying "Sesotho" agree today and disagree the moment one of them
carries a diacritic, and the name is the one field of the three that is a matter
of spelling rather than of fact.
## A session signs at most 64 events, all or nothing
`FrostSigningManager.MAX_BATCH_SIZE` is 64 and `MAX_PROPOSAL_BYTES` is 64 KiB,
and a batch that cannot aggregate one item applies none of them —
[frost-batch-signing.md](frost-batch-signing.md) says why, and says a retry must
be a fresh batch rather than the same one again.
The catalogue below is about fifty entries, so a first wave is one session. Both
properties still have to be defended by a test, because a catalogue that grows
to sixty-five silently becomes two sessions and a reviewer adding an entry has no
reason to be thinking about FROST.
The all-or-nothing risk is answered by the same rule that prevents duplication:
because the picker proposes only what is missing, re-proposing a failed wave is
safe and lands exactly the entries the failed one did not. "It is safe to ask
again" is what the signing screen already tells a member of a failed session, and
here it is true without qualification.
---
## Phase 1 — the catalogue, as data
**Half a day. No behaviour change; nothing reads it yet.**
New file,
`composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt`:
```kotlin
/**
* A language the catalogue offers, in one of the countries it is spoken in.
*
* The unit is the pair, not the language: a dialect is `(name, country, code)`,
* so Swahili appears once for Kenya and once for Tanzania. See the note on
* identity in docs/african-dialect-catalogue.md.
*/
data class AfricanLanguage(
/** What the dialect row will be called. The endonym where there is a settled one. */
val name: String,
/** ISO 639-1 where the language has one, ISO 639-3 otherwise. */
val code: String,
val country: String,
)
object AfricanLanguages {
/** Ordered by country, so a reader scanning for theirs scans once. */
val ALL: List<AfricanLanguage> = listOf(/* … */)
}
```
**Where it lives matters.** `press/mantra/compose/language/` rather than
anywhere under `ui/`, because `docs/scripts/m3-audit.sh` scans `ui/` and
`strings.xml` for string literals and title case, and "Northern Sotho" is title
case for exactly the reason `m3-title-case.py` keeps a `SAMPLE` set of proper
nouns at all — it is one. Putting reference data outside `ui/` is cheaper and more honest than an
`m3-string-exempt` marker on a file that is not UI at all.
It is also not a composeResource. There is no `files/` directory under
`composeResources` today, a bundled JSON would need a parser and a failure mode,
and a Kotlin list is compile-checked, greppable and testable. The catalogue is
reference data in the same sense `SMALL` in `m3-title-case.py` is.
**Codes: 639-1 where it exists, 639-3 otherwise.** `st`, `sw`, `zu`, `ha` — and
`nso`, `bem`, `luo`, `mos`, `kab` for the ones ISO never gave two letters. The
alternative, 639-3 throughout, is more uniform and worse here: it would make the
catalogue's `sot` disagree with the `st` every room that used the form has
already signed, and those rows cannot be rewritten — they are signed by a
quorum.
Roughly fifty entries, covering the cross-border lingua francas and the
high-population national languages: Swahili (Tanzania, Kenya), Hausa (Nigeria,
Niger), Yoruba, Igbo, Fulfulde, Amharic, Oromo, Tigrinya, Somali, Arabic (Egypt,
Sudan, Morocco), Zulu, Xhosa, Afrikaans, Northern Sotho, Southern Sotho, Tswana,
Tsonga, Venda, siSwati, Shona, Northern Ndebele, Chichewa, Bemba, Lingala,
Kikongo, Tshiluba, Kinyarwanda, Kirundi, Luganda, Kikuyu, Dholuo, Akan, Ewe, Ga,
Wolof, Bambara, Mooré, Malagasy, Sango, Umbundu, Kimbundu, Makhuwa, Tamazight,
Kabyle, Krio.
**One judgment call to settle in review.** French, Portuguese and English are
among the most spoken languages on the continent and are not African languages.
Including them makes the catalogue useful to groups that actually translate into
them; leaving them out keeps it a catalogue of African languages. The plan
includes three entries — `fr` · DR Congo, `pt` · Angola, `en` · Nigeria — as one
contiguous, clearly commented block, so deleting them is a four-line change
rather than an argument.
**Test:** `composeApp/src/commonTest/.../language/AfricanLanguagesTest.kt` —
every code lowercase and two or three characters, every name and country
non-blank, `(code, country)` unique across the list, and the list ordered as it
claims to be. The uniqueness assertion is the one that earns its place: a
duplicate pair in the catalogue would propose the same dialect twice in one wave,
and the two would not even collide, because their ids differ.
**Acceptance:** `:composeApp:compileDebugKotlinAndroid` and the new test pass;
`:composeApp:m3Audit` unchanged.
---
## Phase 2 — `DialectScaffold`: what is missing, and the events for it
**Half a day. Pure functions. Still nothing calls them.**
New file,
`composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt`,
built as a deliberate parallel to
[`TranslationScaffold`](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt) —
same package, same shape, same reasons:
```kotlin
object DialectScaffold {
/** The catalogue entries this room has no dialect for, and none in flight. */
fun missingFrom(
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
existing: List<MantraDialect>,
pending: List<AfricanLanguage> = emptyList(),
): List<AfricanLanguage>
/** One dialect event per entry, stamped with one timestamp for the wave. */
fun dialectsOf(
languages: List<AfricanLanguage>,
createdAt: Long,
): List<EventTemplate<DialectEvent>>
}
```
`missingFrom` matches on `(code, country)` normalised with `trim()` and
`lowercase()` — the no-argument `lowercase()` deliberately, which in Kotlin
common is locale-invariant, so a Turkish device does not decide that `TI` and
`ti` are different languages. The name is ignored for matching and carried
through for display.
`dialectsOf` takes one `createdAt` for the whole list rather than reading the
clock per template, for the reason `TranslationScaffold.chaptersOf` does: a wave
proposed as one act should read as one act, not as fifty dialects that happen to
share a minute. It also makes the wave's event ids a function of its inputs,
which is what makes the test below possible.
**Test:** `composeApp/src/commonTest/.../managers/DialectScaffoldTest.kt`,
mirroring `TranslationScaffoldTest`:
- a room with no dialects is missing the whole catalogue;
- a room holding `st` · Lesotho is missing everything else and not that;
- a row whose code is ` ST ` still counts as held;
- a row whose *name* is spelled differently but whose code and country match
still counts as held — the assertion that pins the identity rule;
- `sw` · Kenya held does not make `sw` · Tanzania held;
- a row with a code the catalogue does not list changes nothing;
- `dialectsOf` emits `DialectEvent.KIND`, in catalogue order, all with the one
`createdAt`, and each round-trips through `MantraDialect.fromDialectEventTemplate`
back to the name, country and code it came from.
**Acceptance:** compile and the new test pass.
---
## Phase 3 — what is already in flight
**Half a day. Read-only; the first phase that touches a repository.**
A catalogue entry is in one of three states, and the picker is wrong without all
three:
| state | how it is known | selectable |
|---|---|---|
| in the room | a `MantraDialect` row for `(code, country)` | no |
| in flight | an open signing session holds a kind-30304 event for `(code, country)` | no |
| missing | neither | yes |
Without "in flight" 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.
Everything needed is already on `FrostSigningRepository`:
`observeSessionsForChatRoom(chatRoomId)` gives `LocalFrostSigningSession`, each
with its `items`, and `unsignedEvents(items)` turns those into events **at
proposal time** — before any signature, which is precisely when this has to be
readable.
Add one more pure function to `DialectScaffold`:
```kotlin
/**
* The catalogue entries an open session is already signing.
*
* `FrostSigningStage.FAILED` and `COMPLETE` are both excluded, for opposite
* reasons: a failed wave left nothing behind and must be proposable again --
* the signing screen says as much -- and a complete one is a row by now, so
* counting it here would hide it behind the wrong explanation.
*/
fun pendingIn(
sessions: List<LocalFrostSigningSession>,
unsignedEvents: (List<FrostSigningItem>) -> List<Event>,
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
): List<AfricanLanguage>
```
The `unsignedEvents` lambda is passed in rather than imported, for the reason
`ProposedEvent.summarize` takes `resolve`: it keeps the function pure and
testable without a database behind it.
**Test:** `commonTest`, with hand-built `FrostSigningItem` rows holding
`DialectEvent.build(...)` JSON. Cases: an open session's dialects are pending; a
`FAILED` session's are not; a `COMPLETE` session's are not; a session signing
chapters contributes nothing; an item whose JSON will not parse is skipped rather
than throwing — a malformed item must not take the picker down with it.
**Acceptance:** compile and the new test pass. No wire change, no schema change.
---
## Phase 4 — the picker
**Two days. The screen, and the way in.**
A new pushed screen beside the hand-typed form, which stays exactly as it is.
Five files, following the house pattern (route · state · view model · screen ·
registration) and `SelectSubgroupAdminsScreen` for the multi-select mechanics:
- `ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt` —
`activeUserPublicKey`, `chatRoomId`, `relayHint`, as `AddDialectRoute` has.
- `ui/view/state/AddDialectsFromCatalogueUIState.kt` — `Loading` / `Error` /
`Loaded(localChatRoom, rows: List<CatalogueRow>, selected: Set<String>, canSign)`,
where a `CatalogueRow` is an `AfricanLanguage` plus its state from the table in
phase 3.
- `ui/view/model/AddDialectsFromCatalogueViewModel.kt`
- `ui/composable/AddDialectsFromCatalogueScreen.kt`
- the `composable<…>` arm in `MantraNavHost.kt`, copying `AddDialectRoute`'s
`popUpTo` so back does not return to a form whose proposal has gone out.
**State goes in a `StateFlow`, not `mutableStateOf`.** The existing
`AddDialectViewModel` uses `mutableStateOf` and gets away with it because it
writes the state once. This screen does not: the loaded state is written from
`Dispatchers.IO`, *and* a collector on `observeSessionsForChatRoom` folds the
pending list into it, *and* the UI read-modify-writes the selection. That is the
case where a `mutableStateOf` in a view model loses writes — a
`MutableStateFlow` with `update { }` is the shape that does not.
The selection may live beside it as `mutableStateListOf<String>()` of
`"$code|$country"` keys, which is what `SelectSubgroupAdminsViewModel` does with
`selectedPublicKeys`; the point is that it is not folded into a `mutableStateOf`
holding the whole UI state.
**What the screen shows.** A `TopAppBar` with `NavigateBackButton` (it is pushed;
the desktop target has no system back), a search field filtering on name, code
and country, and a `LazyColumn` of `ListItem` rows grouped by country. Each row
by state:
- **missing** — a `Checkbox`, enabled, toggling selection;
- **in the room** — a `Checkbox(checked = true, enabled = false, onCheckedChange = null)`
and a supporting line saying so, exactly as the subgroup screen renders the
coordinator who is an admin by construction;
- **in flight** — disabled, `Modifier.semantics { disabled() }`, supporting line
"waiting for the group to sign", with the pending wave reachable from the
room's proposal list.
A header line carries the count — "12 of 50 selected" — and a "select everything
missing" action, which is the whole convenience the feature is named for.
All four screen states, per the house rules: `ScreenStateTransition` wrapping the
`when`, `ErrorState` with a real `onRetry` (re-running `initiate()` is a genuine
retry here, so passing null would be the wrong decision), `LoadingDataIndicator`,
and an `EmptyState` with its own message for the case worth getting right — a
room that already holds the whole catalogue. "No dialects found" would be a lie
there; the message is that the catalogue has nothing left to add and the form is
the way to add one it does not list, with that form as the `EmptyState`'s
`action`.
`canSign` gates the FAB the way `AddDialectScreen` does, with the same
explanation of why, because a group with no shared key cannot sign a dialect into
existence at all.
**Spacing from `MaterialTheme.spacing`, colour from roles, strings from
`strings.xml` in sentence case, every target 48dp, every icon's
`contentDescription` decided, `Modifier.padding(innerPadding).readableContent()`
on the content root.** About a dozen new strings. The language names are *not*
among them: they come from the catalogue, which is data.
**The way in:** a second `TextButton` under the dialect list on
`ChatRoomDetailScreen`, beside "Add dialect" — "Add dialects from a list". The
existing button is untouched.
**Test:** a `@ConformancePreviews` preview per state, as every screen here has,
with `FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY`. Row-state logic is
phase 3's and is tested there rather than through the composable.
**Acceptance:** `:composeApp:m3Audit` reports no new budget breach and no drop
below either floor; `compileDebugKotlinAndroid` and `jvmTest` pass; the previews
render in all six schemes.
---
## Phase 5 — proposing the wave
**A day. The first phase that changes what a group sees.**
One method on the view model, built on
`TranslationArtifactVersionDetailViewModel.catchUpMissingChapters` — which is the
same problem, down to the chunking:
```kotlin
fun proposeSelectedDialects(
localChatRoom: LocalChatRoom,
selected: List<AfricanLanguage>,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit,
)
```
- empty selection fails fast rather than opening an empty session;
- `isActionPending` guards the double tap, as every proposing view model here
does;
- one `TimeUtils.now()` for the wave;
- `selected.chunked(FrostSigningManager.MAX_BATCH_SIZE)`, one
`proposeSigningBatch(localChatRoom, userPublicKey, events)` per chunk — the
flat form, since dialects name nothing and need no lead;
- navigate to the **first** session with `popUpTo<AddDialectsFromCatalogueRoute> { inclusive = true }`,
the rest being in the room's proposal list beside it;
- the `onSuccess` callback invoked from `Dispatchers.Main`. This is not
decoration: the callback navigates, and a navigation from `Dispatchers.IO`
crashes. The handoff belongs inside the view model, which is where the
existing ones put it.
With fifty entries the chunking never fires. It is one line, it is the
established idiom, and it is what stops the sixty-fifth catalogue entry from
being a bug instead of a second session.
Nothing else needs changing to make a wave readable.
`ProposedEvent.summarize` already has its `DialectEvent.KIND` arm — "New dialect"
over "Sesotho · Lesotho · st" — and `WhatIsBeingSigned` already leads a batch
with "N events signed together" and renders every item, refusing to let any of it
be signed unless all of it parsed. Fifty two-line blocks in a scrolling `Column`
is a long screen, and that is the correct length: a member is being asked to put
the group's name to fifty dialects, and the batch is all-or-nothing.
**Test:** `composeApp/src/jvmTest/.../managers/DialectWaveProposalJvmTest.kt`,
modelled on `TranslationBatchProposalJvmTest` — over a real in-memory database
and a real key, assert that a wave of N opens one session with N items in
catalogue order, that each item's unsigned event is a kind-30304 whose tags round
-trip to the catalogue entry, that the events share one `createdAt`, and that a
selection of 65 opens two sessions with 64 and 1.
**Acceptance:** the above, plus a manual pass: pick five, propose, approve on
enough devices, see five dialect rows appear on the group detail screen.
---
## Phase 6 — one transcript line for a wave, not fifty
**Half a day. A behaviour fix the previous phase makes necessary.**
`FrostSigningManager.applySignedEvent` hands each signed event to
`ChatMessage.applyInnerEvent` and files whatever chat line comes back. The
dialect arm returns one — "Added Sesotho to dialects" — so a wave of fifty posts
fifty lines into the group's conversation, followed by the session's own "The
group signed 50 events".
This is not a problem the existing batches have, and the reason is instructive.
Of the kinds `applyInnerEvent` files a line for — chat, artifact, chapter,
dialect, translation, project — every one that can appear many-per-batch already
returns null for exactly this reason: a chunk is part of a chapter and the
chapter's line speaks for it; a translation chapter, a contributor list and both
project memberships likewise. `DialectEvent` is the first kind where a batch
holds many peers each of which wants a line of its own.
So the rule, stated once where the batch is visible:
> A signed event files its chat line unless the batch holds more than one event
> of its kind, in which case the session's completion line speaks for them.
Implemented as a `fileChatLine: Boolean` parameter on the private
`applySignedEvent`, computed in its one caller as
`signedEvents.count { it.kind == signedEvent.kind } == 1`. `applyInnerEvent` is
untouched — it still writes the `MantraDialect` row, which is the part that
matters — and only the `chatMessageDao().upsert` is skipped.
Checked against every batch the app builds today, this changes nothing: a chapter
batch is one `ChapterEvent` and N `ChunkEvent`s that file nothing; a translation
batch is one `TranslationArtifactVersionEvent` and N translation chapters that
file nothing; a project batch is one `ProjectEvent` and N memberships that file
nothing. The only batch it affects is the one phase 5 introduces.
`ChronicleManager.applyPage` needs nothing: it already drops the chat line a
payload returns, and says why.
**Test:** extend the jvmTest that covers `applySignedEvent`'s effects — a batch
of one dialect still files its line; a batch of three files none and still writes
three `MantraDialect` rows; a batch of a chapter and its chunks still files the
chapter's line.
**Acceptance:** the above, and a manual pass showing one completion line rather
than fifty.
---
## Phase 7 — the guards that keep it true
**Half a day.**
`composeApp/src/commonTest/.../language/DialectCatalogueSizeTest.kt`, modelled on
[`ChapterProposalSizeTest`](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt):
- the whole catalogue fits one batch —
`AfricanLanguages.ALL.size <= FrostSigningManager.MAX_BATCH_SIZE`;
- the whole catalogue as a proposal fits `MAX_PROPOSAL_BYTES`, measured the way
the manager measures it rather than estimated;
- and, because the first assertion will eventually fail on purpose, a failure
message that says what to do about it: the chunking in phase 5 already handles
it, and the test is lowered to a note rather than the catalogue trimmed.
Then the two commands from `CLAUDE.md`, and a line in
[docs/README.md](README.md) pointing here.
```bash
./gradlew :composeApp:m3Audit
```
```bash
ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest
```
---
## What this deliberately does not do
**No schema change, no migration, no new event kind, no wire change.** The
feature is a different way of building events the group already signs. Database
version stays at 22.
**No uniqueness constraint on `MantraDialect`.** It would reject a re-signing as
readily as a duplicate, and the duplicate is prevented at proposal time instead —
see the note at the top.
**No merging of dialects that are already duplicated.** A room that typed
"Sesotho" by hand and then selects it from the catalogue gets one row, because
the codes match. A room holding two rows from before this feature keeps both;
nothing here can remove a row a quorum signed.
**No admin role.** The user's framing was that admins propose in waves, and in
this app the gate on proposing is `canSign` — holding a share of the group's key
— rather than a role. Every member who can sign can propose a wave, and a quorum
decides. Introducing a dialect-proposing role would be a change to what a group
is, not a convenience method.
**No automatic wave.** Nothing proposes the catalogue on a room's behalf when it
is created. A dialect is a statement the group signs about what it will
translate into, and fifty of them arriving unasked is a statement nobody made.