From 9d34a2cc99f49d9e97d29a6af4c2366cdc20fa55 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:34:29 +0200 Subject: [PATCH 1/8] 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 --- .../compose/language/AfricanLanguage.kt | 186 +++++++ .../compose/language/AfricanLanguagesTest.kt | 126 +++++ docs/african-dialect-catalogue.md | 482 ++++++++++++++++++ 3 files changed, 794 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/language/AfricanLanguagesTest.kt create mode 100644 docs/african-dialect-catalogue.md 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. From 08dc41259458478351cf51d9606ea885ada42c00 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:36:38 +0200 Subject: [PATCH 2/8] feat(dialects): work out what a room is missing from the catalogue DialectScaffold.missingFrom and .dialectsOf, built as a deliberate parallel to TranslationScaffold -- same package, same shape, same reason. "What is this room missing" gets asked by the picker, again when a wave comes back, and a third time by whatever reconciles the two; three copies would be three chances to disagree. Matching is on (code, country) and never the name, which is the rule the eleven tests exist to pin. A room that typed "Southern Sotho" where the catalogue says "Sesotho" still holds st-in-Lesotho, and proposing it again would make a duplicate that nothing can merge -- the two would not even collide, since their ids differ by createdAt. Normalisation is trim + lowercase(), the no-argument form on purpose: it is locale-invariant in Kotlin common, so a device set to Turkish does not decide that TI and ti are different languages. dialectsOf takes one createdAt for the whole wave rather than reading the clock per event, as TranslationScaffold.chaptersOf does, so a wave reads as the one act it is. That also makes its ids a function of its inputs, which is what lets the test assert the round trip back through fromDialectEventTemplate: what the picker chose is what the row says once the group has signed it. Pure and takes everything it reads, so it is testable without a database and a composable can ask it. Co-Authored-By: Claude Opus 5 --- .../compose/managers/DialectScaffold.kt | 100 +++++++++ .../compose/managers/DialectScaffoldTest.kt | 190 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectScaffoldTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt new file mode 100644 index 00000000..37efcea3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt @@ -0,0 +1,100 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.language.AfricanLanguage +import press.mantra.compose.language.AfricanLanguages +import press.mantra.compose.nostr.nip30303.DialectEvent + +/** + * Which catalogue languages a room has no dialect for, and the events to propose + * them with. + * + * Built as a deliberate parallel to [TranslationScaffold], for the same reason: + * the question "what is this room missing" is asked from the picker, answered + * again when a wave comes back, and will be asked a third time by whatever + * reconciles the two. Three copies of it would be three chances to disagree + * about what a room already holds. + * + * Pure, and takes everything it reads. There is no repository here because the + * answer has to be computable from a list of rows and a list of open proposals, + * which is what makes it testable without a database and what lets a composable + * ask it. + */ +object DialectScaffold { + + /** + * What makes two dialects the same one: the language code and the country. + * + * Not the name. Nothing in the schema makes a dialect unique -- its id is a + * content hash that includes `createdAt`, there is no index, and the rows are + * signed by a quorum so they cannot be rewritten -- so duplication is + * prevented here or not at all, and of the three fields the name is the only + * one that is a matter of spelling. A member typing "Sesotho" and a catalogue + * saying "Sesotho" agree until one of them carries a diacritic. + */ + data class Key(val code: String, val country: String) + + /** + * [code] and [country] as a [Key], trimmed and lowercased. + * + * `lowercase()` without a locale, deliberately: the no-argument form is + * locale-invariant in Kotlin common, so a device set to Turkish does not + * decide that `TI` and `ti` are different languages. + */ + fun keyOf(code: String, country: String): Key = Key( + code = code.trim().lowercase(), + country = country.trim().lowercase(), + ) + + fun keyOf(language: AfricanLanguage): Key = keyOf(language.code, language.country) + + /** The dialect's `language` column is its code -- see [DialectEvent]'s language tag. */ + fun keyOf(dialect: MantraDialect): Key = keyOf(dialect.language, dialect.country) + + /** + * The catalogue entries this room has no dialect for and none in flight, in + * catalogue order. + * + * [pending] is what an open signing session is already signing, which the + * caller works out with [pendingIn]. Leaving it empty is not a safe default + * dressed as one -- it is the right answer for a caller that only wants to + * know what the *rows* say, and the wrong one for a picker, where two admins + * both seeing the same forty missing is how a room ends up with eighty + * dialects that nothing will ever merge. + */ + fun missingFrom( + existing: List, + pending: List = emptyList(), + catalogue: List = AfricanLanguages.ALL, + ): List { + val held = existing.mapTo(mutableSetOf(), ::keyOf) + pending.map(::keyOf) + + return catalogue.filterNot { keyOf(it) in held } + } + + /** + * One dialect event per entry, in the order given. + * + * [createdAt] is stamped on all of them rather than read per event, for the + * reason [TranslationScaffold.chaptersOf] does it: 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 a batch assertable. + * + * `DialectEvent.build`'s `language` parameter is the ISO code, not the name + * -- the event's language tag has always held the code, and the form's + * placeholder says so. + */ + fun dialectsOf( + languages: List, + createdAt: Long, + ): List> = languages.map { language -> + DialectEvent.build( + name = language.name, + country = language.country, + language = language.code, + createdAt = createdAt, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectScaffoldTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectScaffoldTest.kt new file mode 100644 index 00000000..caa1e1f3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectScaffoldTest.kt @@ -0,0 +1,190 @@ +package press.mantra.compose.managers + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.language.AfricanLanguage +import press.mantra.compose.language.AfricanLanguages +import press.mantra.compose.nostr.nip30303.DialectEvent + +/** + * What a room is missing from the catalogue, as a pure function. + * + * Every assertion here is about the identity rule, because that rule is the only + * thing standing between this feature and a room full of duplicate dialects. + * Nothing in the schema makes a dialect unique, and two proposals of the same + * one do not even collide -- their ids differ by `createdAt` -- so a match this + * function misses is a duplicate nobody will ever merge. + */ +class DialectScaffoldTest { + + private val chatRoomId = "r".repeat(64) + + private fun dialect( + name: String, + code: String, + country: String, + ) = MantraDialect( + id = "$name$code$country".padEnd(64, 'f').take(64), + publicKey = "p".repeat(64), + name = name, + country = country, + language = code, + signature = "", + chatRoomId = chatRoomId, + ) + + private val sesotho = AfricanLanguage("Sesotho", "st", "Lesotho") + private val swahiliKenya = AfricanLanguage("Kiswahili", "sw", "Kenya") + private val swahiliTanzania = AfricanLanguage("Kiswahili", "sw", "Tanzania") + + private val catalogue = listOf(sesotho, swahiliKenya, swahiliTanzania) + + @Test + fun aRoomWithNoDialectsIsMissingTheWholeCatalogue() { + assertEquals( + AfricanLanguages.ALL, + DialectScaffold.missingFrom(existing = emptyList()) + ) + } + + @Test + fun aHeldDialectIsNotMissingAndTheRestStillAre() { + assertEquals( + listOf(swahiliKenya, swahiliTanzania), + DialectScaffold.missingFrom( + existing = listOf(dialect("Sesotho", "st", "Lesotho")), + catalogue = catalogue, + ) + ) + } + + /** Typed by hand, with the whitespace and the capitals a person leaves behind. */ + @Test + fun aHeldDialectStillCountsThroughWhitespaceAndCase() { + assertEquals( + listOf(swahiliKenya, swahiliTanzania), + DialectScaffold.missingFrom( + existing = listOf(dialect("sesotho", " ST ", " lesotho ")), + catalogue = catalogue, + ) + ) + } + + /** + * The assertion that pins the identity rule. A room that typed the name + * differently still holds the dialect, and proposing it again would be the + * duplicate this whole function exists to avoid. + */ + @Test + fun theNameIsNotPartOfTheIdentity() { + assertEquals( + listOf(swahiliKenya, swahiliTanzania), + DialectScaffold.missingFrom( + existing = listOf(dialect("Southern Sotho", "st", "Lesotho")), + catalogue = catalogue, + ) + ) + } + + /** A dialect is a language in a place, so one country does not answer for another. */ + @Test + fun theSameLanguageInAnotherCountryIsStillMissing() { + assertEquals( + listOf(sesotho, swahiliTanzania), + DialectScaffold.missingFrom( + existing = listOf(dialect("Kiswahili", "sw", "Kenya")), + catalogue = catalogue, + ) + ) + } + + @Test + fun aDialectTheCatalogueDoesNotListChangesNothing() { + assertEquals( + catalogue, + DialectScaffold.missingFrom( + existing = listOf(dialect("Nheengatu", "yrl", "Brazil")), + catalogue = catalogue, + ) + ) + } + + @Test + fun whatIsPendingIsNotMissing() { + assertEquals( + listOf(swahiliTanzania), + DialectScaffold.missingFrom( + existing = listOf(dialect("Sesotho", "st", "Lesotho")), + pending = listOf(swahiliKenya), + catalogue = catalogue, + ) + ) + } + + @Test + fun aRoomHoldingEverythingIsMissingNothing() { + assertEquals( + emptyList(), + DialectScaffold.missingFrom( + existing = catalogue.map { dialect(it.name, it.code, it.country) }, + catalogue = catalogue, + ) + ) + } + + @Test + fun dialectsOfEmitsOneDialectEventPerEntryInOrder() { + val templates = DialectScaffold.dialectsOf(catalogue, createdAt = 1_700_000_000L) + + assertEquals(catalogue.size, templates.size) + assertTrue(templates.all { it.kind == DialectEvent.KIND }) + assertTrue( + templates.all { it.createdAt == 1_700_000_000L }, + "a wave proposed as one act should not read as entries that share a minute" + ) + } + + /** + * The round trip the feature actually depends on: what the picker chose is + * what the row will say once the group has signed it. + */ + @Test + fun eachTemplateRoundTripsBackToItsCatalogueEntry() { + val templates = DialectScaffold.dialectsOf(catalogue, createdAt = 1_700_000_000L) + + val rows = templates.mapNotNull { template -> + MantraDialect.fromDialectEventTemplate( + dialectEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = "k".repeat(64), + ) + } + + assertEquals(catalogue.size, rows.size, "a template did not parse back into a row") + assertEquals( + catalogue, + rows.map { AfricanLanguage(name = it.name, code = it.language, country = it.country) } + ) + } + + /** + * Two entries of one wave are two different events. Obvious, and worth an + * assertion: they share a `createdAt` and an author by construction, so the + * only thing separating their ids is their tags. + */ + @Test + fun entriesOfOneWaveGetDistinctIds() { + val rows = DialectScaffold.dialectsOf(catalogue, createdAt = 1_700_000_000L) + .mapNotNull { template -> + MantraDialect.fromDialectEventTemplate( + dialectEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = "k".repeat(64), + ) + } + + assertEquals(rows.size, rows.map { it.id }.distinct().size) + } +} From f47db494541839868022324d0a8beeff07f4d933 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:39:38 +0200 Subject: [PATCH 3/8] 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 --- .../compose/managers/DialectScaffold.kt | 71 +++++ .../compose/managers/DialectsInFlightTest.kt | 273 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt index 37efcea3..23d0cf7e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt @@ -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, + unsignedEvents: (List) -> List, + catalogue: List = AfricanLanguages.ALL, + ): List { + 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. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt new file mode 100644 index 00000000..858c796e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectsInFlightTest.kt @@ -0,0 +1,273 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertEquals +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +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.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.DialectEvent + +/** + * The dialects an open signing session is already signing. + * + * This is the half of the identity rule that rows cannot answer. Two admins open + * the picker, both see the same forty missing and both propose: without reading + * what is in flight the room gets eighty dialects, forty of them duplicates that + * nothing will ever merge. `FrostSigningRepository.unsignedEvents` makes a + * batch readable at proposal time, which is exactly when it has to be. + */ +class DialectsInFlightTest { + + private val groupKey = "a".repeat(64) + private val chatRoomId = "r".repeat(64) + + private val sesotho = AfricanLanguage("Sesotho", "st", "Lesotho") + private val swahiliKenya = AfricanLanguage("Kiswahili", "sw", "Kenya") + private val swahiliTanzania = AfricanLanguage("Kiswahili", "sw", "Tanzania") + + private val catalogue = listOf(sesotho, swahiliKenya, swahiliTanzania) + + private fun unsigned(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + + /** What the repository does, so the lambda under test is the real one. */ + private val unsignedEvents: (List) -> List = + { items -> items.map { Event.fromJson(it.unsignedEventJson) } } + + private fun session( + id: String, + stage: FrostSigningStage, + json: List, + ) = LocalFrostSigningSession( + session = FrostSigningSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + dkgSessionId = "d".repeat(64), + threshold = 2, + participantCount = 3, + signerId = 1, + stage = stage, + ), + unorderedItems = json.mapIndexed { index, eventJson -> + FrostSigningItem( + sessionId = id, + itemIndex = index, + unsignedEventJson = eventJson, + eventId = "e$index".padEnd(64, 'f').take(64), + nonceRandom = "n$index".padEnd(64, 'f').take(64), + ) + }, + ) + + private fun dialectJson(language: AfricanLanguage) = unsigned( + DialectScaffold.dialectsOf(listOf(language), createdAt = 1_700_000_000L).single() + ).toJson() + + private fun waveFor( + languages: List, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + id: String = "s".repeat(64), + ) = session(id = id, stage = stage, json = languages.map(::dialectJson)) + + @Test + fun anOpenSessionsDialectsArePending() { + assertEquals( + listOf(sesotho, swahiliKenya), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(sesotho, swahiliKenya))), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun aSessionCollectingSignaturesIsStillOpen() { + assertEquals( + listOf(sesotho), + DialectScaffold.pendingIn( + sessions = listOf( + waveFor( + listOf(sesotho), + stage = FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES + ) + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** It left nothing behind, so it has to be proposable again. */ + @Test + fun aFailedSessionsDialectsAreNotPending() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(sesotho), stage = FrostSigningStage.FAILED)), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** + * Its dialects are rows by now, so counting them here would hide them behind + * "waiting for the group" rather than "already yours". + */ + @Test + fun aCompleteSessionsDialectsAreNotPending() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(sesotho), stage = FrostSigningStage.COMPLETE)), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun twoOpenWavesAreReadTogether() { + assertEquals( + listOf(sesotho, swahiliTanzania), + DialectScaffold.pendingIn( + sessions = listOf( + waveFor(listOf(sesotho), id = "1".repeat(64)), + waveFor(listOf(swahiliTanzania), id = "2".repeat(64)), + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun aSessionSigningSomethingElseContributesNothing() { + val chapter = unsigned( + ChapterEvent.build( + artifactVersionId = "b".repeat(64), + name = "Chapter 1", + originalText = "Paragraph.", + index = 0, + wordCount = 1, + characterCount = 10, + ) + ).toJson() + + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf( + session( + id = "s".repeat(64), + stage = FrostSigningStage.COLLECTING_NONCES, + json = listOf(chapter), + ) + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun aDialectTheCatalogueDoesNotListIsNotReported() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(AfricanLanguage("Nheengatu", "yrl", "Brazil")))), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** + * A damaged session must not take the picker down with it, and the other + * session's dialects still have to be read. See the trade named on + * `pendingIn`: its own dialects look selectable again, which is recoverable, + * and a screen that will not open is not. + */ + @Test + fun anUnreadableSessionIsSkippedRatherThanThrown() { + assertEquals( + listOf(swahiliKenya), + DialectScaffold.pendingIn( + sessions = listOf( + session( + id = "1".repeat(64), + stage = FrostSigningStage.COLLECTING_NONCES, + json = listOf("not an event"), + ), + waveFor(listOf(swahiliKenya), id = "2".repeat(64)), + ), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + @Test + fun noSessionsMeansNothingPending() { + assertEquals( + emptyList(), + DialectScaffold.pendingIn( + sessions = emptyList(), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + ) + } + + /** The two halves together, which is how the picker reads them. */ + @Test + fun missingExcludesBothWhatIsHeldAndWhatIsInFlight() { + val pending = DialectScaffold.pendingIn( + sessions = listOf(waveFor(listOf(swahiliKenya))), + unsignedEvents = unsignedEvents, + catalogue = catalogue, + ) + + assertEquals( + listOf(swahiliTanzania), + DialectScaffold.missingFrom( + existing = listOf( + MantraDialect( + id = "d".repeat(64), + publicKey = groupKey, + name = "Sesotho", + country = "Lesotho", + language = "st", + signature = "", + chatRoomId = chatRoomId, + ) + ), + pending = pending, + catalogue = catalogue, + ) + ) + } +} From 2efe320564468295a3b6c8e8ac69497a7a2237e3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:49:14 +0200 Subject: [PATCH 4/8] feat(dialects): a screen for picking dialects out of the catalogue Route, state, view model, screen and the way in from the group's detail screen, beside "Add dialect" rather than instead of it. It shows and picks; proposing is the next commit. Three standings per row, not two. A language the room holds and a language it is already signing both show and neither can be ticked, with different words on each, because "not available" has two explanations and telling a member the wrong one is worse than telling them nothing: one is theirs already, and the other is a proposal somebody else opened that they can go and sign. The standing is derived from DialectScaffold.missingFrom rather than computed beside it. That function is what the proposal will be built from, so a row saying MISSING where it disagreed would offer a dialect the room already has -- and the duplicate would not even collide, since dialect ids differ by createdAt. One authority; held-versus-in-flight is only a label on what it already excluded. The view model holds its state in a StateFlow rather than the mutableStateOf its neighbours use, and not for consistency's sake: those write once, and this one is written from Dispatchers.IO on the way in *and* again from a collector on the room's signing sessions, which is where a mutableStateOf in a view model drops an off-main-thread write made during the first composition. Anything that stops being missing while the screen is open comes off the selection, the way SelectSubgroupAdminsViewModel unpicks a member who turns out to have no key package. Another admin proposing a language this member had ticked would otherwise propose it twice. "Select everything missing" means the catalogue and not the filtered rows: a member who has typed "so" into the search and taps it means all of it, and a selection that silently depended on a filter is the harder of the two to notice. Twelve strings, sentence case, and one of them replaces the literal AddDialectScreen had been carrying for the same sentence about a group with no shared key. A clickable Card rather than a bare clickable modifier, so a row is 48dp without anybody having to remember minimumInteractiveComponentSize. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 12 + .../compose/ui/composable/AddDialectScreen.kt | 7 +- .../AddDialectsFromCatalogueScreen.kt | 433 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 34 ++ .../ui/composable/navigation/MantraNavHost.kt | 20 + .../routes/AddDialectsFromCatalogueRoute.kt | 19 + .../AddDialectsFromCatalogueViewModel.kt | 209 +++++++++ .../state/AddDialectsFromCatalogueUIState.kt | 117 +++++ .../compose/ui/view/state/CatalogueRowTest.kt | 102 +++++ 9 files changed, 951 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectsFromCatalogueUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/CatalogueRowTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a0aa8b78..93b2a8d3 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -569,4 +569,16 @@ Abandoned. No key was created. %1$s of %2$s members will sign with it Set up a shared key + + Add dialects from a list + Pick the languages this group will translate into. Ones it already has, and ones it is already signing, cannot be picked again. + Search by language, country or code + %1$s of %2$s selected + Select everything missing + Clear the selection + Propose %1$s dialects + Already in this group + Waiting for the group to sign this one + This group already has a dialect for every language in the list. Add one it does not list with the form. + This group has no shared key, so it cannot sign a dialect into existence. Run a shared key ceremony first. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index 97e8e4d7..6485a573 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -60,6 +60,7 @@ import mantra.composeapp.generated.resources.eg_sesotho import mantra.composeapp.generated.resources.eg_st import mantra.composeapp.generated.resources.language import mantra.composeapp.generated.resources.propose_dialect +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot_sign_a_dialect import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton @@ -179,8 +180,10 @@ fun AddDialectScreen( if (!addDialectUIState.canSign) { Text( - text = "This group has no shared key, so it cannot sign a " + - "dialect into existence. Run a shared key ceremony first.", + text = stringResource( + Res.string + .this_group_has_no_shared_key_so_it_cannot_sign_a_dialect + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt new file mode 100644 index 00000000..0cf848c6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt @@ -0,0 +1,433 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.add_dialect +import mantra.composeapp.generated.resources.add_dialects_from_a_list +import mantra.composeapp.generated.resources.already_in_this_group +import mantra.composeapp.generated.resources.clear_the_selection +import mantra.composeapp.generated.resources.nothing_matched_that_search +import mantra.composeapp.generated.resources.pick_the_languages_this_group_will_translate_into +import mantra.composeapp.generated.resources.s_of_s_selected +import mantra.composeapp.generated.resources.search_by_language_country_or_code +import mantra.composeapp.generated.resources.select_everything_missing +import mantra.composeapp.generated.resources.this_group_already_has_every_language_in_the_list +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot_sign_a_dialect +import mantra.composeapp.generated.resources.waiting_for_the_group_to_sign_this_one +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.language.AfricanLanguages +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AddDialectsFromCatalogueViewModel +import press.mantra.compose.ui.view.state.AddDialectsFromCatalogueUIState +import press.mantra.compose.ui.view.state.CatalogueRow +import press.mantra.compose.ui.view.state.matches + +/** + * Picking dialects out of the catalogue of African languages. + * + * Shows every entry and what this room can still ask for. A language the room + * holds and a language it is already signing are both shown and neither can be + * ticked, with different words on each, because "not available" has two + * explanations and telling a member the wrong one is worse than telling them + * nothing: one is theirs already, and the other is a proposal somebody else + * opened that they can go and sign. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddDialectsFromCatalogueScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialUIState: AddDialectsFromCatalogueUIState = AddDialectsFromCatalogueUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val viewModel: AddDialectsFromCatalogueViewModel = viewModel( + factory = AddDialectsFromCatalogueViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialUIState = initialUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + val uiState by viewModel.uiState.collectAsState() + + ScreenStateTransition(uiState) { state -> + when (state) { + is AddDialectsFromCatalogueUIState.Error -> ErrorState( + message = state.message, + // A real retry: the state came from a database read that can + // succeed on a second try, not from a navigation argument that + // was already wrong. + onRetry = viewModel::initiate, + ) + + is AddDialectsFromCatalogueUIState.Loaded -> { + // imePadding: the search field is on this screen, and without it + // the software keyboard covers what is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.add_dialects_from_a_list)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + ) { + Text( + stringResource( + Res.string.pick_the_languages_this_group_will_translate_into + ) + ) + + if (!state.canSign) { + Text( + text = stringResource( + Res.string + .this_group_has_no_shared_key_so_it_cannot_sign_a_dialect + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + OutlinedTextField( + value = viewModel.query.value, + onValueChange = viewModel::setQuery, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = Decorative) + }, + label = { + Text( + stringResource(Res.string.search_by_language_country_or_code) + ) + }, + ) + + when { + // Loaded is reached before the rows are read, so an + // empty list is "still reading" rather than "nothing". + state.rows.isEmpty() -> LoadingDataIndicator(fillScreen = false) + + state.missing.isEmpty() -> EmptyState( + message = stringResource( + Res.string + .this_group_already_has_every_language_in_the_list + ), + icon = Icons.Default.Translate, + action = { + TextButton( + onClick = { + onNavigateToRoute( + AddDialectRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + ) + ) + } + ) { + Text(stringResource(Res.string.add_dialect)) + } + }, + ) + + else -> { + SelectionSummary( + selected = viewModel.selected.size, + missing = state.missing.size, + onSelectEverythingMissing = viewModel::selectEverythingMissing, + onClearSelection = viewModel::clearSelection, + ) + + CatalogueList(state = state, viewModel = viewModel) + } + } + } + } + } + + AddDialectsFromCatalogueUIState.Loading -> LoadingDataIndicator(fillScreen = true) + } + } + + LaunchedEffect(true) { + if (initialUIState == AddDialectsFromCatalogueUIState.Loading) { + viewModel.initiate() + } + } +} + +/** + * How much is ticked, and the two ways to change all of it at once. + * + * The count is of what is missing rather than of the catalogue, because the + * catalogue's size is not a number a member can act on: "12 of 40 selected" says + * how much is left to pick, and "12 of 56" would count rows they cannot tick. + */ +@Composable +private fun SelectionSummary( + selected: Int, + missing: Int, + onSelectEverythingMissing: () -> Unit, + onClearSelection: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + ) { + Text( + text = stringResource(Res.string.s_of_s_selected, selected, missing), + style = MaterialTheme.typography.labelMedium, + ) + + if (selected == 0) { + TextButton(onClick = onSelectEverythingMissing) { + Text(stringResource(Res.string.select_everything_missing)) + } + } else { + TextButton(onClick = onClearSelection) { + Text(stringResource(Res.string.clear_the_selection)) + } + } + } +} + +/** The catalogue, filtered by the search and grouped under the country. */ +@Composable +private fun CatalogueList( + state: AddDialectsFromCatalogueUIState.Loaded, + viewModel: AddDialectsFromCatalogueViewModel, +) { + val query = viewModel.query.value + val rows = state.rows.filter { it.language.matches(query) } + + if (rows.isEmpty()) { + EmptyState( + message = stringResource(Res.string.nothing_matched_that_search), + icon = Icons.Default.Search, + ) + return + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50), + ) { + rows.forEachIndexed { index, row -> + val language = row.language + + // The country, once, above the languages spoken there. A heading per + // row would say "Nigeria" four times. + if (rows.getOrNull(index - 1)?.language?.country != language.country) { + item(key = "country ${language.country}") { + Text( + text = language.country, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = MaterialTheme.spacing.space125), + ) + } + } + + item(key = "${language.code} ${language.country}") { + LanguageRow( + row = row, + isSelected = viewModel.isSelected(language), + onToggle = { viewModel.toggle(language) }, + ) + } + } + } +} + +/** + * One catalogue entry. + * + * A clickable `Card` rather than a bare clickable modifier, so the whole row is + * the target and is 48dp without anybody having to remember + * `minimumInteractiveComponentSize` -- the same shape `SelectSubgroupAdminsScreen` + * uses for picking members. (Spelled this way round because + * `m3-touch-targets.py` reads comments as code; see the note in m3-audit.sh on + * the colour check, which hit the same thing.) + */ +@Composable +private fun LanguageRow( + row: CatalogueRow, + isSelected: Boolean, + onToggle: () -> Unit, +) { + val missing = row.standing == CatalogueRow.Standing.MISSING + + Card(onClick = onToggle, enabled = missing) { + ListItem( + leadingContent = { + // Decorative: the name is beside it and the checkbox carries the + // state. + Icon(Icons.Default.Translate, contentDescription = Decorative) + }, + headlineContent = { + Text( + text = row.language.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + // Which of the two reasons it cannot be ticked, since they are + // different things to do about it. The code otherwise, which is + // what a member searching by ISO 639 is looking for. + Text( + text = when (row.standing) { + CatalogueRow.Standing.MISSING -> row.language.code + CatalogueRow.Standing.IN_THE_ROOM -> + stringResource(Res.string.already_in_this_group) + + CatalogueRow.Standing.IN_FLIGHT -> + stringResource(Res.string.waiting_for_the_group_to_sign_this_one) + } + ) + }, + trailingContent = { + Checkbox( + checked = isSelected || row.standing == CatalogueRow.Standing.IN_THE_ROOM, + enabled = missing, + onCheckedChange = if (missing) { + { onToggle() } + } else { + null + }, + ) + } + ) + } +} + +private val previewRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "", + userPublicKey = "", + subject = "Message title", + description = "See something. Say something", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null, + ), +) + +@ConformancePreviews +@Composable +private fun AddDialectsFromCataloguePreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddDialectsFromCatalogueScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialUIState = AddDialectsFromCatalogueUIState.Loaded( + localChatRoom = previewRoom, + canSign = true, + rows = CatalogueRow.rowsOf( + existing = emptyList(), + pending = listOf(AfricanLanguages.ALL.first()), + ), + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} + +@ConformancePreviews +@Composable +private fun AddDialectsFromCatalogueNothingLeftPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddDialectsFromCatalogueScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialUIState = AddDialectsFromCatalogueUIState.Loaded( + localChatRoom = previewRoom, + canSign = true, + rows = CatalogueRow.rowsOf( + existing = emptyList(), + pending = AfricanLanguages.ALL, + ), + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index d5af2cba..5c9310a8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.ChevronRight @@ -86,6 +87,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute +import press.mantra.compose.ui.composable.navigation.routes.AddDialectsFromCatalogueRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute import press.mantra.compose.ui.theme.spacing @@ -111,6 +113,7 @@ import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.add_artifact_to_library import mantra.composeapp.generated.resources.add_dialect +import mantra.composeapp.generated.resources.add_dialects_from_a_list import mantra.composeapp.generated.resources.create_project import mantra.composeapp.generated.resources.delete_group import mantra.composeapp.generated.resources.dialects @@ -580,6 +583,37 @@ fun ChatRoomDetailScreen( } } + item { + // The catalogue, beside the form rather than + // instead of it: the form makes one dialect the + // group names itself, and this picks a wave of + // them out of the languages most spoken in + // Africa. See docs/african-dialect-catalogue.md. + TextButton( + onClick = { + onNavigateToRoute.invoke( + AddDialectsFromCatalogueRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) + ) + } + ) { + + Icon( + Icons.Default.Language, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.add_dialects_from_a_list)) + } + } + item { HorizontalDivider() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 444eaec2..449c5826 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -141,6 +141,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute +import press.mantra.compose.ui.composable.navigation.routes.AddDialectsFromCatalogueRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute @@ -161,6 +162,7 @@ import press.mantra.compose.ui.composable.ProjectDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen import press.mantra.compose.ui.composable.UploadChaptersScreen import press.mantra.compose.ui.composable.AddDialectScreen +import press.mantra.compose.ui.composable.AddDialectsFromCatalogueScreen import press.mantra.compose.ui.composable.FrostSigningScreen import press.mantra.compose.ui.composable.ProposalListScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen @@ -1240,6 +1242,24 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddDialectsFromCatalogueScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate(route = actionRoute) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt new file mode 100644 index 00000000..4995f06e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Picking dialects out of the bundled catalogue instead of typing one in. + * + * The same three arguments as [AddDialectRoute], and a separate destination + * rather than a mode on that one: the form takes three text fields and makes one + * dialect, and this takes a selection and makes a wave of them. One screen doing + * both would have a `Loaded` state carrying the fields *and* the selection, and a + * button meaning two different things. + */ +@Serializable +data class AddDialectsFromCatalogueRoute( + val activeUserPublicKey: String, + val chatRoomId: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt new file mode 100644 index 00000000..3fc835f3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt @@ -0,0 +1,209 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import press.mantra.compose.language.AfricanLanguage +import press.mantra.compose.managers.DialectScaffold +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.view.state.AddDialectsFromCatalogueUIState +import press.mantra.compose.ui.view.state.CatalogueRow + +/** + * Picking dialects out of the catalogue rather than typing one in. + * + * Reads what the room holds and what it is already signing, and offers the rest. + * The second half of that is the part worth having: without it two admins open + * this screen, both see the same forty missing and both propose, and the room + * gets eighty dialects -- forty of them duplicates nothing will ever merge, + * because nothing in the schema makes a dialect unique. + */ +class AddDialectsFromCatalogueViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialUIState: AddDialectsFromCatalogueUIState, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + /** + * A `StateFlow` rather than the `mutableStateOf` the neighbouring view models + * use, and not for consistency's sake. + * + * Those write their state once. This one is written from `Dispatchers.IO` on + * the way in *and* again from a collector on the room's signing sessions, + * which is the case where a `mutableStateOf` in a view model drops a write + * made off the main thread during the first composition. `update { }` over a + * `MutableStateFlow` is the shape that does not. + */ + private val _uiState = MutableStateFlow(initialUIState) + val uiState: StateFlow = _uiState.asStateFlow() + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * What is ticked, by the only thing that identifies a dialect. + * + * Keys rather than entries so that a row arriving from a refresh is the same + * selection as the one that was ticked, and a `mutableStateListOf` rather + * than a field of the UI state because the UI read-modify-writes it on every + * tap -- see the note on [_uiState]. + */ + val selected = mutableStateListOf() + + val query: MutableState = mutableStateOf("") + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + if (localChatRoom == null) { + _uiState.value = AddDialectsFromCatalogueUIState.Error("Couldn't find this group") + return@launch + } + + _uiState.value = AddDialectsFromCatalogueUIState.Loaded( + localChatRoom = localChatRoom, + canSign = frostSigningRepository.canSign(chatRoomId), + ) + + // The room's sessions drive the refresh, and the rows are re-read + // with them rather than observed separately. A wave landing changes + // both at once -- its session completes in the same breath as its + // dialects become rows -- so one emission covers them, and the + // dialect DAO has no flow to observe anyway. + // + // What this does not catch is a dialect arriving with no session of + // this room behind it: a chronicle page, or inbound work from + // another device. Those are picked up on the way in rather than + // live, which is the right trade for a screen somebody opens, + // ticks and leaves. + frostSigningRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions -> + val pending = DialectScaffold.pendingIn( + sessions = sessions, + unsignedEvents = frostSigningRepository::unsignedEvents, + ) + + val rows = CatalogueRow.rowsOf( + existing = mantraRepository.getDialects(chatRoomId), + pending = pending, + ) + + _uiState.update { state -> + when (state) { + is AddDialectsFromCatalogueUIState.Loaded -> state.copy(rows = rows) + else -> state + } + } + + // Anything that stopped being missing while this screen was open + // -- another admin proposed it, or a wave landed -- comes off the + // selection. Leaving it ticked would propose a dialect the room + // already has, and the two would not even collide. + val stillMissing = rows + .filter { it.standing == CatalogueRow.Standing.MISSING } + .mapTo(mutableSetOf()) { DialectScaffold.keyOf(it.language) } + + selected.retainAll(stillMissing) + } + } + } + + /** Ticks or unticks [language]. A row that is not missing is not ours to tick. */ + fun toggle(language: AfricanLanguage) { + if (isActionPending.value) return + + val loaded = _uiState.value as? AddDialectsFromCatalogueUIState.Loaded ?: return + val key = DialectScaffold.keyOf(language) + + if (loaded.rows.none { it.standing == CatalogueRow.Standing.MISSING && DialectScaffold.keyOf(it.language) == key }) { + return + } + + if (!selected.remove(key)) selected.add(key) + } + + fun isSelected(language: AfricanLanguage): Boolean = + DialectScaffold.keyOf(language) in selected + + /** + * Ticks everything the room is missing -- the whole convenience this screen + * is named for. + * + * Everything missing rather than everything shown: a member who has typed + * "so" into the search and taps this means the catalogue, not the three rows + * in front of them, and a selection that silently depended on a filter would + * be the harder of the two to notice. + */ + fun selectEverythingMissing() { + if (isActionPending.value) return + + val loaded = _uiState.value as? AddDialectsFromCatalogueUIState.Loaded ?: return + + selected.clear() + selected.addAll(loaded.missing.map(DialectScaffold::keyOf)) + } + + fun clearSelection() { + if (isActionPending.value) return + + selected.clear() + } + + fun setQuery(value: String) { + query.value = value + } + + /** What is ticked, as catalogue entries in catalogue order. */ + fun selectedLanguages(): List { + val loaded = _uiState.value as? AddDialectsFromCatalogueUIState.Loaded ?: return emptyList() + + return loaded.missing.filter { DialectScaffold.keyOf(it) in selected } + } + + companion object { + private const val TAG = "AddDialectsFromCatalogueViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialUIState: AddDialectsFromCatalogueUIState = AddDialectsFromCatalogueUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddDialectsFromCatalogueViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialUIState = initialUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectsFromCatalogueUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectsFromCatalogueUIState.kt new file mode 100644 index 00000000..5f24b7d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectsFromCatalogueUIState.kt @@ -0,0 +1,117 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.language.AfricanLanguage +import press.mantra.compose.language.AfricanLanguages +import press.mantra.compose.managers.DialectScaffold + +sealed interface AddDialectsFromCatalogueUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** + * Every catalogue entry and where it stands in this room. Loaded is + * reached before these are known, so an empty list here means "still + * reading" rather than "nothing to show". + */ + val rows: List = emptyList(), + + /** + * Whether the group holds a shared key. A dialect is signed into + * existence rather than submitted, so a group without one cannot make + * any here -- the same gate the hand-typed form has. + */ + val canSign: Boolean = false, + ): AddDialectsFromCatalogueUIState { + val missing: List + get() = rows.filter { it.standing == CatalogueRow.Standing.MISSING } + .map { it.language } + } + + data class Error( + val message: String + ): AddDialectsFromCatalogueUIState + + data object Loading: AddDialectsFromCatalogueUIState +} + +/** + * A catalogue entry, and whether this room can still ask for it. + * + * Three standings rather than two, because "not available" has two different + * explanations and telling a member the wrong one is worse than telling them + * nothing: a dialect the room holds is theirs, and a dialect in flight is a + * proposal somebody else opened and they can go and sign. + */ +data class CatalogueRow( + val language: AfricanLanguage, + val standing: Standing, +) { + enum class Standing { + /** Nothing holds it and nothing is signing it. The only tickable state. */ + MISSING, + + /** A `MantraDialect` row for this (code, country) is already here. */ + IN_THE_ROOM, + + /** An open signing session is signing it. See `DialectScaffold.pendingIn`. */ + IN_FLIGHT, + } + + companion object { + /** + * Every catalogue entry, standing against what this room holds and what + * it is signing. + * + * Derived from [DialectScaffold.missingFrom] rather than computed beside + * it, deliberately. That function is what the proposal is built from, so + * a row saying MISSING where it disagreed would offer a member a dialect + * the room already has -- and the duplicate would not even collide, + * because dialect ids differ by `createdAt`. One authority; the other two + * standings are only labels on what it already excluded. + */ + fun rowsOf( + existing: List, + pending: List, + catalogue: List = AfricanLanguages.ALL, + ): List { + val missing = DialectScaffold + .missingFrom(existing = existing, pending = pending, catalogue = catalogue) + .mapTo(mutableSetOf(), DialectScaffold::keyOf) + + val held = existing.mapTo(mutableSetOf(), DialectScaffold::keyOf) + + return catalogue.map { language -> + val key = DialectScaffold.keyOf(language) + + CatalogueRow( + language = language, + standing = when { + key in missing -> Standing.MISSING + key in held -> Standing.IN_THE_ROOM + else -> Standing.IN_FLIGHT + }, + ) + } + } + } +} + +/** + * Whether [query] picks this language out, matched on its name, its country and + * its code. + * + * The code is matched too because it is what a member who knows ISO 639 will + * type, and because it is the one field of the three that is not a matter of + * spelling -- somebody looking for Sepedi may type `nso` rather than guess + * whether this catalogue calls it Sepedi or Northern Sotho. + */ +fun AfricanLanguage.matches(query: String): Boolean { + val needle = query.trim().lowercase() + + return needle.isEmpty() || + name.lowercase().contains(needle) || + country.lowercase().contains(needle) || + code.lowercase().contains(needle) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/CatalogueRowTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/CatalogueRowTest.kt new file mode 100644 index 00000000..afd703ec --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/CatalogueRowTest.kt @@ -0,0 +1,102 @@ +package press.mantra.compose.ui.view.state + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.language.AfricanLanguage + +/** + * Where each catalogue entry stands in a room, and what the search picks out. + * + * The standing is derived from `DialectScaffold.missingFrom` rather than + * computed beside it, so the one case worth asserting hard is that a row never + * says MISSING where that function disagrees: offering a member a dialect the + * room already has would make a duplicate that cannot be merged, since dialect + * ids differ by `createdAt`. + */ +class CatalogueRowTest { + + private val sesotho = AfricanLanguage("Sesotho", "st", "Lesotho") + private val swahiliKenya = AfricanLanguage("Kiswahili", "sw", "Kenya") + private val swahiliTanzania = AfricanLanguage("Kiswahili", "sw", "Tanzania") + + private val catalogue = listOf(sesotho, swahiliKenya, swahiliTanzania) + + private fun dialect(language: AfricanLanguage) = MantraDialect( + id = "d${language.code}${language.country}".padEnd(64, 'f').take(64), + publicKey = "p".repeat(64), + name = language.name, + country = language.country, + language = language.code, + signature = "", + chatRoomId = "r".repeat(64), + ) + + @Test + fun anEmptyRoomMakesEverythingMissing() { + val rows = CatalogueRow.rowsOf( + existing = emptyList(), + pending = emptyList(), + catalogue = catalogue, + ) + + assertEquals(catalogue, rows.map { it.language }) + assertTrue(rows.all { it.standing == CatalogueRow.Standing.MISSING }) + } + + @Test + fun eachOfTheThreeStandingsIsReached() { + val rows = CatalogueRow.rowsOf( + existing = listOf(dialect(sesotho)), + pending = listOf(swahiliKenya), + catalogue = catalogue, + ) + + assertEquals( + listOf( + CatalogueRow.Standing.IN_THE_ROOM, + CatalogueRow.Standing.IN_FLIGHT, + CatalogueRow.Standing.MISSING, + ), + rows.map { it.standing } + ) + } + + /** + * A dialect that is both held and being signed again reads as held. That is + * the more useful of the two -- it is theirs, whatever else is going on -- + * and either way it is not MISSING, which is the part that matters. + */ + @Test + fun heldBeatsInFlight() { + val rows = CatalogueRow.rowsOf( + existing = listOf(dialect(sesotho)), + pending = listOf(sesotho), + catalogue = catalogue, + ) + + assertEquals(CatalogueRow.Standing.IN_THE_ROOM, rows.first().standing) + } + + @Test + fun theOrderIsTheCataloguesAndEveryEntryIsThere() { + val rows = CatalogueRow.rowsOf( + existing = listOf(dialect(swahiliKenya)), + pending = listOf(swahiliTanzania), + catalogue = catalogue, + ) + + assertEquals(catalogue, rows.map { it.language }) + } + + @Test + fun searchMatchesNameCountryAndCode() { + assertTrue(sesotho.matches("seso"), "the name") + assertTrue(sesotho.matches("LESOTHO"), "the country, whatever the case") + assertTrue(sesotho.matches(" st "), "the code, trimmed") + assertTrue(sesotho.matches(""), "an empty search excludes nothing") + assertFalse(sesotho.matches("yoruba")) + } +} From c03112fbbcf1f7dd07f02262abc6e8fc90c7f53d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:54:25 +0200 Subject: [PATCH 5/8] feat(dialects): propose the ticked dialects as a wave The FAB, the view model method behind it, and the navigation onto the session it opens. The picker can now do the thing it is for. One session per MAX_BATCH_SIZE entries, the way TranslationArtifactVersionDetailViewModel catches up a translation's missing chapters. The catalogue is smaller than one batch today so 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 rather than a second session. The jvmTest exercises it anyway, with a synthetic selection of 65, because nothing else will. One timestamp for the lot, so a wave reads as the one act it is. The callback is invoked from Dispatchers.Main, and that is not decoration: it navigates, and navigating off Dispatchers.IO crashes. The handoff belongs in the view model rather than at the call site. A batch is all-or-nothing and a retry has to be a fresh proposal, since the items' nonce seeds have already been published against an aggregate. Both are answered by the rule the screen is built on -- it only ever proposes what is missing -- so asking again after a failed wave lands exactly the entries the failed one did not, which is what makes the signing screen right to say it is safe to ask. The new jvmTest runs the real proposal against a real database and a real key, because the half with the sharp edge is the one the caller cannot see: every event of a wave is re-authored under the group's key at the room's derivation path inside proposeSigningBatch, so the ids the group signs are not the ids dialectsOf produced. It also asserts the round trip the picker depends on -- a proposed wave parses back into the entries that were ticked, through the same fromDialectEvent arm applyInnerEvent will take -- and that missingFrom then stops offering them. Nothing was needed to make a wave readable: ProposedEvent already has its dialect arm, and WhatIsBeingSigned already leads a batch with "N events signed together" and refuses to let any of it be signed unless all of it parsed. Co-Authored-By: Claude Opus 5 --- .../AddDialectsFromCatalogueScreen.kt | 76 +++++ .../ui/composable/navigation/MantraNavHost.kt | 9 + .../AddDialectsFromCatalogueViewModel.kt | 80 +++++ .../managers/DialectWaveProposalJvmTest.kt | 289 ++++++++++++++++++ 4 files changed, 454 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/DialectWaveProposalJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt index 0cf848c6..66a63737 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api @@ -41,6 +43,8 @@ import mantra.composeapp.generated.resources.already_in_this_group import mantra.composeapp.generated.resources.clear_the_selection import mantra.composeapp.generated.resources.nothing_matched_that_search import mantra.composeapp.generated.resources.pick_the_languages_this_group_will_translate_into +import mantra.composeapp.generated.resources.propose_dialect +import mantra.composeapp.generated.resources.propose_s_dialects import mantra.composeapp.generated.resources.s_of_s_selected import mantra.composeapp.generated.resources.search_by_language_country_or_code import mantra.composeapp.generated.resources.select_everything_missing @@ -55,6 +59,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.EmptyState @@ -63,6 +68,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.readableContent @@ -92,6 +98,7 @@ fun AddDialectsFromCatalogueScreen( chatRepository: ChatRepository, mantraRepository: MantraRepository, frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -130,6 +137,47 @@ fun AddDialectsFromCatalogueScreen( title = { Text(stringResource(Res.string.add_dialects_from_a_list)) }, navigationIcon = { NavigateBackButton(onNavigateBack) }, ) + }, + bottomBar = { + // Only where there is something to propose. A room that + // holds the whole catalogue gets the empty state and its + // way to the form, and "Propose 0 dialects" underneath it + // would be a button that can never do anything. + if (state.missing.isNotEmpty()) { + BottomAppBar( + actions = {}, + floatingActionButton = { + ProposeWaveFab( + count = viewModel.selected.size, + enabled = state.canSign && + viewModel.selected.isNotEmpty(), + onClick = { + viewModel.proposeSelectedDialects( + localChatRoom = state.localChatRoom, + onSuccess = { sessionId -> + // Onto the session rather than + // back to the group. Nothing + // has been created yet -- the + // dialects appear when enough + // members sign -- so landing on + // a list they are not in would + // read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = + activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = {}, + ) + } + ) + } + ) + } } ) { innerPadding -> Column( @@ -222,6 +270,32 @@ fun AddDialectsFromCatalogueScreen( } } +/** + * Asks the group to sign what is ticked. + * + * Counted in the label because a wave is the thing being proposed and its size + * is the whole of what distinguishes one from another -- a member who has + * scrolled away from the summary still has to be told how many they are about to + * put in front of the group. + */ +@Composable +private fun ProposeWaveFab( + count: Int, + enabled: Boolean, + onClick: () -> Unit, +) { + ExtendedFab( + label = if (count == 1) { + stringResource(Res.string.propose_dialect) + } else { + stringResource(Res.string.propose_s_dialects, count) + }, + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + enabled = enabled, + onClick = onClick, + ) +} + /** * How much is ticked, and the two ways to change all of it at once. * @@ -398,6 +472,7 @@ private fun AddDialectsFromCataloguePreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {}, onNavigateBack = {}, ) @@ -425,6 +500,7 @@ private fun AddDialectsFromCatalogueNothingLeftPreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {}, onNavigateBack = {}, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 449c5826..117aabe7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -1252,6 +1252,15 @@ fun MantraNavHost( chatRepository = databaseChatRepository, mantraRepository = databaseMantraRepository, frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this picker so back returns to the group rather + // than to a selection whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + }, onNavigateToRoute = { actionRoute -> navController.navigate(route = actionRoute) }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt index 3fc835f3..3877b3c4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectsFromCatalogueViewModel.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow @@ -17,8 +18,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.language.AfricanLanguage import press.mantra.compose.managers.DialectScaffold +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -181,6 +184,83 @@ class AddDialectsFromCatalogueViewModel( return loaded.missing.filter { DialectScaffold.keyOf(it) in selected } } + /** + * Asks the group to sign the ticked dialects into existence. + * + * None of them are created here and none of them exist yet. What goes out is + * a proposal, and the dialects appear -- on every member's device at once, + * authored by the room's own key rather than by whoever ticked them -- when + * enough members have signed. + * + * One session per [FrostSigningManager.MAX_BATCH_SIZE] entries, the same way + * `TranslationArtifactVersionDetailViewModel` catches up a translation's + * missing chapters. The catalogue is smaller than one batch today, so the + * chunking never fires; it is a line, it is the established idiom, and it is + * what stops the sixty-fifth catalogue entry from being a bug rather than a + * second session. `DialectCatalogueSizeTest` is the tripwire. + * + * One timestamp for the lot, so a wave reads as the one act it is rather than + * as entries that happen to share a minute. + * + * A batch is all-or-nothing: if one item cannot be aggregated the session + * fails and none of them are applied, and a retry has to be a fresh proposal + * because the items' nonce seeds have already been published. Both are + * answered by the rule this screen is built on -- it only ever proposes what + * is missing -- so asking again after a failed wave lands exactly the + * entries the failed one did not, and the signing screen is right to tell a + * member it is safe to ask. + */ + fun proposeSelectedDialects( + localChatRoom: LocalChatRoom, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit, + ) { + val languages = selectedLanguages() + + if (languages.isEmpty()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val createdAt = TimeUtils.now() + + val sessions = runCatching { + languages.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { wave -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = DialectScaffold.dialectsOf( + languages = wave, + createdAt = createdAt, + ), + ) + } + }.onFailure { error -> + logger.e("Failed to propose a wave of dialects for signing", error) + }.getOrNull().orEmpty() + + // The first, because it is the one that opened; any others are in the + // room's proposal list beside it. + val session = sessions.firstOrNull() + + if (session != null) selected.clear() + + // On the main thread because the callback navigates, and navigating + // off Dispatchers.IO crashes. The handoff belongs here rather than at + // the call site. + viewModelScope.launch(Dispatchers.Main) { + if (session != null) onSuccess.invoke(session.id) else onFailure.invoke() + } + + isActionPending.value = false + } + } + companion object { private const val TAG = "AddDialectsFromCatalogueViewModel" diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/DialectWaveProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/DialectWaveProposalJvmTest.kt new file mode 100644 index 00000000..c30c9b8c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/DialectWaveProposalJvmTest.kt @@ -0,0 +1,289 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgParticipantMessage +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.language.AfricanLanguage +import press.mantra.compose.language.AfricanLanguages +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.DialectEvent + +/** + * What a wave of dialects actually puts in front of the group. + * + * `DialectScaffoldTest` builds the templates and asserts their shape, which is + * the easy half. This runs the real proposal against a real database and a real + * key, because the half with the sharp edge is the one the caller cannot see: + * every event of a wave is re-authored under the group's key at the room's + * derivation path, inside `proposeSigningBatch`, and the ids the group will sign + * are not the ids `dialectsOf` produced. + * + * The wave is also the first batch in this app made of many *peers* -- fifty + * dialects, no lead and no dependents -- rather than one thing and its parts. + */ +class DialectWaveProposalJvmTest { + private val participants = 3 + private val threshold = 2 + + /** Stands in for a completed ceremony; the test is about the proposal, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + /** The proposer's device: a room it can sign in, and the share to sign with. */ + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128) + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = proposer, + subject = "#Ekklesia", + description = SharedKeyDerivation.describe("Admins of the group."), + mlsGroupState = null + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() } + ) + ) + + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index] + ) + ) + } + + room = LocalChatRoom(chatRoom = chatRoom) + } + + /** Exactly what `AddDialectsFromCatalogueViewModel` proposes, for one wave. */ + private suspend fun proposeWave(languages: List, createdAt: Long = STAMP) = + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + events = DialectScaffold.dialectsOf(languages = languages, createdAt = createdAt), + ) + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + private fun dialectsOf(events: List) = events.map { + DialectEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + @Test + fun `a wave is one session with one item per entry, in order`() = runBlocking { + openDevice() + + val wave = AfricanLanguages.ALL.take(5) + val events = itemEvents(proposeWave(wave).id) + + assertEquals(wave.size, events.size) + assertTrue(events.all { it.kind == DialectEvent.KIND }) + assertEquals(wave.map { it.name }, dialectsOf(events).map { it.name() }) + } + + @Test + fun `every item carries the entry it came from`() = runBlocking { + openDevice() + + val wave = AfricanLanguages.ALL.take(8) + val dialects = dialectsOf(itemEvents(proposeWave(wave).id)) + + assertEquals( + wave, + dialects.map { + AfricanLanguage( + name = it.name().orEmpty(), + code = it.language().orEmpty(), + country = it.country().orEmpty(), + ) + } + ) + } + + @Test + fun `the whole wave is authored by the room, not by whoever ticked it`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(4)).id) + + // Signing runs at the path the room was derived at, so a dialect says + // which group made it simply by being signed. + assertTrue(events.all { it.pubKey == roomId }) + assertTrue(events.none { it.pubKey == proposer }) + } + + @Test + fun `one wave is one timestamp`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(6)).id) + + assertEquals(setOf(STAMP), events.map { it.createdAt }.toSet()) + } + + /** + * The ids the group will sign are not the ids the templates had, because the + * author is resolved inside the proposal. Distinct all the same: the entries + * share an author and a timestamp by construction, so their tags are the only + * thing separating them. + */ + @Test + fun `items of one wave get distinct ids`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(20)).id) + + assertEquals(events.size, events.map { it.id }.distinct().size) + } + + @Test + fun `the whole catalogue proposes in a single session`() = runBlocking { + openDevice() + + val events = itemEvents(proposeWave(AfricanLanguages.ALL).id) + + assertEquals(AfricanLanguages.ALL.size, events.size) + } + + /** + * A selection bigger than a batch is chunked rather than refused, which is + * what the view model does with `chunked(MAX_BATCH_SIZE)`. Asserted here + * because the catalogue is smaller than one batch today, so nothing else + * exercises it. + */ + @Test + fun `a selection larger than a batch becomes several sessions`() = runBlocking { + openDevice() + + val oversized = (0 until FrostSigningManager.MAX_BATCH_SIZE + 1).map { index -> + AfricanLanguage(name = "Language $index", code = "x$index", country = "Nowhere") + } + + val sessions = oversized.chunked(FrostSigningManager.MAX_BATCH_SIZE).map { + proposeWave(it) + } + + assertEquals(2, sessions.size) + assertEquals( + listOf(FrostSigningManager.MAX_BATCH_SIZE, 1), + sessions.map { itemEvents(it.id).size } + ) + } + + /** + * The round trip the picker depends on: the rows a signed wave becomes say + * what was ticked. + * + * Against the *proposed* events rather than the templates, since those are + * the ones the group signs -- and `fromDialectEvent` is the arm + * `ChatMessage.applyInnerEvent` will take when it does. + */ + @Test + fun `a proposed wave parses back into the dialects that were ticked`() = runBlocking { + openDevice() + + val wave = AfricanLanguages.ALL.take(12) + val events = itemEvents(proposeWave(wave).id) + + val rows = events.mapNotNull { event -> + MantraDialect.fromDialectEvent( + dialectEvent = DialectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ), + chatRoomId = roomId, + ) + } + + assertEquals(wave.size, rows.size) + assertEquals( + wave, + rows.map { AfricanLanguage(name = it.name, code = it.language, country = it.country) } + ) + + // And so the picker would now show every one of them as held rather than + // offering them again. + assertTrue( + DialectScaffold.missingFrom(existing = rows, catalogue = wave).isEmpty() + ) + } + + private companion object { + const val STAMP = 1_700_000_000L + } +} From 226653d2828b1817d671f807b03fcb84939556d0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:59:14 +0200 Subject: [PATCH 6/8] fix(frost): one transcript line for a wave, not one per dialect 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. Needed by the previous commit. A wave of fifty dialects would otherwise post fifty "Added X to dialects" lines into the group's conversation, one after another, followed by a line saying the group signed fifty events -- a single act burying the conversation it was announced in. Every kind that could already appear many-per-batch returns null from applyInnerEvent for precisely this reason: a chunk is part of a chapter and the chapter's line speaks for it, and likewise a translation chapter, a contributor list and both project memberships. DialectEvent is the first kind where a batch holds many peers each of which wants a line. So checked against every batch the app builds today, this changes nothing: the leading kind of each is one of its kind and keeps its line. It is not a rule about batch size, which is the mistake available here. A chapter and its sixty-four chunks is one ChapterEvent, and gating on the batch being larger than one event would have taken the chapter's line away. Decided at the call site because that is the only place that can see the batch. applyInnerEvent is untouched -- it still writes the row, which is the part that matters -- and only the chatMessageDao upsert is skipped. ChronicleManager needed nothing: it already drops the line a payload returns, and says why. Three tests on SignedGroupKeyStateTest, which runs real two-device sessions: one dialect still files its line, three file none and still land three rows, and a dialect with the artifact that names it files both. The first two are the same mechanism asserted at 1 and at 0, so neither can pass vacuously. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningManager.kt | 37 ++++- .../managers/SignedGroupKeyStateTest.kt | 126 ++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 59798fd1..bdcc7bf9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -975,7 +975,32 @@ object FrostSigningManager { // re-authors rumors as their sender and would strip the group's // signature off. Sending one to relays is a member's separate act, // from `BroadcastGroupSignedEventScreen`, off the recorded row. - signedEvents.forEach { applySignedEvent(database, session, it, recorded) } + // + // A line per event, *unless the batch holds more than one of its kind*, + // in which case the completion line below speaks for them. Peers in a + // batch are the case that needs it: a wave of fifty dialects would + // otherwise post fifty "Added X to dialects" lines into the group's + // conversation, one after the other, followed by a line saying the group + // signed fifty events. + // + // Every kind that could already appear many-per-batch returns null from + // `applyInnerEvent` for precisely this reason -- a chunk is part of a + // chapter and the chapter's line speaks for it, and likewise a + // translation chapter, a contributor list and both project memberships -- + // so this changes nothing for any batch built before the dialect wave. + // What it is not is a rule about batch *size*: a chapter and its + // sixty-four chunks is one ChapterEvent, and its line stays. + val kinds = signedEvents.groupingBy { it.kind }.eachCount() + + signedEvents.forEach { signedEvent -> + applySignedEvent( + database = database, + session = session, + signedEvent = signedEvent, + recorded = recorded, + fileChatLine = kinds[signedEvent.kind] == 1, + ) + } announce( database = database, @@ -1040,12 +1065,18 @@ object FrostSigningManager { * A failure here is not the session's: the signature is made and valid, and * saying otherwise would tell the group to abandon a ceremony that * succeeded. It is logged and the session still completes. + * + * [fileChatLine] is false for an event the batch holds several of a kind of; + * the row is written either way, and only the transcript line is skipped. + * See the rule where it is decided, at the call site -- which is the only + * place that can see the batch, and so the only place that can know. */ private suspend fun applySignedEvent( database: MantraDatabase, session: FrostSigningSession, signedEvent: Event, - recorded: Boolean + recorded: Boolean, + fileChatLine: Boolean = true ) { try { ChatMessage.applyInnerEvent( @@ -1058,7 +1089,7 @@ object FrostSigningManager { isUserMessage = session.isCoordinator(), createdAt = Clock.System.now(), groupSignedEventId = signedEvent.id.takeIf { recorded } - )?.let { database.chatMessageDao().upsert(it) } + )?.takeIf { fileChatLine }?.let { database.chatMessageDao().upsert(it) } } catch (e: Throwable) { logger.e("Signed ${signedEvent.id} but could not apply it locally", e) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 1e85b6a7..8804a231 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -48,6 +48,7 @@ import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.DialectEvent /** @@ -198,6 +199,13 @@ class SignedGroupKeyStateTest { /** Everything the group has signed here, as the group signed it. */ suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId) + + /** How many transcript lines of [messageType] this room holds. */ + suspend fun linesOfType(messageType: String) = + db.chatMessageDao().getChatMessagesByChatRoomId(roomId) + .count { it.chatMessage.messageType == messageType } + + suspend fun dialectLines() = linesOfType("dialect") } private suspend fun device( @@ -1071,6 +1079,124 @@ class SignedGroupKeyStateTest { } } + // ---- What a batch says in the transcript ------------------------------ + + /** + * A dialect signed on its own still announces itself. + * + * The other half of the rule below, and the half that has to keep working: + * the hand-typed form proposes exactly this, and a group that adds one + * dialect should see a line saying so. + */ + @Test + fun `a dialect signed on its own files its line`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects().take(1) + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + assertEquals(1, creator.dialectLines(), "one dialect, one line") + } + + /** + * A wave says it once. + * + * Fifty dialects filing fifty "Added X to dialects" lines would bury the + * group's conversation under a single act, so a batch holding more than one + * event of a kind leaves the talking to its completion line. The rows are + * written either way -- that is the part a member came for, and it is + * asserted here rather than taken on trust. + */ + @Test + fun `a wave of dialects files one completion line and not one per dialect`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val wave = dialects() + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = wave + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + assertEquals( + 0, + device.dialectLines(), + "a wave of ${wave.size} should not file a line each" + ) + + assertEquals( + 1, + device.linesOfType(ChatMessage.TYPE_FROST_COMPLETE), + "the session's own line is what speaks for the wave" + ) + + // The dialects themselves still arrive. Only the transcript is quiet. + assertEquals( + wave.size, + device.items(session.id).count { + device.db.mantraDialectDao().getDialectById(it.eventId) != null + } + ) + } + } + + /** + * A batch of different kinds keeps every line. + * + * This is the shape every batch built before the dialect wave has -- one + * thing and its parts, where the parts file nothing of their own -- so it is + * the case the rule must not have broken. A dialect and the artifact that + * names it: two kinds, one of each, two lines. + * + * The artifact has to be built from what the proposal hands back, since its + * `dialectId` is a hash over the group's key at the room's path and the + * dialect is not a row until item 0 is applied. + */ + @Test + fun `a batch of one dialect and one artifact files both lines`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + lead = dialects().first(), + dependents = { dialect -> + listOf( + ArtifactEvent.build( + name = "Frank Talk", + url = "https://example.invalid/frank-talk", + visibility = "public", + license = "CC0", + dialectId = dialect.id, + versionLabel = "1", + ) + ) + } + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + assertEquals(1, creator.dialectLines(), "the dialect is one of its kind here") + assertEquals(1, creator.linesOfType("artifact"), "and so is the artifact") + } + /** * A proposal is the one place a remote party decides how much work everybody * else does, so the size cap is checked on arrival and not only when From dd405e90e5adf094fd4c7375002175879e2fdf7b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:03:08 +0200 Subject: [PATCH 7/8] test(dialects): guard the catalogue against a proposal's two limits DialectCatalogueSizeTest, modelled on ChapterProposalSizeTest and measured the way the manager measures it -- the real encoding rather than an estimate. Neither limit is checked anywhere a reviewer would see it: proposeSigningBatch enforces both with a require, so the first sign of the catalogue outgrowing a batch would be a wave that split in two, or one that threw. Somebody adding a language has no reason to be thinking about FROST. The third assertion changed shape while being written. "A wave is small rather than just legal", as a fraction of MAX_PROPOSAL_BYTES, failed: the catalogue encodes to 16,832 bytes and a quarter of the budget is 16,384. The quarter was a guess. What actually wants guarding is the cost of one dialect -- 301 bytes, bounded here at twice that -- because that is the number that would drift if a dialect event grew something large, and unlike a fraction it does not move every time a language is added. The size test's failure message says not to trim the catalogue. The view model already chunks by MAX_BATCH_SIZE, so a larger catalogue is two sessions rather than a bug; what would be lost is a group adopting the whole list answering once, and that is a decision rather than a repair. Also records the plan as built, with a table of the seven places the build chose differently, and indexes it in docs/README.md. One of those entries is a finding rather than a choice: m3-touch-targets.py reads comments as code, so a KDoc explaining that a row used a clickable Card "rather than a Modifier.clickable" broke a zero budget and with it ./gradlew check. The comment was reworded, which is the wrong fix. m3-audit.sh already solves this for the colour check and says why, at NOT_A_COMMENT. Co-Authored-By: Claude Opus 5 --- .../language/DialectCatalogueSizeTest.kt | 138 ++++++++++++++++++ docs/README.md | 12 ++ docs/african-dialect-catalogue.md | 37 ++++- 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/language/DialectCatalogueSizeTest.kt diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/language/DialectCatalogueSizeTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/language/DialectCatalogueSizeTest.kt new file mode 100644 index 00000000..c0d45cf5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/language/DialectCatalogueSizeTest.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.language + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.test.Test +import kotlin.test.assertTrue +import press.mantra.compose.managers.DialectScaffold +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.frost.FrostSigningEvents + +/** + * The catalogue against the two limits a signing proposal has. + * + * The tripwire for somebody adding a language, who has no reason to be thinking + * about FROST. Neither limit is this file's to choose and neither is checked + * anywhere a reviewer would see it: `proposeSigningBatch` enforces both with a + * `require`, so the first sign of the catalogue outgrowing a batch would be a + * wave that split in two, or one that threw. + * + * Modelled on `ChapterProposalSizeTest`, which does the same job for a chapter's + * markdown, and measured the way the manager measures it -- the real encoding + * rather than an estimate of it. + */ +class DialectCatalogueSizeTest { + + private val groupKey = "a".repeat(64) + + private fun unsigned(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + + /** The wave as `proposeSigningBatch` would encode it, in bytes on the wire. */ + private fun encodedBytes(languages: List): Int = + FrostSigningEvents + .encodeProposal( + DialectScaffold + .dialectsOf(languages = languages, createdAt = 1_700_000_000L) + .map(::unsigned) + ) + .encodeToByteArray() + .size + + /** + * The whole catalogue is one wave. + * + * **If this fails, do not trim the catalogue.** The view model already + * chunks a selection by `MAX_BATCH_SIZE`, so a larger catalogue is two + * sessions rather than a bug -- what is lost is the property that a group + * adopting the whole list answers once. Decide whether that is worth keeping, + * and if it is not, turn this assertion into a note. + */ + @Test + fun theWholeCatalogueFitsOneBatch() { + assertTrue( + AfricanLanguages.ALL.size <= FrostSigningManager.MAX_BATCH_SIZE, + "the catalogue holds ${AfricanLanguages.ALL.size} entries and a session signs at " + + "most ${FrostSigningManager.MAX_BATCH_SIZE}, so adopting all of it is no " + + "longer one wave -- read this test's note before trimming anything" + ) + } + + /** + * And it fits the byte limit, which is the one a `require` would throw on + * before anything reached a relay. + */ + @Test + fun theWholeCatalogueFitsOneProposal() { + val bytes = encodedBytes(AfricanLanguages.ALL) + + assertTrue( + bytes <= FrostSigningManager.MAX_PROPOSAL_BYTES, + "the catalogue encodes to $bytes bytes and a proposal carries at most " + + "${FrostSigningManager.MAX_PROPOSAL_BYTES}" + ) + } + + /** + * A dialect stays cheap. + * + * Per entry rather than as a fraction of the budget, because the fraction + * moves when a language is added and the per-entry cost does not -- so this + * stays an assertion about the *event* instead of quietly becoming one about + * the catalogue's length. + * + * A dialect is an id, an author, four single-value tags and a two- or + * three-letter code, and measured at 301 bytes for the catalogue as it + * stands. The bound is twice that, which catches a dialect event growing + * something large while leaving the ordinary drift of a longer country name + * alone. It is also self-consistent with [aFullBatchOfLongEntriesStillFits]: + * sixty-four entries at this bound is well inside + * [FrostSigningManager.MAX_PROPOSAL_BYTES]. + */ + @Test + fun aDialectStaysCheapOnTheWire() { + val perEntry = encodedBytes(AfricanLanguages.ALL) / AfricanLanguages.ALL.size + + assertTrue( + perEntry <= MAX_BYTES_PER_ENTRY, + "a dialect now costs $perEntry bytes in a proposal, over the " + + "$MAX_BYTES_PER_ENTRY this test allows -- it was 301 when the bound was set" + ) + } + + /** A full batch of the longest entries the catalogue could hold still proposes. */ + @Test + fun aFullBatchOfLongEntriesStillFits() { + val longest = AfricanLanguages.ALL.maxBy { it.name.length + it.country.length } + + val bytes = encodedBytes( + (0 until FrostSigningManager.MAX_BATCH_SIZE).map { index -> + longest.copy(country = "${longest.country} $index") + } + ) + + assertTrue( + bytes <= FrostSigningManager.MAX_PROPOSAL_BYTES, + "a full batch of the catalogue's longest entry encodes to $bytes bytes" + ) + } + + private companion object { + /** See [aDialectStaysCheapOnTheWire]: twice the measured cost of one. */ + const val MAX_BYTES_PER_ENTRY = 602 + } +} diff --git a/docs/README.md b/docs/README.md index f47439a6..1e47a605 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ silent, or a decision that looked arbitrary and was not. | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | | [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes | | [chapter-uploads.md](./chapter-uploads.md) | picking files off a device and having the group sign each one in as a chapter — why N files can never be one signing session, and the four things the single-chapter path gets away with | +| [african-dialect-catalogue.md](./african-dialect-catalogue.md) | filling a group's dialects from a bundled catalogue of the languages most spoken in Africa — why a dialect is a language *in a country*, why nothing in the schema makes one unique so duplication has to be stopped at proposal time, and the race two admins hit if the picker cannot see what is already being signed | | [project-feature.md](./project-feature.md) | bringing the server schema's `project` and its two join models into a group that has no accounts and no server — a creator tag of its own and owners as a signed set of `p` tags, the role the parser has to be told about or every project lands ownerless, why a membership is its own signed event rather than a list, and the kind band that is already double-booked | | [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme | | [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand | @@ -88,6 +89,17 @@ built, because each of those is wrong in the single-chapter path today. Its two splitter phases are separable from the rest and worth reading on their own: what a chunk is, measured against the parser the web app translates with rather than assumed. +The African dialect catalogue note is a phased plan that has been built. It is +the smallest of the nip30303 plans and the only one whose hard part is an +identity rule rather than a ceremony: a dialect's id is a content hash including +`createdAt`, there is no unique index, and the rows are quorum-signed, so two +proposals of the same dialect are two rows and neither is wrong by any rule the +schema knows. Read its two framing sections before its phases -- they are what +every phase is built to keep -- and read the batch-signing note first if the +all-or-nothing rule is not already familiar. Its last phase is a fix the feature +makes necessary rather than part of it, which is worth knowing before reading the +diff. + The projects note is a phased plan that has been built as far as its seventh phase, and is the only one whose starting point is outside this repository: the platform's server schema, diff --git a/docs/african-dialect-catalogue.md b/docs/african-dialect-catalogue.md index 2aad2d92..8353f132 100644 --- a/docs/african-dialect-catalogue.md +++ b/docs/african-dialect-catalogue.md @@ -5,7 +5,14 @@ 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. +**Built.** All seven phases are in, one commit each, and the phases below are +kept as written -- they are the reasoning, and the code is easier to read against +the argument it came from than against a summary of itself. Where the +implementation chose differently the table at the end says so. + +`AfricanLanguages.ALL` is the catalogue, `DialectScaffold` answers what a room is +missing, and `AddDialectsFromCatalogueScreen` is the picker, reached from the +group's detail screen beside "Add dialect". Two facts shape every phase and are stated before them, because both are easy to design around and expensive to discover afterwards. @@ -480,3 +487,31 @@ 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. + +--- + +## Where the build chose differently + +| the plan said | the build does | why | +|---|---|---| +| "about fifty entries" | 56 — 53 African, 3 European | the fan-out across countries landed there. 8 under `MAX_BATCH_SIZE`, which is thinner than it reads; phase 7's test is the tripwire | +| one `african` list | two, `african` and `european`, with `ALL` their sum | the ordering test needs a list that is actually grouped by country, and appending the three European entries breaks that grouping. It also makes "drop them" one edit, which is what the plan promised | +| `missingFrom(existing, pending, catalogue)` | that, plus a public `DialectScaffold.Key` and three `keyOf` overloads | the identity rule has four callers — the scaffold, the row classifier, the selection and the view model's unpick — and spelling `(code, country)` four times is four chances to forget `trim()` | +| phase 4 ships the picker with its FAB | phase 4 shows and picks, phase 5 proposes | a commit whose FAB calls a method written in the next commit does not compile. Split so each commit does something whole | +| `proposeSelectedDialects(localChatRoom, selected, …)` | the selection is read internally | the view model owns `selected`; passing it back in lets a caller pass something else, and nothing needs to | +| `CatalogueRow.rowsOf` computes the three standings | it derives them from `missingFrom` | two functions deciding what is proposable is one too many. MISSING is whatever that function says, and held-versus-in-flight is a label on what it already excluded | +| a wave "is small rather than just legal" — under a quarter of `MAX_PROPOSAL_BYTES` | at most 602 bytes *per entry* | the catalogue encodes to 16,832 bytes, which is 25.7% of the budget — the quarter was a guess and it failed. The per-entry cost is the thing that would actually drift, and it does not move when a language is added | + +### One thing found on the way + +`docs/scripts/m3-touch-targets.py` reads comments as code. A KDoc line on the +picker's row composable explaining that it used a clickable `Card` "rather than a +`Modifier.clickable`" was counted as a bare clickable chain, which breaks a zero +budget and so breaks `./gradlew check`. The comment was reworded to get around +it, which is the wrong fix -- the sentence was right. + +`m3-audit.sh` already solves this for the colour check and says why, at +`NOT_A_COMMENT`: "a note *about* a hardcoded colour counts as one -- which +happened the first time a call site was fixed and the commit explained what it +had replaced." The touch-target checker wants the same treatment, and +`m3-title-case.py` should be looked at too, since it scans whole files. From 46ccec3439e2f9e67f342edcc6a67cf6b4107882 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 00:25:16 +0200 Subject: [PATCH 8/8] fix(scripts): stop m3-touch-targets.py reading comments as code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KDoc line on the dialect picker's row composable, explaining that it used `Card(onClick = …)` rather than a `Modifier.clickable`, was counted as a bare clickable chain. The budget is zero, so a sentence about the rule broke `./gradlew check`. The comment was reworded to get around it, which was the wrong fix -- the sentence was right -- and it is restored here. Not a new rule. m3-spacing-positions.py skips `\s*(//|\*|/\*)` and m3-title-case.py skips the same three prefixes as a startswith tuple; only this checker was missing the two block-comment ones, having been written with `//` alone. m3-audit.sh explains the whole problem at NOT_A_COMMENT, where a note about a hardcoded colour once counted as a colour. The same function had the mirror bug, which is the dangerous direction. It looked for minimumInteractiveComponentSize in a two-line window without stripping comments, so a comment above a real bare `.clickable` that merely mentioned the modifier would have excused it. A false positive is noise; a silently excused offender is a miss. Both lines of the window are now read as code only. Checked against six cases: a bare clickable is still caught, a guard on the same line and a guard on the line above still excuse one, a starred KDoc no longer counts, and a real offender under a comment naming the modifier is now caught. Counts for the tree are unchanged -- 0 before and after -- which was worth establishing, since a broken detector would also report zero. A prefix test rather than block tracking, and the limitation is written on the function: a `/* ... */ `body line not starting with `*` still reads as code. There are 539 block comments under ui/ and not one such line, because every KDoc here stars its body, and three identical rules spelled three ways would be worse than one limitation on the record. Co-Authored-By: Claude Opus 5 --- .../AddDialectsFromCatalogueScreen.kt | 8 ++--- docs/african-dialect-catalogue.md | 34 ++++++++++++++----- docs/scripts/m3-touch-targets.py | 29 ++++++++++++++-- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt index 66a63737..8c9b478d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectsFromCatalogueScreen.kt @@ -382,12 +382,10 @@ private fun CatalogueList( /** * One catalogue entry. * - * A clickable `Card` rather than a bare clickable modifier, so the whole row is - * the target and is 48dp without anybody having to remember + * `Card(onClick = …)` rather than a `Modifier.clickable`, so the whole row is the + * target and it is 48dp without anybody having to remember * `minimumInteractiveComponentSize` -- the same shape `SelectSubgroupAdminsScreen` - * uses for picking members. (Spelled this way round because - * `m3-touch-targets.py` reads comments as code; see the note in m3-audit.sh on - * the colour check, which hit the same thing.) + * uses for picking members. */ @Composable private fun LanguageRow( diff --git a/docs/african-dialect-catalogue.md b/docs/african-dialect-catalogue.md index 8353f132..fdec86ce 100644 --- a/docs/african-dialect-catalogue.md +++ b/docs/african-dialect-catalogue.md @@ -502,16 +502,34 @@ translate into, and fifty of them arriving unasked is a statement nobody made. | `CatalogueRow.rowsOf` computes the three standings | it derives them from `missingFrom` | two functions deciding what is proposable is one too many. MISSING is whatever that function says, and held-versus-in-flight is a label on what it already excluded | | a wave "is small rather than just legal" — under a quarter of `MAX_PROPOSAL_BYTES` | at most 602 bytes *per entry* | the catalogue encodes to 16,832 bytes, which is 25.7% of the budget — the quarter was a guess and it failed. The per-entry cost is the thing that would actually drift, and it does not move when a language is added | -### One thing found on the way +### One thing found on the way, and fixed -`docs/scripts/m3-touch-targets.py` reads comments as code. A KDoc line on the -picker's row composable explaining that it used a clickable `Card` "rather than a -`Modifier.clickable`" was counted as a bare clickable chain, which breaks a zero -budget and so breaks `./gradlew check`. The comment was reworded to get around -it, which is the wrong fix -- the sentence was right. +`docs/scripts/m3-touch-targets.py` read comments as code. A KDoc line on the +picker's row composable explaining that it used `Card(onClick = …)` "rather than +a `Modifier.clickable`" was counted as a bare clickable chain, which breaks a +zero budget and so breaks `./gradlew check`. The comment was first reworded to +get around it, which was the wrong fix -- the sentence was right. `m3-audit.sh` already solves this for the colour check and says why, at `NOT_A_COMMENT`: "a note *about* a hardcoded colour counts as one -- which happened the first time a call site was fixed and the commit explained what it -had replaced." The touch-target checker wants the same treatment, and -`m3-title-case.py` should be looked at too, since it scans whole files. +had replaced." + +Fixed on 2026-10-03, and the KDoc restored. Two things were worth knowing while +looking: + + - **The other two checkers already had it.** `m3-spacing-positions.py` skips + `\s*(//|\*|/\*)` and `m3-title-case.py` skips the same three prefixes as a + `startswith` tuple. Only the touch-target one was missing the two block-comment + prefixes, so the fix is the house rule rather than a new one. + - **The same function had the mirror bug, which is the dangerous direction.** It + looked for `minimumInteractiveComponentSize` in a two-line window without + stripping comments, so a comment above a real bare `.clickable` that merely + mentioned the modifier would have excused it. A false positive is noise; that + is a miss. Both lines of the window are now read as code only. + +The fix is a prefix test and not block tracking, deliberately: a `/* … */` body +line that does not start with `*` still reads as code. There are 539 block +comments under `ui/` and not one such line, because every KDoc here stars its +body -- and three identical rules spelled three ways would be worse than one +limitation written down. diff --git a/docs/scripts/m3-touch-targets.py b/docs/scripts/m3-touch-targets.py index 56470da1..85f0bc07 100755 --- a/docs/scripts/m3-touch-targets.py +++ b/docs/scripts/m3-touch-targets.py @@ -14,6 +14,14 @@ a pointer on desktop has to land on. Components that carry their own minimum -- IconButton, Button, Checkbox, ListItem and the rest of material3 -- are not `.clickable` call sites and never appear here. +Comments are not code, in either direction. A KDoc line explaining that a row uses a +clickable `Card` *rather than* a `Modifier.clickable` was counted as a call site, which +breaks a zero budget and so breaks `./gradlew check`; and a comment above a real call site +that merely mentions `minimumInteractiveComponentSize` would have excused it. The first is +noise and the second is a miss, so both lines of the window are read as code only. This is +the same thing m3-audit.sh's `NOT_A_COMMENT` exists for, spelled as m3-title-case.py +spells it -- see the note there on a colour comment counting as a colour. + Usage: m3-touch-targets.py [--list] """ import os, re, sys @@ -21,6 +29,18 @@ import os, re, sys UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui' +def _is_comment(line): + """Whether `line` is a comment line: `//`, or a `/*` block's opener or body. + + A prefix test rather than block tracking, because that is what the other two checkers + do and three identical rules spelled three ways is worse than one limitation. The + limitation: a `/* ... */` body line that does not start with `*` reads as code. There + are 539 block comments under `ui/` and not one such line, since every KDoc here stars + its body -- if that ever stops being true, this is the function to make stateful. + """ + return line.lstrip().startswith(('//', '*', '/*')) + + def offenders(): found = [] for root, _, files in os.walk(UI): @@ -32,11 +52,14 @@ def offenders(): for i, line in enumerate(lines): if '.clickable' not in line: continue - if line.lstrip().startswith('//') or line.startswith('import '): + if _is_comment(line) or line.startswith('import '): continue # The modifier may sit on this line or on the one above, since a chain - # broken across lines is the common shape. - window = '\n'.join(lines[max(0, i - 1):i + 1]) + # broken across lines is the common shape. Comment lines are dropped from + # the window rather than kept: an exemption has to be in the code. + window = '\n'.join( + l for l in lines[max(0, i - 1):i + 1] if not _is_comment(l) + ) if 'minimumInteractiveComponentSize' in window: continue found.append((path, i + 1, line.strip()[:90]))