diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt new file mode 100644 index 00000000..c2289d7a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt @@ -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 = 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 = 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 = african + european +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/language/AfricanLanguagesTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/language/AfricanLanguagesTest.kt new file mode 100644 index 00000000..b60820ce --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/language/AfricanLanguagesTest.kt @@ -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" + ) + } +} diff --git a/docs/african-dialect-catalogue.md b/docs/african-dialect-catalogue.md new file mode 100644 index 00000000..2aad2d92 --- /dev/null +++ b/docs/african-dialect-catalogue.md @@ -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 = 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 = AfricanLanguages.ALL, + existing: List, + pending: List = emptyList(), + ): List + + /** One dialect event per entry, stamped with one timestamp for the wave. */ + fun dialectsOf( + languages: List, + createdAt: Long, + ): List> +} +``` + +`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, + unsignedEvents: (List) -> List, + catalogue: List = AfricanLanguages.ALL, +): List +``` + +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, selected: Set, 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()` 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, + 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 { 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.