From 9d34a2cc99f49d9e97d29a6af4c2366cdc20fa55 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:34:29 +0200 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 07/28] 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 a998cf20a9f6f0d3e411c6b60e5932696159a6f0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:37:27 +0200 Subject: [PATCH 08/28] docs: the plan for an ios build, phased against the route Curated walked Curated reached a device in eight commits. Five defects stand between this tree and the same place -- the native chain not configuring on a mac, Room with no way to construct the database on native, a cancellation Swift cannot see, two separate ios link failures, and CIO having no TLS on kotlin/native. Each is diagnosed upstream, so the work is porting a known answer. Three places this tree cannot simply cherry-pick: the test-name sweep has to run over Mantra's own 84 names in 35 files (ten of Curated's 38 files are from lines Mantra never pulled, and Mantra has eight Curated does not); the fork's "linux cinterop" fix is `if (!currentOs.isLinux)` and is really the fix that makes a mac work; and `@ConstructedBy` changes what Room's KSP generates for every target, not only the one it unblocks. Co-Authored-By: Claude Opus 5 --- docs/README.md | 1 + docs/ios-build.md | 462 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 docs/ios-build.md diff --git a/docs/README.md b/docs/README.md index f47439a6..50ebb0bc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ silent, or a decision that looked arbitrary and was not. | [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles | | [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose | | [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 | +| [ios-build.md](./ios-build.md) | building for an iPhone, following the route the Curated fork walked — the five defects between here and a device, why the fork's "linux cinterop" fix is the one that makes a mac work, and the one annotation that unblocks native by changing every other target | | [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 | | [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 | diff --git a/docs/ios-build.md b/docs/ios-build.md new file mode 100644 index 00000000..55e56c38 --- /dev/null +++ b/docs/ios-build.md @@ -0,0 +1,462 @@ +# Bringing up the iOS target + +What it takes to build and run Mantra on an iPhone, phased, following the route +the Curated fork already walked. Every one of its findings is diagnosed and +reproducible here, so the work is porting a known answer rather than +re-discovering it. + +The headline is that almost nothing is missing. The `iosArm64` and +`iosSimulatorArm64` targets are **already declared** in +[composeApp/build.gradle.kts:36](../composeApp/build.gradle.kts), gated behind +`OperatingSystem.current().isMacOsX` the way everything in the composite chain is; +`composeApp/src/iosMain` holds ten `actual` files that match `jvmMain`'s ten +one-for-one, so no `expect` is unimplemented; `commonMain` has no `java.*` or +`javax.*` import anywhere; quartz 1.14.0 publishes `iosArm64` and +`iosSimulatorArm64` klibs; and `iosApp/` is byte-identical to Curated's apart from +brand tokens and one icon file, Xcode build phase and all. + +What stands in the way is five concrete defects, which Curated hit in the order a +build hits them — configure, compile, link, connect, sign — and fixed in eight +commits. + +## The eight commits, and what each one is here + +| Curated | what it did | how it lands here | +|---|---|---| +| `94de86a3`, `cbf23640`, `64b37447` | three gitlink bumps of `lightning-kmp-app`, carrying a secp256k1-kmp build fix | the same three bumps, verbatim — Mantra is pinned at their exact parent | +| `987df2c8` | `@ConstructedBy`, `@Throws(CancellationException)`, an unused import, `TEAM_ID` | the same, minus `TEAM_ID`, which is account-specific | +| `aa9312f0` | comma-free `commonTest` names, plus a disabled compiler phase | **rewritten**, not cherry-picked — see Phase 3 | +| `b9140c6a` | the Darwin ktor engine | near-verbatim; the shape it assumes is the shape here | +| `bd47c841` | app icon and `MARKETING_VERSION` | needs Mantra artwork | +| `a673c85e` | `versionName` | a release decision, not a port | + +## What is already done for you + +**The pin lines up exactly.** Mantra records `lightning-kmp-app` at +`84cc44c855e3d173e26016b807c449153a3af597`. That is the *parent* of Curated's +`94de86a3`. The three bumps are therefore a straight walk along the same gitlinks — +`84cc44c` → `2dcd4fb` → `db15e59` → `e0d2b82` — with no rebase and no divergence +to reconcile. + +**The library's own iOS gating is already right.** `lightning-kmp-app/library` +declares `iosArm64()` and `iosSimulatorArm64()` under the same `isMacOsX` guard, +and sets `kotlin.native.enableKlibsCrossCompilation=true`. Nothing in the outer +build has to teach it about apple targets. + +**`commonMain` is clean, and the publish surface is small.** One `HttpClient {` +construction in the whole module +([NostrSocketClientFactory.kt:10](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt)), +no explicit Coil `ImageLoader` anywhere, six `@Throws` annotations on the publish +path. That is the same shape Curated's fixes were written against, so Phases 2 and +4 are small and exact rather than a search. + +**The audit does not look at tests.** `docs/scripts/m3-audit.sh` scans +`composeApp/src/commonMain/kotlin/press/mantra/compose/ui` only, so Phase 3's sweep +over test names cannot trip `./gradlew check`. + +## What actually blocks it + +Five things, independent of each other except for the order the build reveals them +in: + +1. **The chain will not configure on a mac.** secp256k1-kmp's `.def` declares + `staticLibraries` for every target and the cinterop tool refuses to emit a klib + while the archive is absent. Off a Linux host the Linux archives are never + built, so the IDE sync and configuration fail with `cinterop file ... does not + exist`. The fix — `9bcae25` in secp256k1-kmp, hardened by `8adf691` — stubs those + archives under `if (!currentOs.isLinux)`. Its commit subject says "linux + cinterop", which reads backwards from here: **it is the fix that makes the chain + work on a mac**, and it is a strict no-op on Linux. + +2. **Room cannot construct the database on native.** + `MantraDatabaseConstructor` already exists in `commonMain`, `expect`, + `@Suppress`ed and referenced by nothing. Without `@ConstructedBy` naming it, + Room's KSP generates no `actual`, and on a target with no reflection there is no + other way in. + +3. **Swift cannot see a cancellation as a cancellation.** Six `@Throws` sites on + the publish path name `NostrPublishException` and not + `kotlin.coroutines.cancellation.CancellationException`, so a cancelled publish + crosses the ObjC boundary as an unexpected throw. + +4. **The iOS link tasks fail, twice over.** `linkDebugTestIosArm64` is rejected by + Kotlin/Native over backticked test names containing a comma (`Name contains + illegal characters: ','`), and `linkReleaseFrameworkIosArm64` crashes Kotlin + 2.4.10's release backend inside `StaticInitializersOptimization`. + +5. **Nothing secure connects on a device.** CIO has no TLS implementation on + Kotlin/Native — every `wss://` and `https://` call dies at the handshake with + `TLS sessions are not supported on Native platform`. The relay sockets and + Coil's image fetcher both sit on a default-engine client. + +Everything below is ordered by that list. + +--- + +## Phase 0 — bootstrap, and a recorded baseline + +Nothing is changed in this phase. Its output is a green build to compare against, +because four of the five defects above are invisible until a mac is in the loop and +you do not want to be guessing afterwards which failure you introduced. + +**In this worktree, initialise the chain first.** Four nested submodules — +`lightning-kmp-app` → `experimental/lightning-kmp` → `experimental/bitcoin-kmp` → +`experimental/secp256k1-kmp` (→ `native/secp256k1`) — are unpopulated, and gradle +dies early and confusingly with `Project with path ':library' not found`: + +```bash +git -c protocol.file.allow=always submodule update --init --recursive +``` + +`protocol.file.allow=always` is required: the recorded url for `lightning-kmp-app` +is the main checkout's path, and git refuses `file` transport for submodules by +default. The last level (`native/secp256k1`, fetched from GitHub) can stall for +ten minutes or more; let the recursive call do what it can and finish that one +level from the main checkout's copy. Trust `git -C rev-parse HEAD` per +level rather than `git submodule status --recursive`, which lies after a +per-level url override. + +Then record the baseline: + +```bash +ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest +``` + +Note the jvmTest count and that it is green. `IdentitySwitchJvmTest`'s "last +profile opened" case fails about one run in nine on a 5s DataStore timeout on a +clean tree too — if you see exactly that, re-run before believing it. + +**Deliverable:** no commit. A noted baseline. + +--- + +## Phase 1 — advance the library chain + +The three gitlink bumps, kept as three commits so each one says what it carries. +Walk the outer pin, and let the nested gitlinks come with it. + +```bash +git -C lightning-kmp-app fetch https://github.com/kngako/lightning-kmp-app.git 'refs/heads/*:refs/remotes/github/*' +git -C lightning-kmp-app checkout --detach 2dcd4fbe1513421d100a4485583f167b1fdcd47c +git -c protocol.file.allow=always -C lightning-kmp-app submodule update --init --recursive +git add lightning-kmp-app && git commit +``` + +In a worktree the submodule's `origin` is the **main checkout's copy**, not GitHub, +so `git fetch origin` shows nothing new — fetch the `.gitmodules` url directly, as +above. Each bump moves `experimental/lightning-kmp` too, which moves `bitcoin-kmp`, +which moves `secp256k1-kmp`, so re-run `submodule update --init --recursive` inside +`lightning-kmp-app` after each checkout or the chain is left mid-walk. + +The three, in order: + +| commit | pin | what it carries | +|---|---|---| +| 1 | `2dcd4fb` | secp256k1-kmp `9bcae25` — the stub archives that let the chain configure off Linux | +| 2 | `db15e59` | the same fix merged into the `threshold` branches, so the nested gitlinks name branch tips rather than side commits | +| 3 | `e0d2b82` | secp256k1-kmp `8adf691` — registers the stub task with `tasks.register` instead of the delegate provider, which Gradle 9 requires | + +Commits 2 and 3 look like bookkeeping and are not: without 3 the stub task is +never registered under a name, and without 2 the pins sit on commits that were +rebased away. + +**Verify, on Linux.** The whole fix is behind `if (!currentOs.isLinux)`, so Linux +must be *unchanged*: + +```bash +ANDROID_HOME=$HOME/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest :lightning-kmp-app:library:jvmTest +``` + +Same test count, same green, as Phase 0. The library's own ~159 tests run inside +the composite build and are the thing that proves the wiring still gives it what it +expects. + +**Verify, on a mac.** `./gradlew :composeApp:tasks` configures at all, and +`:composeApp:compileKotlinIosArm64` resolves `fr.acinq.phoenix:lightning-kmp-app`. +That is the phase's actual purpose. + +> Suggested message, commit 1: +> `build: carry secp256k1-kmp's fix for configuring off a linux host` +> — and say in the body that the upstream subject reads the other way round. + +--- + +## Phase 2 — declare the Room constructor and cancellation to Swift + +Four small edits, one commit. All of it compiles on Linux. + +**`@ConstructedBy`.** Add the import and the annotation above +[MantraDatabase.kt:369](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt)'s +`@ColumnTypeConverters`: + +```kotlin +import androidx.room3.ConstructedBy +… +@ConstructedBy(MantraDatabaseConstructor::class) +@ColumnTypeConverters(MantraConverters::class) +abstract class MantraDatabase: RoomDatabase() { +``` + +The `expect object` it names already exists and needs no edit. This is the one +change in the whole plan that **also changes the JVM and Android builds**: Room's +KSP starts generating an `actual` for every target rather than none, so the +annotation that unblocks iOS is the annotation most likely to move something +underneath `:composeApp:jvmTest`. Run it and read the output rather than assuming. + +**Cancellation on the publish path.** Add +`kotlin.coroutines.cancellation.CancellationException::class` to the six existing +`@Throws`, and give `EventPublishTransport.publishEvent` one it does not have: + +- [RelayPool.kt:289](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt) +- [RelaysSocketManager.kt:103](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) and `:118` +- [NostrPublisherRepository.kt:33](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt), `:55` and `:66` +- [EventPublishTransport.kt:31](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt) — new, naming `NostrPublishException` as well + +Keep the fully-qualified style the surrounding annotations already use. + +**One unused import.** `import kotlin.native.Platform` in +[Platform.ios.kt](../composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt) +shadows the app's own `Platform` interface. Delete the line. Nothing on Linux +compiles `iosMain`, so this one is carried on Curated's word and the compiler's, +later. + +**Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest`. +**On a mac:** `:composeApp:compileKotlinIosArm64`. + +> `feat(ios): declare the Room constructor and publish cancellation to swift` + +--- + +## Phase 3 — unbreak the iOS link tasks + +Two unrelated link failures, one commit, because they are the same symptom from the +outside: `./gradlew :composeApp:linkDebugTestIosArm64` and +`:composeApp:linkReleaseFrameworkIosArm64` both fail. + +### The comma names — rewrite, do not cherry-pick + +Curated's `aa9312f0` touched 38 `commonTest` files. **Ten of them do not exist +here** (`GroupEventProposalTest`, `CuratedSchemaEventTest`, `CuratedEntryEventTest`, +`CuratedSchemaTemplateTest`, `GroupCuratedEntryTest`, `GroupCuratedSchemaTest`, +`GroupPostTest`, `CuratedSuggestionTest`, `MarmotRoomIdentityTest`, +`MarmotPayloadBoundsTest`, `FrostSigningMembersTest`) — they belong to the fork's +curated-list and Marmot-v2 lines, neither of which Mantra has pulled. Meanwhile +Mantra has test files Curated does not: `ProjectEventTest`, +`ChapterProposalSizeTest`, `UploadChaptersStateTest`, `UploadReviewTest`, +`SubgroupLineageTest`, `MarkdownBlockTest`, `MarkdownProducerTest`, +`ChapterTextTest`. + +So do the sweep over **Mantra's own tree**: 84 backticked `fun` names across 35 +files in `composeApp/src/commonTest`. Every comma in every one of them is followed +by exactly one space — verified, no exceptions — so the rewrite is total and +unambiguous: + +```bash +grep -rl 'fun `[^`]*,[^`]*`' composeApp/src/commonTest --include='*.kt' \ + | xargs perl -i -pe 's/(fun `[^`]*`)/($x=$1)=~s{, }{ }g; $x/ge' +``` + +Then read the diff. A name losing its comma loses a little of its rhythm and none +of its meaning (`read as its kind, tags and content` → `read as its kind tags and +content`); if any of the 84 reads badly afterwards, reword that one by hand rather +than leaving a comma behind. + +**Leave `jvmTest` alone.** It has 138 comma names and Kotlin/Native never compiles +it. Curated left it alone for the same reason, and widening the sweep would put 138 +cosmetic edits in a commit that is about a link failure. + +### The release backend crash + +Append to `composeApp/build.gradle.kts`, after the `kotlin { }` block and before +`android {` at line 161 — the same place Curated put it: + +```kotlin +tasks.withType().configureEach { + if (name.startsWith("linkRelease")) { + toolOptions.freeCompilerArgs.add("-Xdisable-phases=RemoveRedundantCallsToStaticInitializersPhase") + } +} +``` + +With the comment Curated wrote, which is the valuable part: the pass is an LTO +optimization, so only optimized links reach it (which is why the debug test binary +links fine); it assumes every function in the call graph has at least one statement +in its body; something in this module's graph arrives with an empty one; and +disabling it costs the redundant initializer calls it would have removed and +nothing else. + +`tasks.withType()` matches nothing on Linux, so this is inert +there — but the class resolves from the KMP plugin's classpath on any host, so the +script still has to compile. `:composeApp:compileDebugKotlinAndroid` proves that +much. + +**Expect this one not to be the end of it.** The crash is a property of the call +graph, and Mantra's graph is a superset of Curated's — projects, chapter uploads, +multi-parent subgroups, the markdown producer. The same phase may crash somewhere +else, or a different phase may crash. Treat the stack trace as the instruction: +disable the named phase, and write down why. + +**Verify, on a mac:** `:composeApp:linkDebugTestIosArm64` and +`:composeApp:linkReleaseFrameworkIosArm64`. +**On Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:jvmTest` — the +renamed tests must still run, and the count must not move. + +> `build(ios): unbreak the ios link tasks` + +--- + +## Phase 4 — put every ktor client on Darwin + +CIO has no TLS on Kotlin/Native. Pick the engine per platform instead of letting +the classpath pick it. + +**Catalogue entry**, beside `ktor-client-cio` in +[gradle/libs.versions.toml:82](../gradle/libs.versions.toml): + +```toml +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +``` + +**Dependency**, inside the existing `isMacOsX`-gated `iosMain.dependencies` block at +[composeApp/build.gradle.kts:154](../composeApp/build.gradle.kts). It goes inside +the gate, which is why Linux resolution is untouched by this phase. + +**Three new files**, an `expect` and two `actual`s, in +`press.mantra.compose.network`: + +- `commonMain/.../PlatformHttpClientEngine.kt` — `internal expect fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*>` +- `androidMain/.../PlatformHttpClientEngine.android.kt` — `= CIO` +- `jvmMain/.../PlatformHttpClientEngine.jvm.kt` — `= CIO` +- `iosMain/.../PlatformHttpClientEngine.ios.kt` — `= Darwin` + +**Two call sites.** `defaultSocketsHttpClient` becomes +`HttpClient(platformHttpClientEngineFactory()) { … }`, keeping its `WebSockets` +block as-is. And `MantraApp` gains the singleton Coil loader, because Coil's +default ktor fetcher builds a bare `HttpClient()` and would fail on `https://` +images exactly as the sockets did: + +```kotlin +private val imageHttpClient by lazy { HttpClient(platformHttpClientEngineFactory()) } + +@Composable +fun MantraApp(…) { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { add(KtorNetworkFetcherFactory(httpClient = { imageHttpClient })) } + .build() + } + + MantraTheme { … } +} +``` + +`setSingletonImageLoaderFactory` goes *above* `MantraTheme`, at the top of the +composable's body, so it is installed before the first `AsyncImage` composes. +`MantraApp` is the only place both call sites are reachable from, and Mantra has no +`ImageLoader` configuration today to reconcile with. + +**Verify, on Linux:** `:composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinJvm :composeApp:jvmTest` — the android and jvm `actual`s and both call sites compile here, which is most of the phase. +**On a mac:** `:composeApp:compileKotlinIosArm64`, then the real test — a device or +simulator that reaches a relay over `wss://` and renders an avatar over `https://`. + +> `fix(ios): build ktor clients on Darwin so wss/https work on a device` + +--- + +## Phase 5 — signing, icon, version + +Three things Curated's last two commits did that **cannot be ported**, only +decided. Each needs an answer from you before it can be written. + +**`TEAM_ID`.** [iosApp/Configuration/Config.xcconfig](../iosApp/Configuration/Config.xcconfig) +has `TEAM_ID=` empty, and the bundle identifier is built from it +(`press.mantra.ios$(TEAM_ID)`). Curated committed its development team id in the +clear, which is normal for an xcconfig — a team id is not a secret — but it is +*their* team id and signs builds to *their* account. Mantra needs its own, or an +`xcconfig` that stays out of git. + +**The app icon.** Curated replaced its single `app-icon-1024.png` with a 1024 +light variant and a 1024 dark one, adding the `luminosity: dark` appearance to +`Contents.json` and leaving the `tinted` slot filenameless so iOS derives it. +Mantra's `AppIcon.appiconset` still holds one 1024 file and one entry. The +*structure* ports; the artwork does not. Two 1024×1024 PNGs are needed. + +**`MARKETING_VERSION`.** Mantra's xcconfig says `1.0` while +`android { versionName }` says `0.1.2` — the two have simply never been reconciled +because nothing shipped on iOS. Curated set both to the `0.1.x` line. Pick one +number for both; the mismatch is the bug, not either value. + +Curated's `a673c85e` moved `versionName` from `0.1.2` to `0.1.1`, which is a +release-bookkeeping step on their tree and means nothing on this one. Do not port +it. + +> `feat(ios): the app icon, the signing team, and one version across both stores` + +--- + +## Phase 6 — the part only a mac can find + +Phases 1 to 4 fix everything Curated hit. They do not fix what Curated never had, +and Mantra has pulled four lines of work the fork does not carry: projects, chapter +uploads, multi-parent subgroups, and the markdown producer. None of it has ever +been compiled for `iosArm64`. + +So the honest shape of this phase is a loop, not a list: + +```bash +./gradlew :composeApp:compileKotlinIosArm64 +./gradlew :composeApp:linkDebugTestIosArm64 +./gradlew :composeApp:iosSimulatorArm64Test +./gradlew :composeApp:linkReleaseFrameworkIosArm64 +``` + +then Xcode, whose build phase already runs +`:composeApp:embedAndSignAppleFrameworkForXcode` against the `ComposeApp` +framework, deployment target 18.2. + +Known places to look first, each a guess with a reason: + +- **`FilePicker.ios.kt`** is the newest `iosMain` actual and the one behind chapter + uploads. It exists, so it compiles in principle; whether `UIDocumentPicker` + actually returns bytes on a device is untested anywhere. +- **Anything reached only from the new screens** — the `StaticInitializers` crash in + Phase 3 is call-graph-shaped, and these four lines are what widened the graph. +- **`kotlinx.datetime` and `okio` edges** in the markdown and chapter paths, which + are the usual source of a jvm-only overload that compiles everywhere but native. + +Each fix found here is its own commit, in the voice of what broke rather than +"ios fixes". + +--- + +## Phase 7 — write down what it cost + +This document, revised from a plan into a record: which of the five defects +behaved as predicted, what Phase 6 actually found, and which compiler phase is +disabled with which stack trace behind it. Then revise its row in +[docs/README.md](./README.md), which this document already carries, so it describes +what happened rather than what was planned. + +The `-Xdisable-phases` flag in particular needs a home in prose. It is a load- +bearing workaround for an upstream compiler bug, it is invisible from the source, +and the next person to bump Kotlin should be able to find out in one grep whether +they can take it out again. + +--- + +## What to do if there is no mac + +Phases 1 to 4 are worth landing anyway, and all four are verifiable on Linux for +the half that Linux compiles: + +| phase | Linux-verifiable | needs a mac | +|---|---|---| +| 1 | the fix is `if (!currentOs.isLinux)`, so "unchanged" is the whole check | that it configures at all | +| 2 | `@ConstructedBy` and all seven `@Throws` | the unused-import removal | +| 3 | the renamed tests still run; the build script still compiles | both link tasks | +| 4 | the `expect`, the android and jvm `actual`s, both call sites | the Darwin `actual`, and TLS actually working | +| 5 | nothing | nothing — it needs decisions, not a host | + +Phase 2's `@ConstructedBy` is the one with real Linux-side risk, since it changes +what Room's KSP generates for every target. Land it on its own and read +`:composeApp:jvmTest` carefully. From a8f0e26f2ae7329bdffb34ceb8d608ca25192caa Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:43:32 +0200 Subject: [PATCH 09/28] build: carry secp256k1-kmp's fix for configuring off a linux host `84cc44c` -> `2dcd4fb`, which carries secp256k1-kmp `9bcae25` down the chain. Its upstream subject says "linux cinterop sync fix" and reads backwards from here: the whole of it is behind `if (!currentOs.isLinux)`, so on this host it is a no-op, and what it actually fixes is configuring the chain on a mac. secp256k1-kmp's .def declares `staticLibraries` for every target and the cinterop tool refuses to emit a klib while the archive is absent from libraryPaths. Off a linux host the linux archives are never built -- buildSecp256k1LinuxArm64 is onlyIf(isLinux), and the linuxX64 cinterop points at native/build/linux, which only a linux host fills -- so the cinterop commonization an IDE sync resolves for linuxMain fails with "cinterop file ... does not exist". Cinterop only parses headers, so the fix leaves it enabled and satisfies the archive check with placeholder ones; compile, link and publication for the linux targets stay off. Co-Authored-By: Claude Opus 5 --- lightning-kmp-app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-kmp-app b/lightning-kmp-app index 84cc44c8..2dcd4fbe 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 84cc44c855e3d173e26016b807c449153a3af597 +Subproject commit 2dcd4fbe1513421d100a4485583f167b1fdcd47c From 1b8df6b44646c5c4aa763483b37ebafdff3c3cea Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:43:54 +0200 Subject: [PATCH 10/28] build: follow the chain onto the merged threshold tips `2dcd4fb` -> `db15e59`. The previous commit's fix reached this tree as a side commit on each of the three nested repositories; this moves all of them onto the `threshold` branch tips that merged it -- secp256k1-kmp `9bcae25` -> `6c7b1e4`, and bitcoin-kmp and lightning-kmp with it. Bookkeeping in appearance only. The pins the previous commit named were rebased away upstream, so a later fetch would not have found them. Co-Authored-By: Claude Opus 5 --- lightning-kmp-app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-kmp-app b/lightning-kmp-app index 2dcd4fbe..db15e593 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 2dcd4fbe1513421d100a4485583f167b1fdcd47c +Subproject commit db15e593864581dbe13191efd571b8657012b020 From dda2f95c1b18634b88a05d10bf80aedb2cd71417 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:44:08 +0200 Subject: [PATCH 11/28] build: register the stub archive task under a name Gradle 9 accepts `db15e59` -> `e0d2b82`, carrying secp256k1-kmp `8adf691`. The stub-archive task the previous two commits brought in was declared with `val createLinuxStubStaticLibs = tasks.registering(...)`, whose delegate provider Gradle 9 no longer takes outside a `by` delegation -- so the task was never registered under a name and the cinterop tasks had nothing to depend on. `tasks.register("createLinuxStubStaticLibs")` instead. With this the chain's five levels are where Curated's are: `e0d2b82` -> `e5b22f5d` -> `9e3c4f8` -> `8adf691` -> `7a35fde1`. Co-Authored-By: Claude Opus 5 --- lightning-kmp-app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-kmp-app b/lightning-kmp-app index db15e593..e0d2b823 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit db15e593864581dbe13191efd571b8657012b020 +Subproject commit e0d2b823dad95d56816da56116575e7564938d90 From 73b2c8ae9af46a7a4d1d3290d4b6b61856aa3064 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:48:43 +0200 Subject: [PATCH 12/28] feat(ios): give Room a way to construct the database without reflection `MantraDatabaseConstructor` has been in commonMain all along -- `expect`, `@Suppress`ed for having no actual, and referenced by nothing. Room's KSP only generates the actual when `@ConstructedBy` names the object, so on a target with no reflection there was no way into the database at all. This is the one annotation in the ios work that changes every other target too: the generator now emits an `actual object` for android and jvm as well, each returning `MantraDatabase_Impl()`, where before it emitted none. jvmTest is unmoved at 1174, and MantraDatabaseJvmTest's comment about the no-Context builder overload being available "because MantraDatabaseConstructor gives Room a way to instantiate without reflection" is now true rather than aspirational. Co-Authored-By: Claude Opus 5 --- .../kotlin/press/mantra/compose/database/MantraDatabase.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index c0c690ba..f7cd28cc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -2,6 +2,7 @@ package press.mantra.compose.database import androidx.room3.ColumnTypeConverters import androidx.room3.AutoMigration +import androidx.room3.ConstructedBy import androidx.room3.Database import androidx.room3.RoomDatabase import androidx.room3.immediateTransaction @@ -366,6 +367,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) AutoMigration(from = 21, to = 22), ] ) +@ConstructedBy(MantraDatabaseConstructor::class) @ColumnTypeConverters(MantraConverters::class) abstract class MantraDatabase: RoomDatabase() { abstract fun broadcastNostrEventReceiptDao(): BroadcastNostrEventReceiptDao From fc19583225fb111233d2f852e4358c8fac9bea29 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:51:13 +0200 Subject: [PATCH 13/28] feat(ios): declare publish cancellation to swift, and unshadow Platform Seven `@Throws` on the publish path now name `kotlin.coroutines.cancellation.CancellationException` alongside `NostrPublishException`. Without it a publish cancelled by the screen navigating away crosses the ObjC boundary as an unexpected throw rather than as the cancellation it is, and swift has no way to tell the two apart. Six of the seven already existed; `EventPublishTransport` had none, so the interface the group-signed path publishes through said less about itself than the implementation behind it. `Platform.ios.kt` also imported `kotlin.native.Platform`, which shadows the app's own `Platform` interface the file is there to implement. It was unused and nothing on this host compiles iosMain, so the compiler never said so. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/network/relays/EventPublishTransport.kt | 1 + .../kotlin/press/mantra/compose/network/relays/RelayPool.kt | 2 +- .../mantra/compose/network/relays/RelaysSocketManager.kt | 4 ++-- .../mantra/compose/repository/NostrPublisherRepository.kt | 6 +++--- .../src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt | 1 - 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt index a15fd5db..f269f013 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt @@ -28,5 +28,6 @@ interface EventPublishTransport { * * @see RelayPool.publishEvent */ + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishEvent(nostrEvent: NostrEvent, relays: Set): Flow } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt index a260a1d7..42c3e99f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelayPool.kt @@ -286,7 +286,7 @@ class RelayPool( ) } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set = emptySet()): Flow { val relayUrls = relays.map { NormalizedRelayUrl(it.url).displayUrl() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index c79e7f7c..81db3293 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -100,7 +100,7 @@ class RelaysSocketManager( relayPool.closePool() } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent): Flow { return if (userRelays.isNotEmpty()) { relayPool.publishEvent( @@ -115,7 +115,7 @@ class RelaysSocketManager( } } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) override suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow { logger.d("publishEvent: $nostrEvent") return relayPool.publishEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt index ef911ca7..93bc0bfd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrPublisherRepository.kt @@ -30,7 +30,7 @@ class NostrPublisherRepository( } } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, kotlin.coroutines.cancellation.CancellationException::class) private suspend fun publishAndImportEvent(signedNostrEvent: press.mantra.compose.database.model.NostrEvent, outboxRelays: List = emptyList()) { relaysSocketManager.publishEvent(signedNostrEvent) importEvent(signedNostrEvent) @@ -52,7 +52,7 @@ class NostrPublisherRepository( } } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun signPublishImportNostrEvent( unsignedNostrEvent: press.mantra.compose.database.model.UnsignedNostrEvent, outboxRelays: List, @@ -63,7 +63,7 @@ class NostrPublisherRepository( } - @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class) + @Throws(press.mantra.compose.exceptions.NostrPublishException::class, press.mantra.compose.exceptions.SignatureException::class, kotlin.coroutines.cancellation.CancellationException::class) suspend fun publishRelayList(userId: String, relays: List): press.mantra.compose.database.model.NostrEvent { val signedNostrEvent = nostrNotary.signRelayListMetadata(userId = userId, relays = relays).unwrapOrThrow() relaysSocketManager.publishEvent(nostrEvent = signedNostrEvent, relays = relays.toSet()) diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt index cfaf67f8..37ba5f43 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/Platform.ios.kt @@ -1,7 +1,6 @@ package press.mantra.compose import platform.UIKit.UIDevice -import kotlin.native.Platform class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion From d024838bae268a7fb2d7c69038d6aa23452ec9e3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:54:27 +0200 Subject: [PATCH 14/28] build(ios): unbreak the ios link tasks Two unrelated failures with one symptom from outside: neither `linkDebugTestIosArm64` nor `linkReleaseFrameworkIosArm64` completes. linkDebugTestIosArm64: Kotlin/Native rejects backticked test names containing a comma ("Name contains illegal characters: ','"), so the commas come out of the commonTest names -- 84 of them across 35 files. Every one was a `, `, so the sweep is a deletion and nothing else; two names where the comma was doing real work are reworded rather than jammed together ("EOSE does not end a live subscription, CLOSED does" became "a live subscription ends on CLOSED and not on EOSE"). jvmTest's own 138 are left alone: Kotlin/Native never compiles that source set, and 138 cosmetic edits do not belong in a commit about a link failure. linkReleaseFrameworkIosArm64: Kotlin 2.4.10's release backend crashes in StaticInitializersOptimization on this module's call graph, so RemoveRedundantCallsToStaticInitializersPhase is disabled for the release native links. The pass is an optimization only. It matches no task on this host, where no ios target is declared at all. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 22 +++++++++++++++++++ .../query/NostrEventFilterQueryTest.kt | 2 +- .../compose/managers/FrostSigningRoundTest.kt | 4 ++-- .../compose/managers/GroupKeyStateTest.kt | 10 ++++----- .../compose/managers/LiveInstanceCacheTest.kt | 2 +- .../managers/LiveSubscriptionPlanTest.kt | 4 ++-- .../managers/MarmotMip03CarveOutTest.kt | 2 +- .../managers/SharedKeyDerivationTest.kt | 4 ++-- .../compose/managers/SignedArtifactTest.kt | 4 ++-- .../compose/managers/SignedChapterTest.kt | 2 +- .../managers/TranslationScaffoldTest.kt | 4 ++-- .../relays/RelayPoolSubscriptionTest.kt | 4 ++-- .../network/sockets/ReconnectBackoffTest.kt | 2 +- .../nostr/DirectMessagePeerSyncTest.kt | 4 ++-- .../compose/nostr/MarmotDeliveryTest.kt | 6 ++--- .../nostr/MarmotDirectMessageDeliveryTest.kt | 2 +- .../compose/nostr/MarmotDirectMessageTest.kt | 4 ++-- .../compose/nostr/MemberProfileSyncTest.kt | 4 ++-- .../mantra/compose/nostr/SignInSyncTest.kt | 6 ++--- .../nostr/chronicle/ChronicleEventTest.kt | 18 +++++++-------- .../nostr/frost/FrostProposalCodecTest.kt | 6 ++--- .../nostr/nip30303/ArtifactEventTest.kt | 4 ++-- .../nostr/nip30303/ChapterProposalSizeTest.kt | 6 ++--- .../nostr/nip30303/ProjectEventTest.kt | 2 +- .../SubgroupBirthCertificateEventTest.kt | 14 ++++++------ .../nostr/subgroup/SubgroupLineageTest.kt | 8 +++---- .../mantra/compose/text/ChapterTextTest.kt | 4 ++-- .../mantra/compose/text/MarkdownBlockTest.kt | 2 +- .../compose/text/MarkdownProducerTest.kt | 2 +- .../mantra/compose/text/ProposedEventTest.kt | 12 +++++----- .../navigation/NavigationSuiteTest.kt | 2 +- .../ui/view/model/NavigationRoutingTest.kt | 4 ++-- .../view/model/UnsyncedProfileDecisionTest.kt | 4 ++-- .../ui/view/state/StartupChoiceTest.kt | 4 ++-- .../ui/view/state/UploadChaptersStateTest.kt | 2 +- .../compose/ui/view/state/UploadReviewTest.kt | 4 ++-- 36 files changed, 106 insertions(+), 84 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 65f10bea..2c6a928e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -158,6 +158,28 @@ kotlin { } } +/** + * Kotlin 2.4.10's release backend crashes linking this module's ios binaries: + * + * e: java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0 + * at org.jetbrains.kotlin.backend.konan.optimizations.StaticInitializersOptimization + * $InterproceduralAnalysis.getCalledInitializer(StaticInitializersOptimization.kt:244) + * + * `RemoveRedundantCallsToStaticInitializersPhase` -- an LTO pass, so only optimized links hit + * it, which is why the debug test binary links fine -- assumes every function in the call + * graph has at least one statement in its body, and something in this module's graph arrives + * with an empty one. The pass is an optimization only; disabling it costs the redundant + * initializer calls it would have removed and nothing else. + * + * Nothing matches this on a non-mac host, where no ios target is declared and so no + * KotlinNativeLink task exists. + */ +tasks.withType().configureEach { + if (name.startsWith("linkRelease")) { + toolOptions.freeCompilerArgs.add("-Xdisable-phases=RemoveRedundantCallsToStaticInitializersPhase") + } +} + android { namespace = "press.mantra.android" compileSdk = libs.versions.android.compileSdk.get().toInt() diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt index f950561a..5b79b735 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/query/NostrEventFilterQueryTest.kt @@ -61,7 +61,7 @@ class NostrEventFilterQueryTest { /** AND between tag names, OR between the values of one name — as `FilterMatcher` does. */ @Test - fun `tags are matched by name and value, not by substring`() { + fun `tags are matched by name and value not by substring`() { val query = NostrEventFilterQuery.build( SynchronizationFilter( kinds = arrayOf(1059), diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 5a34e8aa..aa19c07f 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -161,7 +161,7 @@ class FrostSigningRoundTest { } @Test - fun `one signer set signs a batch of three, and every signature verifies`() { + fun `one signer set signs a batch of three and every signature verifies`() { // The manager's batch, with the database taken out of the way: one signer // set and one tweak cache shared, and a nonce, a Session and a signature // per event. If any of that is wired up wrongly the aggregate simply @@ -571,7 +571,7 @@ class FrostSigningCompletionTest { } @Test - fun `the finished event is the one that was proposed, with a signature on it`() { + fun `the finished event is the one that was proposed with a signature on it`() { // Not rebuilt and not rehashed: the id a session is pinned to is the id // the signature is over, so anything that changed here would produce an // event whose signature verifies against nothing. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index 48892ee3..f43caf63 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -697,7 +697,7 @@ class GroupKeyStateTest { } @Test - fun `an event edited after signing fails on the id, not on the signature`() { + fun `an event edited after signing fails on the id not on the signature`() { val signed = signedByRoom() val tampered = Event( @@ -951,7 +951,7 @@ class GroupKeyStateTest { } @Test - fun `two parents land as adjacent pairs, each id before its own certificate`() { + fun `two parents land as adjacent pairs each id before its own certificate`() { // The one thing in this design that exists for *other people's* builds. A // reader that predates several parents takes the first `parent_group` and // the first `birth_certificate` and checks them against each other, so @@ -989,7 +989,7 @@ class GroupKeyStateTest { } @Test - fun `a state claiming two parents keeps both, each with its own certificate`() { + fun `a state claiming two parents keeps both each with its own certificate`() { // Two groups, two quorums, two signatures over the same child's id. The // second one signed later and could not have named the first as a // co-parent, which is why nothing requires the certificates to agree on @@ -1071,7 +1071,7 @@ class GroupKeyStateTest { } @Test - fun `an uneven number of halves is refused, not completed from the other`() { + fun `an uneven number of halves is refused not completed from the other`() { val certificate = birthCertificate() val extraParent = GroupKeyStateEvent.assembleTags( @@ -1094,7 +1094,7 @@ class GroupKeyStateTest { } @Test - fun `a half that is present and broken shifts nothing, because it is counted`() { + fun `a half that is present and broken shifts nothing because it is counted`() { // Counted by tag name as well as by successful parse, so a broken half is // told from one that was never written. Only the first would slide the // pairing along by one and pair an id with somebody else's certificate. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt index c2931f4d..c58616db 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveInstanceCacheTest.kt @@ -128,7 +128,7 @@ class LiveInstanceCacheTest { } @Test - fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking { + fun `does not run the block or cache anything when there is nothing to build`() = runBlocking { val cache = cache() var ran = false diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt index 8761f337..f0145864 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/LiveSubscriptionPlanTest.kt @@ -57,7 +57,7 @@ class LiveSubscriptionPlanTest { * costs; it does not bound what arrives live. */ @Test - fun `the group filter asks for the given groups, and also carries no since`() { + fun `the group filter asks for the given groups and also carries no since`() { val filter = groupFilter(listOf("group-a", "group-b")) assertEquals(listOf(GroupEvent.KIND), filter.kinds) @@ -145,7 +145,7 @@ class LiveSubscriptionPlanTest { // --- what ends a live subscription ------------------------------------------------- @Test - fun `EOSE does not end a live subscription, CLOSED does`() { + fun `a live subscription ends on CLOSED and not on EOSE`() { assertFalse(NostrIncomingMessage.EoseMessage(subscriptionId = "s").endsLiveSubscription()) assertFalse(NostrIncomingMessage.EventMessage(subscriptionId = "s").endsLiveSubscription()) assertFalse(NostrIncomingMessage.NoticeMessage(message = "hi").endsLiveSubscription()) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt index f6059a92..75f69eb0 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/MarmotMip03CarveOutTest.kt @@ -72,7 +72,7 @@ class MarmotMip03CarveOutTest { } @Test - fun `a sender with no credential identity is rejected, wrap or not`() { + fun `a sender with no credential identity is rejected wrap or not`() { // Required rather than merely compared: with no identity there is nothing to // attribute a line to, and for a gift wrap there is nothing to check the seal // against either. Both kinds must fail, not just the one that does a comparison. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt index 1e78e49c..8c027933 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt @@ -58,7 +58,7 @@ class SharedKeyDerivationTest { } @Test - fun `each step commits to the key it tweaks, so depth is not interchangeable`() { + fun `each step commits to the key it tweaks so depth is not interchangeable`() { // Index 0 applied once is not index 0 applied three times: the scalar binds // the key being tweaked, so a shorter path cannot collide with a deeper one // that happens to end on the same indices. @@ -107,7 +107,7 @@ class SharedKeyDerivationTest { } @Test - fun `a hardened path is refused, not silently walked`() { + fun `a hardened path is refused not silently walked`() { // Hardened derivation needs the parent private key, which nobody in a // threshold group has. A path claiming one was never walked, so acting on it // would mean deriving something different from what the room says. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt index 5cccb1dc..5a1054cc 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -157,7 +157,7 @@ class SignedArtifactTest { } @Test - fun `the artifact the group signs is authored by the group, not the proposer`() { + fun `the artifact the group signs is authored by the group not the proposer`() { val artifact = MantraArtifact.fromArtifactEvent(signedArtifactEvent(), chatRoomId) assertNotNull(artifact) @@ -282,7 +282,7 @@ class SignedArtifactTest { } @Test - fun `the version is a second message, signed under its own nonce`() { + fun `the version is a second message signed under its own nonce`() { // Two items of a batch are two independent FROST instances: k signatures // over k ids, never one signature stretched over both. A version whose // signature verified against the artifact's id would mean a nonce had diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt index 5397cb69..6eb24317 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt @@ -210,7 +210,7 @@ class SignedChapterTest { } @Test - fun `the chapter the group signs is authored by the group, not the proposer`() { + fun `the chapter the group signs is authored by the group not the proposer`() { val chapter = MantraChapter.fromChapterEvent(signedChapterEvent(), chatRoomId) assertNotNull(chapter) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt index 3beec9f7..f9e96494 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/TranslationScaffoldTest.kt @@ -103,7 +103,7 @@ class TranslationScaffoldTest { } @Test - fun `a catch-up keeps each chapter's own position, not the position in the gap`() { + fun `a catch-up keeps each chapter's own position not the position in the gap`() { val chapters = (0..5).map(::sourceChapter) // A catch-up proposes a subset, and the subset is not contiguous. @@ -151,7 +151,7 @@ class TranslationScaffoldTest { } @Test - fun `what is missing is matched on the chapter named, not on how many there are`() { + fun `what is missing is matched on the chapter named not on how many there are`() { val chapters = (0..3).map(::sourceChapter) // Chapter 1 never landed but chapter 2 did, which a count would read as diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt index decc69cd..f2d7ea0b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/relays/RelayPoolSubscriptionTest.kt @@ -48,7 +48,7 @@ class RelayPoolSubscriptionTest { ReqCmd(subId = subId, filters = listOf(Filter(kinds = listOf(kind)))) @Test - fun `a query is retained, and replayed when its socket comes back`() = runTest { + fun `a query is retained and replayed when its socket comes back`() = runTest { val factory = FakeSocketClientFactory() val pool = pool(factory) @@ -66,7 +66,7 @@ class RelayPoolSubscriptionTest { } @Test - fun `a closed query is forgotten, and the socket stops reconnecting for it`() = runTest { + fun `a closed query is forgotten and the socket stops reconnecting for it`() = runTest { val factory = FakeSocketClientFactory() val pool = pool(factory) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt index 0c4cb389..929471bb 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/network/sockets/ReconnectBackoffTest.kt @@ -53,7 +53,7 @@ class ReconnectBackoffTest { } @Test - fun `adds at most the jitter fraction on top, never subtracts`() { + fun `adds at most the jitter fraction on top never subtracts`() { assertEquals(4.seconds, delay(attempt = 3, jitter = 0.0)) assertEquals(5.seconds, delay(attempt = 3, jitter = 1.0)) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt index 1cafb423..244b199f 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt @@ -22,7 +22,7 @@ class DirectMessagePeerSyncTest { // --- the DM relays: everything a chat needs ---------------------------------------- @Test - fun `one request per DM relay, asking for the three kinds a chat needs`() { + fun `one request per DM relay asking for the three kinds a chat needs`() { val requests = DirectMessagePeerSync.requests(alice) assertEquals(Relays.DefaultDMRelayList.size, requests.size) @@ -57,7 +57,7 @@ class DirectMessagePeerSyncTest { // --- the indexers: the kind 0 alone ------------------------------------------------- @Test - fun `one request per bootstrap relay, asking for the kind 0 alone`() { + fun `one request per bootstrap relay asking for the kind 0 alone`() { val requests = DirectMessagePeerSync.profileRequests(alice) assertEquals(SignInSync.bootstrapRelays.size, requests.size) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt index 5df982dc..91a265fd 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDeliveryTest.kt @@ -38,7 +38,7 @@ class MarmotDeliveryTest { ) @Test - fun `a signing proposal goes out, though nothing in the chat points at it`() { + fun `a signing proposal goes out though nothing in the chat points at it`() { // The regression. A FROST message has no ChatMessage by design -- the manager // writes its own transcript lines from what arrives, so a row here would be a // second, worse account of the same thing -- and that must not be the reason the @@ -65,7 +65,7 @@ class MarmotDeliveryTest { } @Test - fun `every relay gets a request, naming the event`() { + fun `every relay gets a request naming the event`() { val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = chatLine) assertContentEquals( @@ -76,7 +76,7 @@ class MarmotDeliveryTest { } @Test - fun `requests are queued pending, which is all the broadcaster looks at`() { + fun `requests are queued pending which is all the broadcaster looks at`() { // `observeBroadcastNostrEventRequestsByStatus("pending")` is the only thing that // picks these up. A request written in any other state is as unsent as no request. val delivery = MarmotDelivery.plan(groupEventId, relays, chatMessage = null) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt index ee4b8473..c24402d7 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageDeliveryTest.kt @@ -75,7 +75,7 @@ class MarmotDirectMessageDeliveryTest { } @Test - fun `our own message is ours, even though we cannot open it`() { + fun `our own message is ours even though we cannot open it`() { // The case that destroys data if it is missed. Alice cannot decrypt the wrap she // sent -- its key was discarded -- so nothing about the payload distinguishes this // from a bystander's view. Only the MLS sender identity does. Were it to come back diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt index 1d3d2474..207246ef 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MarmotDirectMessageTest.kt @@ -67,7 +67,7 @@ class MarmotDirectMessageTest { } @Test - fun `a bystander gets nothing, and no exception`() { + fun `a bystander gets nothing and no exception`() { // Null rather than a throw: the caller is midway through processing a kind:445 // that carries a real message for somebody, and an exception would abandon it. assertNull(MarmotDirectMessage.open(aliceWrapsForBob(), eve)) @@ -103,7 +103,7 @@ class MarmotDirectMessageTest { } @Test - fun `a seal from somebody else is still opened, and is caught by its pubkey`() { + fun `a seal from somebody else is still opened and is caught by its pubkey`() { // open() decrypts; it does not adjudicate. Eve can seal her own rumor to Bob and // wrap it, and Bob's key will open it -- what stops it being rendered as Alice's // is the inbound check that the seal's pubkey is the MLS sender's identity. This diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt index b3ac1227..394af299 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/MemberProfileSyncTest.kt @@ -29,7 +29,7 @@ class MemberProfileSyncTest { * once their single ask comes back empty. */ @Test - fun `a placeholder counts as unknown, the same as no row at all`() { + fun `a placeholder counts as unknown the same as no row at all`() { val rooms = listOf( room( members = listOf( @@ -81,7 +81,7 @@ class MemberProfileSyncTest { } @Test - fun `one request per relay, asking those authors for kind zero`() { + fun `one request per relay asking those authors for kind zero`() { val requests = MemberProfileSync.requests(listOf(alice, bob)) assertEquals(Relays.DefaultDMRelayList.size, requests.size) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt index 9db84af3..f5e41c2f 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt @@ -19,7 +19,7 @@ class SignInSyncTest { private fun relay(url: String) = RelayUrlNormalizer.normalize(url) @Test - fun `the bootstrap asks every indexer and our own relay, once each`() { + fun `the bootstrap asks every indexer and our own relay once each`() { val bootstrap = SignInSync.bootstrapRelays assertTrue(bootstrap.containsAll(Relays.DefaultIndexerRelayList), "the indexers hold everyone's kinds 0, 3 and 10002") @@ -29,7 +29,7 @@ class SignInSyncTest { } @Test - fun `one request per relay, all asking for the same things about the same key`() { + fun `one request per relay all asking for the same things about the same key`() { val relays = listOf(relay("wss://one.example"), relay("wss://two.example")) val requests = SignInSync.requests(unsignedNostrEventId = 7, publicKey = publicKey, relays = relays, level = 1) @@ -47,7 +47,7 @@ class SignInSyncTest { } @Test - fun `a relay list yields its write relays, less those already asked`() { + fun `a relay list yields its write relays less those already asked`() { val tags = arrayOf( arrayOf("r", "wss://write.example", "write"), arrayOf("r", "wss://both.example"), diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt index 0bb7fa12..fec97a88 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt @@ -60,7 +60,7 @@ class ChronicleEventTest { // ---- The codec ------------------------------------------------------- @Test - fun `what is encoded is what comes back, whole`() { + fun `what is encoded is what comes back whole`() { val events = listOf( payload(DialectEvent.KIND, "isiZulu"), payload(ArtifactEvent.KIND, "an artifact"), @@ -168,7 +168,7 @@ class ChronicleEventTest { } @Test - fun `the byte cap counts bytes, not characters`() { + fun `the byte cap counts bytes not characters`() { // A multi-byte payload that fits as characters and does not as bytes. If // this were measured in characters the cap would let through up to four // times what it says. @@ -183,7 +183,7 @@ class ChronicleEventTest { // ---- What a chronicle may carry --------------------------------------- @Test - fun `the allowlist is every kind the group signs, and only those`() { + fun `the allowlist is every kind the group signs and only those`() { listOf( DialectEvent.KIND, ArtifactEvent.KIND, @@ -246,7 +246,7 @@ class ChronicleEventTest { } @Test - fun `the translated text is chroniclable, now that the group signs it`() { + fun `the translated text is chroniclable now that the group signs it`() { // Worth its own case because it was the feature's headline limitation for // as long as `saveTranslation` wrote the row and queued the member's own // rumor. A chronicle can only carry what the receiver can check, so a @@ -269,7 +269,7 @@ class ChronicleEventTest { } @Test - fun `a chronicle may not carry a key state, however well signed`() { + fun `a chronicle may not carry a key state however well signed`() { // The attack the allowlist exists for, and the reason it is an allowlist // rather than a denylist. A GroupKeyStateEvent is signed by the room and // passes verification perfectly, so a chronicle carrying an old one is a @@ -298,7 +298,7 @@ class ChronicleEventTest { } @Test - fun `a chronicle carries documents, not envelopes or protocol`() { + fun `a chronicle carries documents not envelopes or protocol`() { assertFalse(ChronicleEvent.isChroniclable(SubmissionEvent.KIND)) assertFalse(ChronicleEvent.isChroniclable(ChronicleEvent.KIND)) assertFalse(ChronicleEvent.isChroniclable(ChronicleRequestEvent.KIND)) @@ -320,7 +320,7 @@ class ChronicleEventTest { // ---- Dependency order ------------------------------------------------ @Test - fun `the order is the foreign keys, not the kind numbers`() { + fun `the order is the foreign keys not the kind numbers`() { fun rank(kind: Int) = assertNotNull(ChronicleEvent.applyRank(kind), "kind $kind has no rank") // Every one of these is a foreign key in Room, so a chronicle applied the @@ -393,7 +393,7 @@ class ChronicleEventTest { // ---- The tags -------------------------------------------------------- @Test - fun `a page says which chronicle it is, where in it, and who for`() { + fun `a page says which chronicle it is and where in it and who for`() { val template = ChronicleEvent.build( payloads = listOf(payload(DialectEvent.KIND)), chronicleId = chronicleId, @@ -451,7 +451,7 @@ class ChronicleEventTest { // ---- What build refuses to make -------------------------------------- @Test - fun `a page is not built over either cap, or out of what it may not carry`() { + fun `a page is not built over either cap or out of what it may not carry`() { // The outbound half of both checks. It is politeness rather than // security -- the inbound one is the boundary -- but a page nobody can // read back is better failed where it was made. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt index f63474ad..4f5527d3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt @@ -28,7 +28,7 @@ class FrostProposalCodecTest { ) @Test - fun `a batch of one is the bare event, exactly as it always was`() { + fun `a batch of one is the bare event exactly as it always was`() { val one = event("just this") // The load-bearing assertion. A device on a build that predates batching @@ -49,7 +49,7 @@ class FrostProposalCodecTest { } @Test - fun `what is encoded is what comes back, in order`() { + fun `what is encoded is what comes back in order`() { val events = listOf(event("first"), event("second"), event("third")) val decoded = FrostSigningEvents.decodeProposal( @@ -65,7 +65,7 @@ class FrostProposalCodecTest { } @Test - fun `a bare event decodes as a batch of one, forever`() { + fun `a bare event decodes as a batch of one forever`() { // Proposals in the old shape do not stop arriving just because this build // stopped writing them. val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson()) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt index db212a4a..0678548e 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ArtifactEventTest.kt @@ -61,7 +61,7 @@ class ArtifactEventTest { } @Test - fun `the proposal is the artifact's kind, so the signing screen can describe it`() { + fun `the proposal is the artifact's kind so the signing screen can describe it`() { // The session carries a kind and nothing else to go on. Get this wrong // and members are asked to sign "event of kind 30300" -- a question // nobody can answer. @@ -69,7 +69,7 @@ class ArtifactEventTest { } @Test - fun `an artifact declares one version, whatever an initializer adds`() { + fun `an artifact declares one version whatever an initializer adds`() { // addUnique, not add: two labels would leave receivers deriving two // different first versions depending on which one they read first. val template = ArtifactEvent.build( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt index ec1080fe..7ad1db41 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt @@ -144,7 +144,7 @@ class ChapterProposalSizeTest { } @Test - fun `where the fit runs out, for a chapter-shaped chapter`() { + fun `where the fit runs out for a chapter-shaped chapter`() { // Pinned so the number is on the record rather than in a comment, and so // that a change to either event's tags shows up as a moved boundary // instead of as a quorum that never arrives. Forty paragraphs is the @@ -169,7 +169,7 @@ class ChapterProposalSizeTest { } @Test - fun `the text is measured in bytes, not in characters`() { + fun `the text is measured in bytes not in characters`() { // The languages this app exists for are full of characters that are one // UTF-16 unit and three bytes. Measuring by length would under-count a // Setswana or Amharic chapter and the cap would stop binding exactly @@ -191,7 +191,7 @@ class ChapterProposalSizeTest { private val AddChapterMaxChunks: Int get() = FrostSigningManager.MAX_BATCH_SIZE - 1 @Test - fun `the chunk cap is one less than the batch, because the chapter takes a place`() { + fun `the chunk cap is one less than the batch because the chapter takes a place`() { assertEquals(FrostSigningManager.MAX_BATCH_SIZE - 1, AddChapterMaxChunks) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt index e0c2fea9..a3c2de28 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt @@ -77,7 +77,7 @@ class ProjectEventTest { // --- owners are a set, and all of them survive --- @Test - fun `three owners come back, sorted`() { + fun `three owners come back sorted`() { val event = project(setOf(owner3, owner1, owner2)) // `addUnique` here would leave one. Sorted because the id is a hash over diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index ada04fb2..5a569840 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -243,7 +243,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `a certificate carrying no parent, no key or no path is refused`() { + fun `a certificate carrying no parent no key or no path is refused`() { listOf("parent_group", "subgroup_key", "frost_path").forEach { dropped -> val incomplete = certificate( tags = SubgroupBirthCertificateEvent.assembleTags( @@ -359,7 +359,7 @@ class SubgroupBirthCertificateEventTest { // signature against a room id, and a room id is a public key. @Test - fun `in the parent's room, a certificate is about the room's own child`() { + fun `in the parent's room a certificate is about the room's own child`() { assertEquals( SubgroupManager.ArrivingCertificate.OurChild(subgroupChatRoomId), SubgroupManager.classify(parentChatRoomId, certificate()) @@ -367,7 +367,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `in the child's room, the same certificate is an offer to adopt it`() { + fun `in the child's room the same certificate is an offer to adopt it`() { // The half of an adoption the child's members would otherwise never hear // about until a proposal asked for their signature. One event, two // meanings, and the room it arrived in is the whole of the difference. @@ -398,7 +398,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `a group is not its own child, however truthfully it says so`() { + fun `a group is not its own child however truthfully it says so`() { // The one self-referential certificate `certifies` waves through: author, // subject and named parent all one room, the id deriving from the key, // the signature verifying. Refused before either branch -- read as an @@ -446,7 +446,7 @@ class SubgroupBirthCertificateEventTest { // asserting about it is where it stops. @Test - fun `an unsigned proposal addresses its child, and certifies nothing`() { + fun `an unsigned proposal addresses its child and certifies nothing`() { // What the ceremony screen is actually holding: the event as it was // assembled, before anybody signed it. Any string in `sig` serves -- // the point is that no quorum has put one there. @@ -472,7 +472,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `a forgery by another group addresses the pair it names, and certifies nothing`() { + fun `a forgery by another group addresses the pair it names and certifies nothing`() { // The reason `addresses` must never decide a lineage: anybody can author // an event naming any pair, and this says yes to it. The guard is that // nothing downstream of a lineage asks `addresses` -- only the screen @@ -560,7 +560,7 @@ class SubgroupBirthCertificateEventTest { } @Test - fun `everything certifies addresses, so the weaker one cannot be the stricter`() { + fun `everything certifies addresses so the weaker one cannot be the stricter`() { // `certifies` calls `addresses` and adds to it, and this is what keeps // that true: a change that let one pass where the other refuses would // mean two different answers to "which child is this about". diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt index 7c8c31df..dd7cac9b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt @@ -29,7 +29,7 @@ class SubgroupLineageTest { } @Test - fun `one parent cannot fail, because it has nothing to repeat`() { + fun `one parent cannot fail because it has nothing to repeat`() { val lineage = SubgroupLineage.of(parentage(alpha)) assertEquals(1, lineage.size) @@ -53,14 +53,14 @@ class SubgroupLineageTest { } @Test - fun `a room id is matched case-insensitively, because it is hex`() { + fun `a room id is matched case-insensitively because it is hex`() { val lineage = SubgroupLineage.of(parentage(alpha)) assertTrue(lineage.names(alpha.uppercase())) } @Test - fun `the order is the parents' ids, whatever order they arrived in`() { + fun `the order is the parents' ids whatever order they arrived in`() { // The whole reason this class sorts: two devices assembling one lineage // from the same certificates have to emit the same bytes, or a reader // that pairs the first tag of each name gets a mismatched pair. @@ -82,7 +82,7 @@ class SubgroupLineageTest { } @Test - fun `adding a parent is an addition, not a replacement`() { + fun `adding a parent is an addition not a replacement`() { // A key state is replaceable and the newest wins wholesale, so an // adoption that carried only the new parent would silently drop the old // one. Expressing it as an addition is what stops a caller assembling diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt index aa99eaed..43709f78 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt @@ -99,7 +99,7 @@ class ChapterTextTest { } @Test - fun `utf16 is refused, because it is not supported rather than because it is binary`() { + fun `utf16 is refused because it is not supported rather than because it is binary`() { // FF FE is a UTF-16LE byte order mark and an illegal UTF-8 lead byte, so // this lands on NotText either way. Asserted so that a future lenient // decode cannot quietly turn it into a chapter of interleaved NULs. @@ -118,7 +118,7 @@ class ChapterTextTest { } @Test - fun `an empty file is text, not a failure`() { + fun `an empty file is text not a failure`() { // Whether an empty chapter may be proposed is the form's decision; decode // only says what the bytes were. assertEquals("", decoded(ByteArray(0))) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt index d272bad3..90e795b3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt @@ -30,7 +30,7 @@ class MarkdownBlockTest { } @Test - fun `the corpus is not empty, so a generator that wrote nothing fails`() { + fun `the corpus is not empty so a generator that wrote nothing fails`() { assertEquals(true, MARKDOWN_BLOCK_FIXTURES.size >= 25) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt index 455ed8a1..9be32aeb 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt @@ -76,7 +76,7 @@ class MarkdownProducerTest { } @Test - fun `a blockquote is quoted again, line by line`() { + fun `a blockquote is quoted again line by line`() { val source = "> line one\n> line two\n" // Two lines in, three out: the marker goes in front of every line, so a diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 1915ed16..b9309634 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -42,7 +42,7 @@ class ProposedEventTest { * contains and so the rest of what is being agreed to. */ @Test - fun `a chapter is named, measured, and counted`() { + fun `a chapter is named measured and counted`() { val summary = ProposedEvent.summarize(chapter(name = "The wreck"), chunkCount = 4) assertEquals("New chapter", summary.label) @@ -67,7 +67,7 @@ class ProposedEventTest { * handed -- the text here splits into two, and the batch says five. */ @Test - fun `the chunk count is the batch's, not a re-reading of the text`() { + fun `the chunk count is the batch's not a re-reading of the text`() { val summary = ProposedEvent.summarize( chapter(originalText = "One.\n\nTwo."), chunkCount = 5, @@ -77,7 +77,7 @@ class ProposedEventTest { } @Test - fun `with no batch to count, a chapter says nothing about chunks`() { + fun `with no batch to count a chapter says nothing about chunks`() { // A transcript line, or a signed event on its own: there is no batch to // ask, and guessing is what this parameter exists to stop. val summary = ProposedEvent.summarize(chapter(name = "The wreck")) @@ -86,7 +86,7 @@ class ProposedEventTest { } @Test - fun `a chunk is said as its own words, in its own place`() { + fun `a chunk is said as its own words in its own place`() { // The chunks travel in the same batch as their chapter and are read // under it, so each has to be checkable on its own. val summary = ProposedEvent.summarize( @@ -179,7 +179,7 @@ class ProposedEventTest { } @Test - fun `a project owned by somebody else says who, and not the room`() { + fun `a project owned by somebody else says who and not the room`() { val summary = ProposedEvent.summarize( project(setOf(roomId, outsider)), chatRoomId = roomId @@ -191,7 +191,7 @@ class ProposedEventTest { } @Test - fun `with no room to compare against, every owner is named`() { + fun `with no room to compare against every owner is named`() { val summary = ProposedEvent.summarize(project(setOf(roomId, outsider))) assertTrue(summary.detail.contains(roomId.take(12)), summary.detail) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt index f899c5cc..4d619abc 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt @@ -45,7 +45,7 @@ class NavigationSuiteTest { } @Test - fun `nothing is drawn off a top-level destination, at any width`() { + fun `nothing is drawn off a top-level destination at any width`() { // The component belongs on the destinations it switches between. On a chat room, a // signing screen or an onboarding step -- reached by pushing a route and left by // coming back -- a persistent bar is an invitation to lose your place. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt index a8aef044..8b06cb9b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -176,7 +176,7 @@ class NavigationRoutingTest { } @Test - fun `asked from anywhere but startup, it sends the wallet up first`() = runTest { + fun `asked from anywhere but startup it sends the wallet up first`() = runTest { val viewModel = bootedOn(account(signed = true)) viewModel.loadNostrProfile(startupRoute = null) @@ -195,7 +195,7 @@ class NavigationRoutingTest { * to one state, and Landing pops the back stack. */ @Test - fun `a key just imported is sent to fetch its profile, not to make one`() = runTest { + fun `a key just imported is sent to fetch its profile not to make one`() = runTest { val viewModel = bootedOn(placeholderAccount()) viewModel.loadNostrProfile(SovereignWalletStartupRoute) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt index c543e8f4..ba6ebb11 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt @@ -99,7 +99,7 @@ class UnsyncedProfileDecisionTest { } @Test - fun `a kind 0 that has arrived, indexed or not, is never not-found`() { + fun `a kind 0 that has arrived indexed or not is never not-found`() { val event = NostrEvent( id = "e".repeat(64), pubKey = publicKey, @@ -117,7 +117,7 @@ class UnsyncedProfileDecisionTest { } @Test - fun `no account, or no request yet, is still searching`() { + fun `no account or no request yet is still searching`() { assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(null)) assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account())) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt index 1715d159..886e3ba8 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt @@ -32,7 +32,7 @@ class StartupChoiceTest { } @Test - fun `what a switch or a sign-in named is opened, even after the user once asked for the list`() { + fun `what a switch or a sign-in named is opened even after the user once asked for the list`() { assertEquals(b, resolve(desired = b.id, startImmediately = false)) } @@ -53,7 +53,7 @@ class StartupChoiceTest { } @Test - fun `a default the list no longer has, or no default, shows the list`() { + fun `a default the list no longer has or no default shows the list`() { assertNull(resolve(default = WalletId("cc".repeat(20)))) assertNull(resolve()) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt index bafb8d18..b13f9c55 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt @@ -141,7 +141,7 @@ class UploadChaptersStateTest { } @Test - fun `a failed proposal is still proposable, because a retry is a new proposal`() { + fun `a failed proposal is still proposable because a retry is a new proposal`() { val chapters = listOf(row("tried", problem = UploadProblem.ProposalFailed)) assertEquals(listOf("tried"), state(chapters).proposable.map { it.id }) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt index d910d905..5e6989a3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt @@ -49,7 +49,7 @@ class UploadReviewTest { } @Test - fun `counts are of the decoded text, not of the file`() { + fun `counts are of the decoded text not of the file`() { // CRLF in, LF counted: the row shows what would be signed. val rows = reviewed(file("a.md", "One two.\r\n\r\nThree.\r\n")) @@ -58,7 +58,7 @@ class UploadReviewTest { } @Test - fun `a file that is not text is refused, and blocks only its own row`() { + fun `a file that is not text is refused and blocks only its own row`() { val rows = reviewed( file("good.md", "Fine.\n"), PickedFile("cover.png", byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x00, 0x0D)), From e6c3c8a2441cbd3c0f4859e1f8aa945c982efc32 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 23:57:59 +0200 Subject: [PATCH 15/28] fix(ios): build ktor clients on Darwin so wss/https work on a device CIO has no TLS implementation on kotlin/native, so every secure connection died at the handshake with "TLS sessions are not supported on Native platform". Pick the engine per platform -- CIO on android and the desktop jvm, Darwin on ios -- and pin both the relay socket client and coil's image fetcher to it. Coil is the half that is easy to miss. Nothing in the app configured an ImageLoader, so its default ktor fetcher built a bare `HttpClient()` and took whichever engine the classpath served first; every avatar and preview over https would have failed the same way the sockets did, with nothing in the app's own code to point at. `MantraApp` is the one place both call sites are reachable from, and the factory is installed above `MantraTheme` so it is in place before the first AsyncImage composes. The darwin dependency goes inside the existing isMacOsX gate, so nothing about resolution on a linux or windows host changes. Co-Authored-By: Claude Opus 5 --- composeApp/build.gradle.kts | 6 ++++++ .../PlatformHttpClientEngine.android.kt | 6 ++++++ .../kotlin/press/mantra/compose/MantraApp.kt | 21 +++++++++++++++++++ .../network/PlatformHttpClientEngine.kt | 11 ++++++++++ .../sockets/NostrSocketClientFactory.kt | 3 ++- .../network/PlatformHttpClientEngine.ios.kt | 6 ++++++ .../network/PlatformHttpClientEngine.jvm.kt | 6 ++++++ gradle/libs.versions.toml | 1 + 8 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt create mode 100644 composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2c6a928e..9f70db05 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -153,6 +153,12 @@ kotlin { if (org.gradle.internal.os.OperatingSystem.current().isMacOsX) { iosMain.dependencies { implementation(libs.sqldelight.native.driver) + + // CIO has no TLS implementation on native ("TLS sessions are not + // supported on Native platform"), so every wss/https call it makes on + // a device fails at the handshake. Darwin (NSURLSession) is the engine + // apple targets must use. + implementation(libs.ktor.client.darwin) } } } diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt new file mode 100644 index 00000000..a1cfbe1b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.android.kt @@ -0,0 +1,6 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO + +internal actual fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> = CIO diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt index 321545fa..324ef5da 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt @@ -5,17 +5,38 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.network.ktor3.KtorNetworkFetcherFactory +import io.ktor.client.HttpClient +import press.mantra.compose.network.platformHttpClientEngineFactory import press.mantra.compose.ui.composable.navigation.MantraNavHost import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import fr.acinq.phoenix.PhoenixGlobal +/** + * Coil's default ktor fetcher builds a bare `HttpClient()`, whose engine is whichever the + * classpath happens to serve first. With CIO that means no TLS on ios, so images over + * https fail exactly like the relay sockets did. Pin the singleton loader to the same + * platform engine the sockets use. + */ +private val imageHttpClient by lazy { HttpClient(platformHttpClientEngineFactory()) } + @Composable fun MantraApp( navController: NavHostController, mantraGlobal: MantraGlobal, phoenixGlobal: PhoenixGlobal ) { + setSingletonImageLoaderFactory { context -> + ImageLoader.Builder(context) + .components { + add(KtorNetworkFetcherFactory(httpClient = { imageHttpClient })) + } + .build() + } + MantraTheme { Surface( modifier = Modifier.fillMaxSize() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt new file mode 100644 index 00000000..11d09c53 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory + +/** + * The engine every Ktor client in the app is built on. CIO serves android and the desktop + * jvm; on apple targets its TLS layer does not exist (`TLS sessions are not supported on + * Native platform`), so every wss/https call fails at the handshake and ios uses Darwin + * (NSURLSession) instead. + */ +internal expect fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt index 99262b0d..860702c6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/sockets/NostrSocketClientFactory.kt @@ -4,10 +4,11 @@ import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.WebSockets import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter import kotlinx.serialization.json.Json +import press.mantra.compose.network.platformHttpClientEngineFactory internal val defaultSocketsHttpClient by lazy { - HttpClient { + HttpClient(platformHttpClientEngineFactory()) { install(WebSockets) { contentConverter = KotlinxWebsocketSerializationConverter(Json { isLenient = true diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt new file mode 100644 index 00000000..6bbe3a40 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.ios.kt @@ -0,0 +1,6 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.darwin.Darwin + +internal actual fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> = Darwin diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt new file mode 100644 index 00000000..a1cfbe1b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/network/PlatformHttpClientEngine.jvm.kt @@ -0,0 +1,6 @@ +package press.mantra.compose.network + +import io.ktor.client.engine.HttpClientEngineFactory +import io.ktor.client.engine.cio.CIO + +internal actual fun platformHttpClientEngineFactory(): HttpClientEngineFactory<*> = CIO diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 34c59f78..5e6f2132 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,6 +80,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } lightning-kmp-core = { module = "fr.acinq.lightning:lightning-kmp-core", version.ref = "lightningKmpCore" } From 55c8f92cd33541e9fd131d14b44e3841ac086066 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 00:00:15 +0200 Subject: [PATCH 16/28] docs: record what the first four ios phases actually did Phases 0 to 4 are on this branch and verified on linux; 5 needs two decisions and an asset, 6 and 7 need a mac. Each phase's text is left as it was planned, with a Landed note where what happened is worth knowing. Three findings the plan did not predict. The three library bumps touch no Kotlin source anywhere in the chain -- eleven build scripts in secp256k1-kmp and nothing else -- so gradle reports jvmTest UP-TO-DATE afterwards, which reads like a stale cache and is not one. That bump also carries four fixes beyond the stub archives, the only consequential one being compileSdk 35 -> 36 in secp256k1-kmp's jni/android, below Mantra's own 37. And two of the 84 test names needed rewording by hand, where the comma had been carrying real work rather than pausing. Also records that `-Xdisable-phases` is in but unproven -- no KotlinNativeLink task exists here for it to match -- and the team id Phase 5 is expected to use, which still wants confirming. Co-Authored-By: Claude Opus 5 --- docs/ios-build.md | 86 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/ios-build.md b/docs/ios-build.md index 55e56c38..d58e0472 100644 --- a/docs/ios-build.md +++ b/docs/ios-build.md @@ -19,6 +19,29 @@ What stands in the way is five concrete defects, which Curated hit in the order build hits them — configure, compile, link, connect, sign — and fixed in eight commits. +## Where this stands + +Phases 0 to 4 are **done and on this branch**, verified on a linux host; Phase 5 +needs two decisions and an asset, and Phases 6 and 7 need a mac. Nothing below was +rewritten after the fact — each phase's text is as it was planned, with a +**Landed** note where what happened is worth knowing. + +| phase | state | commit | +|---|---|---| +| 0 bootstrap and baseline | done — 1174 + 159 tests green | (no commit) | +| 1 advance the library chain | done — linux unchanged, as predicted | `a8f0e26f`, `1b8df6b4`, `dda2f95c` | +| 2 Room constructor and cancellation | done | `73b2c8ae`, `fc195832` | +| 3 unbreak the ios link tasks | done — linux half only | `d024838b` | +| 4 ktor on Darwin | done — android and jvm halves only | `e6c3c8a2` | +| 5 signing, icon, version | **not started** — needs decisions | — | +| 6 what only a mac can find | **not started** — needs a mac | — | +| 7 write down what it cost | in progress — this section | — | + +`:composeApp:check` passes at the tip: lint, the m3 audit's ten counts, and all +three test suites — `jvmTest` 1174, `testDebugUnitTest` 563, the library's own 159. +Every count is the Phase 0 baseline, unmoved. + + ## The eight commits, and what each one is here | Curated | what it did | how it lands here | @@ -179,6 +202,24 @@ That is the phase's actual purpose. > `build: carry secp256k1-kmp's fix for configuring off a linux host` > — and say in the body that the upstream subject reads the other way round. +**Landed** as `a8f0e26f`, `1b8df6b4`, `dda2f95c`. All five levels now sit exactly +where Curated's do: `e0d2b82` → `e5b22f5d` → `9e3c4f8` → `8adf691` → `7a35fde1`. + +Two things worth knowing. First, the walk across all three bumps touches **no +Kotlin source anywhere in the chain** — the entire payload is eleven +`build.gradle.kts` and `settings.gradle.kts` files in secp256k1-kmp — so gradle +correctly reports `jvmTest UP-TO-DATE` afterwards. That reads like a stale cache +and is not one; a forced `--rerun` returns the same 1174 and 159. + +Second, the bump carries four fixes beyond the stub archives, because +`a8cc3ec..8adf691` is seven commits rather than three: the `generateHeaders` +output paths (which were `generated./jni`, with a stray dot, and whose temp +directory was being cleared with a `delete()` that silently does nothing on a +non-empty directory), Gradle 10's removal of the delegated-property DSL, `Project` +objects passed as dependency notation, and `compileSdk 35` → `36` in +secp256k1-kmp's `jni/android`. The last of those is the only one that can touch an +android build here, and it is below Mantra's own 37. + --- ## Phase 2 — declare the Room constructor and cancellation to Swift @@ -225,6 +266,19 @@ later. > `feat(ios): declare the Room constructor and publish cancellation to swift` +**Landed** as two commits rather than one — `73b2c8ae` for `@ConstructedBy` alone, +`fc195832` for the seven annotations and the import — because the first was the +only change in the plan with real linux-side risk and deserved to be readable on +its own. + +The risk was real and benign. Room's KSP now emits an `actual object +MantraDatabaseConstructor` for **android and jvm as well**, each returning +`MantraDatabase_Impl()`, where before it emitted none at all and the `expect` sat +`@Suppress`ed and unreferenced. `jvmTest` is unmoved at 1174. A side effect worth +noting: `MantraDatabaseJvmTest`'s comment about the no-`Context` builder overload +being available "because `MantraDatabaseConstructor` gives Room a way to +instantiate without reflection" was aspirational when written and is now true. + --- ## Phase 3 — unbreak the iOS link tasks @@ -303,6 +357,23 @@ renamed tests must still run, and the count must not move. > `build(ios): unbreak the ios link tasks` +**Landed** as `d024838b`, the linux-verifiable half. The sweep ran exactly as +predicted: 84 names in 35 files, 84 changed lines, every one of them a `fun` line +and no other line in the tree touched. Both renamed tests show up in the results +under their new names, under `jvmTest` and `testDebugUnitTest` alike, and the 138 +commas left in the results are `jvmTest`'s own — the set the plan leaves alone. + +Two of the 84 needed rewording by hand, where the comma had been carrying real +work rather than pausing: `EOSE does not end a live subscription, CLOSED does` +became `a live subscription ends on CLOSED and not on EOSE`, and `a page says +which chronicle it is, where in it, and who for` became `... and where in it and +who for`. The other 82 lost only a pause. + +The `-Xdisable-phases` block is in but **unproven** — no `KotlinNativeLink` task +exists on this host for it to match, so all that is verified is that the build +script still compiles. Whether this module's call graph crashes the same phase +Curated's did is Phase 6's question. + --- ## Phase 4 — put every ktor client on Darwin @@ -361,6 +432,13 @@ simulator that reaches a relay over `wss://` and renders an avatar over `https:/ > `fix(ios): build ktor clients on Darwin so wss/https work on a device` +**Landed** as `e6c3c8a2`. The android and jvm `actual`s and both call sites +compile; the Darwin `actual` and the thing the phase is actually for do not, here. +`ktor-client-darwin` 3.5.2 has never been resolved on this host — the gradle cache +holds 3.3.3 and 3.4.1 from older work and nothing newer — because the dependency +sits inside the `isMacOsX` gate. It resolves on the mac at the same `ktor` version +ref Curated built against. + --- ## Phase 5 — signing, icon, version @@ -371,9 +449,11 @@ decided. Each needs an answer from you before it can be written. **`TEAM_ID`.** [iosApp/Configuration/Config.xcconfig](../iosApp/Configuration/Config.xcconfig) has `TEAM_ID=` empty, and the bundle identifier is built from it (`press.mantra.ios$(TEAM_ID)`). Curated committed its development team id in the -clear, which is normal for an xcconfig — a team id is not a secret — but it is -*their* team id and signs builds to *their* account. Mantra needs its own, or an -`xcconfig` that stays out of git. +clear, which is normal for an xcconfig — a team id is not a secret — but it signs +builds to a particular account. The expectation is that Mantra uses **the same one +Curare does, `ZK3TQ662GB`**; that still wants confirming before it is written, +since it decides whose account the build belongs to and the bundle identifier is +derived from it. **The app icon.** Curated replaced its single `app-icon-1024.png` with a 1024 light variant and a 1024 dark one, adding the `luminosity: dark` appearance to From 46ccec3439e2f9e67f342edcc6a67cf6b4107882 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 00:25:16 +0200 Subject: [PATCH 17/28] 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])) From 276608679f6ff4b68df43d9aa877eb664064f546 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:07:56 +0200 Subject: [PATCH 18/28] fix(nip30303): a translation reference parses as itself, not as a dialect TranslationArtifactVersionIdTag.parse was declared to return DialectIdTag? and constructed one. The tag-name guard was right -- it compared against this class's own TAG_NAME -- so the event id came off the right slot of the right tag, and nothing behaved differently: all nine call sites reach straight through to .ref, and the two classes have identical shape. A caller that branched on the type, or collected parse results across the family, would have got a dialect reference where a translation reference belongs. The twenty-four tags in that package were written from one template, so the whole family was checked: this was the only mismatch. Every other parse returns and constructs its enclosing class. The wire names are a separate matter and stay as they are. Six tags carry a stray Tag suffix -- dialectIdTag, chapterIdTag, chunkIdTag, translationIdTag, translationChapterIdTag, translationChunkIdTag -- which ProjectIdTag already documents as a copy-paste slip that cannot be fixed, because those strings sit inside event ids the group has signed. Renaming one invalidates every signature over it. TagRoundTripTest covers all twenty-four, grouped by the four shapes they take, plus a matrix asserting no parse accepts any of the other twenty-three arrays -- the same template's other failure mode, a copied TAG_NAME. The mechanism is the type argument, and it has to be written out. The first draft of the test left T to inference and passed against the bug when it was reintroduced to check: T is read off the function reference, so a parse returning DialectIdTag simply makes T be DialectIdTag, every member the two classes share resolves, and assertIs is a tautology. With the class named explicitly at each call site, a parse whose declared return type is a sibling no longer satisfies (Array) -> T? and the file stops compiling on that tag's own line -- earlier and more precisely than any runtime check. Verified by re-breaking the file: the build fails naming the offending parse. The KDoc says so, because tidying the type argument away would silently defeat the test. One more test, on arrayOfNotNull rather than on the family: it strips only trailing nulls and writes "" for an interior one, so a reference assembled with an author but no relay hint is [name, id, "", author] and the author stays in slot 3. That is the shape most of these tags are assembled in, and if it ever changed every author would be read as a relay hint and dropped. 605 unit tests pass on --rerun-tasks, and m3Audit is clean. Co-Authored-By: Claude Opus 5 --- .../tags/TranslationArtifactVersionIdTag.kt | 4 +- .../nostr/nip30303/tags/TagRoundTripTest.kt | 381 ++++++++++++++++++ 2 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/tags/TagRoundTripTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/TranslationArtifactVersionIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/TranslationArtifactVersionIdTag.kt index a451426f..d000bf49 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/TranslationArtifactVersionIdTag.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/TranslationArtifactVersionIdTag.kt @@ -18,14 +18,14 @@ class TranslationArtifactVersionIdTag( companion object { const val TAG_NAME = "translationArtifactVersionId" - fun parse(tag: Array): DialectIdTag? { + fun parse(tag: Array): TranslationArtifactVersionIdTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } - return DialectIdTag(tag[1], relayHint, tag.getOrNull(3)) + return TranslationArtifactVersionIdTag(tag[1], relayHint, tag.getOrNull(3)) } fun assemble( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/tags/TagRoundTripTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/tags/TagRoundTripTest.kt new file mode 100644 index 00000000..35e09f63 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/tags/TagRoundTripTest.kt @@ -0,0 +1,381 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * The twenty-four tags in this package were written from one template, and the + * template is the hazard: every one of them declares `parse` beside a `TAG_NAME` + * two lines above it, so a file copied for a new tag can keep the old file's + * class in both the return type and the `return` while the name guard reads + * correctly. `TranslationArtifactVersionIdTag` did exactly that -- it parsed the + * right slot off the right tag name and handed back a `DialectIdTag`. Nothing + * failed, because every caller in the app reaches straight through to `.ref` and + * an event id is an event id. A caller that branched on the type, or collected + * parse results across the family, would have got a dialect reference where a + * translation reference belongs. + * + * So the type is the assertion here, not the value. Each tag is handed to + * [roundTrip] through its own `::parse`, with its own class **written out** as + * the type argument: a `parse` whose *declared* return type names a sibling then + * no longer satisfies `(Array) -> T?`, and the file stops compiling on + * the line of the offending tag -- earlier and more precisely than any runtime + * check. [assertIs] covers the other half, a declaration that is right over a + * `return` that is not. + * + * The explicit `` is the whole mechanism and must not be tidied away. Left + * to inference `T` is read *off the function reference*, so a `parse` returning + * `DialectIdTag` simply makes `T` be `DialectIdTag`, every member these classes + * share resolves, and the test passes while the bug sits there. That is how the + * first draft of this file missed it. + * + * A new tag in this package belongs in the list for its shape below. There are + * four shapes: an [EventReference], a single string, counts, and the two that + * carry something of their own. + */ +class TagRoundTripTest { + private val eventId = "a".repeat(64) + private val author = "b".repeat(64) + private val relay = RelayUrlNormalizer.normalize("wss://relay.mantra.press") + + /** + * Asserts that [parse] accepts what `assemble` produced and returns a [T]. + * + * [name] is passed separately from the array so the tag's own `TAG_NAME` is + * checked against slot 0 rather than read out of it. + */ + private inline fun roundTrip( + name: String, + assembled: Array, + parse: (Array) -> T?, + ): T { + assertEquals(name, assembled[0], "assemble wrote the wrong name into slot 0") + val parsed = assertNotNull( + parse(assembled), + "$name: parse rejected its own assemble output", + ) + return assertIs(parsed, "$name: parse returned the wrong class") + } + + private inline fun refRoundTrip( + name: String, + assembled: Array, + parse: (Array) -> T?, + ref: (T) -> EventReference, + ) { + val parsed = ref(roundTrip(name, assembled, parse)) + + assertEquals(eventId, parsed.eventId, "$name: event id did not survive the round trip") + assertEquals(author, parsed.author, "$name: author did not survive the round trip") + assertEquals(relay, parsed.relayHint, "$name: relay hint did not survive the round trip") + } + + // --- the eleven that carry an event reference --- + + @Test + fun `every event-reference tag parses back as itself`() { + refRoundTrip( + ArtifactIdTag.TAG_NAME, + ArtifactIdTag.assemble(eventId, relay, author), + ArtifactIdTag::parse, + ) { it.ref } + + refRoundTrip( + ArtifactVersionIdTag.TAG_NAME, + ArtifactVersionIdTag.assemble(eventId, relay, author), + ArtifactVersionIdTag::parse, + ) { it.ref } + + refRoundTrip( + ChapterIdTag.TAG_NAME, + ChapterIdTag.assemble(eventId, relay, author), + ChapterIdTag::parse, + ) { it.ref } + + refRoundTrip( + ChunkIdTag.TAG_NAME, + ChunkIdTag.assemble(eventId, relay, author), + ChunkIdTag::parse, + ) { it.ref } + + refRoundTrip( + DialectIdTag.TAG_NAME, + DialectIdTag.assemble(eventId, relay, author), + DialectIdTag::parse, + ) { it.ref } + + refRoundTrip( + PayloadIdTag.TAG_NAME, + PayloadIdTag.assemble(eventId, relay, author), + PayloadIdTag::parse, + ) { it.ref } + + refRoundTrip( + ProjectIdTag.TAG_NAME, + ProjectIdTag.assemble(eventId, relay, author), + ProjectIdTag::parse, + ) { it.ref } + + // The one the template got wrong. Before the fix this call would not + // compile: `::parse` was declared `(Array) -> DialectIdTag?`. + refRoundTrip( + TranslationArtifactVersionIdTag.TAG_NAME, + TranslationArtifactVersionIdTag.assemble(eventId, relay, author), + TranslationArtifactVersionIdTag::parse, + ) { it.ref } + + refRoundTrip( + TranslationChapterIdTag.TAG_NAME, + TranslationChapterIdTag.assemble(eventId, relay, author), + TranslationChapterIdTag::parse, + ) { it.ref } + + refRoundTrip( + TranslationChunkIdTag.TAG_NAME, + TranslationChunkIdTag.assemble(eventId, relay, author), + TranslationChunkIdTag::parse, + ) { it.ref } + + refRoundTrip( + TranslationIdTag.TAG_NAME, + TranslationIdTag.assemble(eventId, relay, author), + TranslationIdTag::parse, + ) { it.ref } + } + + /** + * An author with no relay hint still lands in slot 3. + * + * `arrayOfNotNull` drops only *trailing* nulls and writes `""` for an + * interior one, so a reference assembled without a hint is + * `[name, id, "", author]` rather than `[name, id, author]`. If that ever + * changed the author would be read as a relay hint and silently dropped, + * which is the shape most of these tags are assembled in. + */ + @Test + fun `an event-reference tag keeps its author when the relay hint is absent`() { + val assembled = ArtifactIdTag.assemble(eventId, relay = null, pubkey = author) + val parsed = assertNotNull(ArtifactIdTag.parse(assembled)).ref + + assertEquals(eventId, parsed.eventId) + assertEquals(author, parsed.author) + assertNull(parsed.relayHint) + } + + // --- the eight that carry one string, and the version label that behaves like one --- + + @Test + fun `every single-value tag parses back as itself`() { + assertEquals( + "Botswana", + roundTrip( + CountryTag.TAG_NAME, + CountryTag.assemble("Botswana"), + CountryTag::parse, + ).country, + ) + + assertEquals( + author, + roundTrip( + CreatorTag.TAG_NAME, + CreatorTag.assemble(author), + CreatorTag::parse, + ).pubKey, + ) + + assertEquals( + "tn", + roundTrip( + LanguageTag.TAG_NAME, + LanguageTag.assemble("tn"), + LanguageTag::parse, + ).language, + ) + + assertEquals( + "CC-BY-SA-4.0", + roundTrip( + LicenseTag.TAG_NAME, + LicenseTag.assemble("CC-BY-SA-4.0"), + LicenseTag::parse, + ).license, + ) + + assertEquals( + "Gospel of John", + roundTrip( + NameTag.TAG_NAME, + NameTag.assemble("Gospel of John"), + NameTag::parse, + ).name, + ) + + assertEquals( + "In the beginning was the Word", + roundTrip( + OriginalTextTag.TAG_NAME, + OriginalTextTag.assemble("In the beginning was the Word"), + OriginalTextTag::parse, + ).originalText, + ) + + assertEquals( + "https://mantra.press/john", + roundTrip( + UrlTag.TAG_NAME, + UrlTag.assemble("https://mantra.press/john"), + UrlTag::parse, + ).url, + ) + + assertEquals( + "private", + roundTrip( + VisibilityTag.TAG_NAME, + VisibilityTag.assemble("private"), + VisibilityTag::parse, + ).visibility, + ) + + assertEquals( + "v2", + roundTrip( + ArtifactVersionMetadataTag.TAG_NAME, + ArtifactVersionMetadataTag.assemble("v2"), + ArtifactVersionMetadataTag::parse, + ).versionLabel, + ) + } + + // --- the three that carry numbers through a string slot --- + + @Test + fun `every numeric tag parses back as itself`() { + assertEquals( + 7, + roundTrip( + IndexTag.TAG_NAME, + IndexTag.assemble(7), + IndexTag::parse, + ).index, + ) + + assertEquals( + 30303, + roundTrip( + PayloadKindTag.TAG_NAME, + PayloadKindTag.assemble(30303), + PayloadKindTag::parse, + ).kind, + ) + + val stats = roundTrip( + WordStatisticsTag.TAG_NAME, + WordStatisticsTag.assemble(wordCount = 412, characterCount = 2317), + WordStatisticsTag::parse, + ) + assertEquals(412, stats.wordCount) + assertEquals(2317, stats.characterCount) + } + + // --- and the one with a role beside the key --- + + @Test + fun `the contributor tag parses back as itself with its role`() { + val parsed = roundTrip( + ContributorTag.TAG_NAME, + ContributorTag.assemble(author, ContributorTag.ROLE_TRANSLATOR), + ContributorTag::parse, + ) + + assertEquals(author, parsed.pubKey) + assertEquals(ContributorTag.ROLE_TRANSLATOR, parsed.role) + } + + /** + * No tag in the package answers to another tag's name. + * + * The wire names are not a tidy family -- six carry a stray `Tag` suffix and + * `ContributorTag` is the single letter `p` -- so the guard against the + * template's other failure mode, a copied `TAG_NAME`, is that every parse + * refuses all twenty-three arrays that are not its own. + */ + @Test + fun `no tag parses another tag's array`() { + val assembled: List>> = listOf( + ArtifactIdTag.TAG_NAME to ArtifactIdTag.assemble(eventId, relay, author), + ArtifactVersionIdTag.TAG_NAME to ArtifactVersionIdTag.assemble(eventId, relay, author), + ArtifactVersionMetadataTag.TAG_NAME to ArtifactVersionMetadataTag.assemble("v2"), + ChapterIdTag.TAG_NAME to ChapterIdTag.assemble(eventId, relay, author), + ChunkIdTag.TAG_NAME to ChunkIdTag.assemble(eventId, relay, author), + ContributorTag.TAG_NAME to ContributorTag.assemble(author, ContributorTag.ROLE_EDITOR), + CountryTag.TAG_NAME to CountryTag.assemble("Botswana"), + CreatorTag.TAG_NAME to CreatorTag.assemble(author), + DialectIdTag.TAG_NAME to DialectIdTag.assemble(eventId, relay, author), + IndexTag.TAG_NAME to IndexTag.assemble(7), + LanguageTag.TAG_NAME to LanguageTag.assemble("tn"), + LicenseTag.TAG_NAME to LicenseTag.assemble("CC-BY-SA-4.0"), + NameTag.TAG_NAME to NameTag.assemble("Gospel of John"), + OriginalTextTag.TAG_NAME to OriginalTextTag.assemble("In the beginning was the Word"), + PayloadIdTag.TAG_NAME to PayloadIdTag.assemble(eventId, relay, author), + PayloadKindTag.TAG_NAME to PayloadKindTag.assemble(30303), + ProjectIdTag.TAG_NAME to ProjectIdTag.assemble(eventId, relay, author), + TranslationArtifactVersionIdTag.TAG_NAME to + TranslationArtifactVersionIdTag.assemble(eventId, relay, author), + TranslationChapterIdTag.TAG_NAME to + TranslationChapterIdTag.assemble(eventId, relay, author), + TranslationChunkIdTag.TAG_NAME to + TranslationChunkIdTag.assemble(eventId, relay, author), + TranslationIdTag.TAG_NAME to TranslationIdTag.assemble(eventId, relay, author), + UrlTag.TAG_NAME to UrlTag.assemble("https://mantra.press/john"), + VisibilityTag.TAG_NAME to VisibilityTag.assemble("private"), + WordStatisticsTag.TAG_NAME to WordStatisticsTag.assemble(412, 2317), + ) + + val parsers: List) -> Any?>> = listOf( + ArtifactIdTag.TAG_NAME to ArtifactIdTag::parse, + ArtifactVersionIdTag.TAG_NAME to ArtifactVersionIdTag::parse, + ArtifactVersionMetadataTag.TAG_NAME to ArtifactVersionMetadataTag::parse, + ChapterIdTag.TAG_NAME to ChapterIdTag::parse, + ChunkIdTag.TAG_NAME to ChunkIdTag::parse, + ContributorTag.TAG_NAME to ContributorTag::parse, + CountryTag.TAG_NAME to CountryTag::parse, + CreatorTag.TAG_NAME to CreatorTag::parse, + DialectIdTag.TAG_NAME to DialectIdTag::parse, + IndexTag.TAG_NAME to IndexTag::parse, + LanguageTag.TAG_NAME to LanguageTag::parse, + LicenseTag.TAG_NAME to LicenseTag::parse, + NameTag.TAG_NAME to NameTag::parse, + OriginalTextTag.TAG_NAME to OriginalTextTag::parse, + PayloadIdTag.TAG_NAME to PayloadIdTag::parse, + PayloadKindTag.TAG_NAME to PayloadKindTag::parse, + ProjectIdTag.TAG_NAME to ProjectIdTag::parse, + TranslationArtifactVersionIdTag.TAG_NAME to TranslationArtifactVersionIdTag::parse, + TranslationChapterIdTag.TAG_NAME to TranslationChapterIdTag::parse, + TranslationChunkIdTag.TAG_NAME to TranslationChunkIdTag::parse, + TranslationIdTag.TAG_NAME to TranslationIdTag::parse, + UrlTag.TAG_NAME to UrlTag::parse, + VisibilityTag.TAG_NAME to VisibilityTag::parse, + WordStatisticsTag.TAG_NAME to WordStatisticsTag::parse, + ) + + // Twenty-four names, each one its own. Two tags sharing a name would make + // the cross-check below vacuous for the pair, so count them first. + assertEquals(24, assembled.size) + assertEquals(assembled.size, assembled.map { it.first }.toSet().size) + assertEquals(assembled.map { it.first }, parsers.map { it.first }) + + for ((name, parse) in parsers) { + for ((otherName, otherTag) in assembled) { + if (otherName == name) continue + assertNull(parse(otherTag), "$name: parse accepted $otherName's tag") + } + } + } +} From c17a5be8945769e93969a3655864c2f5b139c280 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:08:45 +0200 Subject: [PATCH 19/28] docs(subgroups): a plan for making a subgroup from a dialect, an artifact, a translation or a project Four entities, one shape: `id`, `name`, `chatRoomId` and a per-type kind. So the convenience function the title asks for is four field reads, and recording that is most of what this doc does -- the rest is the part that is actually missing. Nothing in the app can say which subgroup a project's room *is*. A name pre-filled into the picker gets a room related to its entity by a string somebody typed once, which is the same bargain a dialect already loses: nothing in the schema makes a dialect unique, so its identity is `(language, country)` and never its name. So the link goes on the birth certificate -- the one artefact in the relationship that is signed, travels, and is already read on both sides -- as an optional `subgroupFor` tag the parent's quorum signs along with the name it already signs. Eight phases, one commit each, one migration. Phases 1-3 are a shippable release on their own and are exactly the cheap version of this feature; 4-7 add the tag, the `DkgSession` columns that carry it past the ceremony, and the reverse view that makes it worth having. Two things Phase 0 found by doing what it says: A dialect has no detail screen, so it is Phase 2 and not a button on a card. The first draft said the opposite; the reversal is recorded in the doc rather than edited out, because the argument against the card is the argument for Phase 7 -- a `ListItem` in a `LazyColumn` has nowhere to put a list of the subgroups made for an entity, and a dialect is the entity a room is most likely to want several rooms for. And the schema number collided. 23, 24 and 25 are taken on an unmerged branch; this takes 23 anyway, because a number is free relative to the merge base and pre-allocating 26 moves the renumbering rather than avoiding it. Co-Authored-By: Claude Opus 5 --- docs/subgroups-for-entities.md | 538 +++++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 docs/subgroups-for-entities.md diff --git a/docs/subgroups-for-entities.md b/docs/subgroups-for-entities.md new file mode 100644 index 00000000..959297f5 --- /dev/null +++ b/docs/subgroups-for-entities.md @@ -0,0 +1,538 @@ +# A subgroup for a dialect, an artifact, a translation or a project + +A group's room holds a library, a set of dialects and a list of projects. Making +a room for one of them — a differently-keyed group whose work *is* that artifact, +that dialect, that translation, that project — takes seven screens today: back +out to the room, find "Add subgroup", retype the name, pick the admins. Every +part of that except the admins is already known from the thing the user was +looking at. + +This is the plan for one call that answers it, what the call hands back, the one +place the answer has to be written down so the room is still connected to the +entity tomorrow, and the screen a dialect turns out to need before it can be +asked at all. + +Read [subgroups.md](./subgroups.md) first — the four steps, what the birth +certificate covers, and why the child's key is fresh. Nothing here changes any of +that. Written against the code as it stood at `5875655a`. + +## What the four have in common + +| entity | kind | name | room | +|---|---|---|---| +| `MantraDialect` | 30304 | `name` | `chatRoomId` | +| `MantraArtifact` | 30300 | `name` | `chatRoomId` | +| `MantraTranslationArtifactVersion` | 30306 | `name` | `chatRoomId` | +| `MantraProject` | 30330 | `name` | `chatRoomId` | + +Three fields, the same three on all four, plus a kind that is a per-type +constant. That is the whole of what a subgroup needs to be started: the parent is +`chatRoomId`, the proposed name is `name`, and the thing it is for is +`(kind, id)`. + +It is worth saying plainly how little of this feature is the convenience +function, because it decides where the effort goes. Deriving `(parent, name)` +from an entity is four field reads. What is actually missing is the other +direction: **nothing in the app can say which subgroup a project's room is**, and +without that the convenience function produces rooms that are related to their +entity by a string somebody typed once. + +## The fork: a name, or a signed link + +This decision changes the shape of the plan and should be made now rather than +discovered in Phase 6. + +**Option A — pre-fill only.** `SubgroupOrigin.of(project)` gives +`(parentChatRoomId, name)`, the picker opens with the name filled in, and the +ceremony runs exactly as it does today. One pure file, four buttons, no schema +change, no new tag. A room called `#Setswana Luke` and a translation called +`Setswana Luke` are then connected by nothing a reader can check, and +`ChatRoomDetailScreen` lists the subgroup under "subgroups" with no idea what it +is for. Two subgroups for one project are indistinguishable from two subgroups +with similar names. + +**Option B — the certificate says what it is for.** The birth certificate grows +one optional tag naming `(kind, id)`, and that is the link: signed by the +parent's quorum, carried on the child's key state, checkable by every member, and +already read by every screen that reads subgroups. + +**Recommended: B, phased so that A ships inside it.** Phases 1–3 are the pure +function, a dialect's screen and the entry points, which are useful on their own +and are exactly Option A; Phases 4–7 add the tag, the column that carries it and +the reverse view. Stopping after Phase 3 is a legitimate release, and nothing in +Phases 1–3 has to be redone to continue. + +The argument for putting the link on the certificate rather than anywhere else is +the argument the certificate was designed around, and it is in +[subgroups.md](./subgroups.md#informed-consent-and-what-the-parents-admins-are-actually-signing): +the tags carry what makes the claim checkable, and the signature covers them +because an event id hashes over its tags. A parent admin is already being asked +to sign a room id produced by a ceremony most of them were not in. "This is the +room for the Setswana Luke translation" is the single most useful sentence that +can be put in front of them, and it costs one tag. + +The alternatives were considered and are worse: + +- **A column on the entity.** `MantraProject.subgroupChatRoomId` would be a local + column on a quorum-signed, content-hashed row. Either it goes into the event — + and then the id changes, and the project cannot name a room that does not exist + until after the project is signed — or it does not, and it is a fact one device + holds and no other member can see. +- **A new table.** A join table from entity to room, written locally. Same + problem one layer out: nothing signs it, so it is this device's note. +- **The room's subject.** Already taken by the name, and a name is not an id. A + dialect is the clearest case: nothing in the schema makes one 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 a dialect's identity is its + `(language, country)` pair and never its name. A room that typed "Southern + Sotho" where another typed "Sesotho" holds the same dialect, and two rooms + named for it are two rooms with no checkable relation to it. + +The certificate is the only artefact in the relationship that is signed, travels, +and is already read on both sides. + +## What the tag is, and what it is not + +``` +["subgroupFor", "30330", "<64-hex event id>"] +``` + +Kind first, id second, both required for the tag to parse. The kind is not +redundant: an id alone would have to be looked up in four tables to be +understood, and a reader that does not know the kind cannot tell "a project this +device does not hold" from "not a project at all". The precedent is +`PayloadKindTag`/`PayloadIdTag`, which carry the same pair for the same reason — +so a client can decide what it is looking at without parsing the thing itself. + +**It authenticates nothing about the entity.** The parent's quorum is signing that +*it intends* this room to be for that entity; it is not vouching that the entity +exists, that this device can resolve it, or that the entity's own signature is +good. That is the same limit `docs/project-feature.md` states for a cross-room +project membership — "the group asserts the membership; it does not vouch for the +thing named, which no amount of signing can" — and it is the correct limit here +too, because a subgroup's members need never have been in the parent and so need +never hold the parent's rows. + +**It is never checked by `certifies`.** The founding-roster rule in +`SubgroupBirthCertificateEvent` says the name and the `p` tags are history and +are not compared against anything current, and the origin joins them. A +certificate with no origin tag is valid — every certificate signed before this +feature has none — and a certificate naming an entity this device cannot resolve +is valid. There is a test whose job is to fail the day somebody adds the +comparison that looks obviously missing, and this adds a second case to it. + +## Phase 0 — the decisions, written down + +Before any code: + +1. **The fork above.** B, phased. Record it here if it changes. +2. **Schema number. Checked, and it collided: 23, 24 and 25 are all taken on + `claude/focused-brattain-433b3b`**, which is unmerged — it keys a `DkgSession` + by profile (23), splits `ChatRoomMembership` off `ChatRoom` (24) and replaces + `ChatMessage.isUserMessage` with `senderIdentity` (25). `mantra` is at 22. + + **Taken anyway: 23**, with `AutoMigration(from = 22, to = 23)`, because the + merge base is what a number is free *relative to* and pre-allocating 26 does + not avoid the work. Room needs a continuous path from the version on a user's + device: if that branch lands first the edge here has to become 25 → 26 + regardless, and if it is itself renumbered or abandoned a pre-allocated 26 + leaves three dead numbers behind. Whichever tree merges second renumbers, one + commit, which is the bargain `MantraDatabase`'s v22 comment already records. + + The check is worth doing even though it did not change the answer, which is + the point `docs/project-feature.md` was making: it went wrong by checking the + wrong tree, not by failing to check. The sweep that found this: + + ```bash + for b in $(git for-each-ref --format='%(refname:short)' refs/heads/); do + git show "$b:composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt" 2>/dev/null | + grep -m1 "^ version = " | sed "s|$| <- $b|" + done | sort -u + ``` +3. **The dialect entry point. Settled: a dialect gets its own screen**, which is + Phase 2. The first draft of this plan recommended hanging the action off the + dialect card in `ChatRoomDetailScreen` instead, on the grounds that a whole + screen to hold one button is a screen somebody has to keep. That was wrong on + its own terms twice over, and the reversal is recorded rather than quietly + edited out because the argument against it is the argument for Phase 7. + + A dialect is the one of the four with no screen, so the card would have been + the only entry point that *cannot grow*: Phase 7 gives every entity a list of + the subgroups made for it and the ceremonies in flight for it, and a + `ListItem` in a `LazyColumn` of dialects has nowhere to put either. The + asymmetry would land exactly where it hurts, since a dialect is the entity a + room is most likely to want several rooms for. And the card is already the + thinnest surface in that screen — it is the only one of the three lists whose + `Card` has no `onClick` at all, so a dialect is the one thing in a room a + member cannot open and look at. +4. **Whether a second subgroup for one entity is refused.** **Recommended: no.** + Two teams working on one artifact in two rooms is a real thing and refusing it + would be this device deciding a group's structure. What Phase 7 does instead is + show what already exists, which is what prevents the *accidental* second one. + +## Phase 1 — `SubgroupOrigin`, and the trait that feeds it + +One commit. Pure, `commonMain`, no database and no Compose. + +A trait in `database/model/traits/`, implemented by the four entities: + +```kotlin +/** + * An entity a subgroup can be made for. + * + * Four rows in three tables have the same three fields and a per-type kind, and + * this is what makes "which things can a subgroup be made for" a list the + * compiler holds rather than four overloads somebody has to remember to extend. + */ +interface SubgroupOriginEntity { + val id: HexKey + val name: String + val chatRoomId: String + val subgroupOriginKind: Kind +} +``` + +`MantraDialect`, `MantraArtifact`, `MantraTranslationArtifactVersion` and +`MantraProject` each gain `override` on the three fields they already have and +one line for the kind. **No `toXEvent` or tag order changes** — the trait is +about naming a room, touches no event, and so cannot move an event id. + +Then the function the whole plan is named after: + +```kotlin +object SubgroupOrigin { + /** What a certificate names: the kind and the id, together or not at all. */ + data class Ref(val kind: Kind, val id: HexKey) + + /** Everything a picker needs to open a subgroup for [entity]. */ + data class Candidate( + val parentChatRoomId: String, + val name: String, + val origin: Ref, + ) + + fun of(entity: SubgroupOriginEntity): Candidate +} +``` + +Parallel to `TranslationScaffold` and `ChapterScaffolding`: `managers/`, pure, +tested in `commonTest`. `name` is trimmed and handed over bare — the `#` is added +by `MarmotGroupName.of` where the name is used, twice, idempotently, exactly as +the typed name is today. + +Tests (`commonTest`, `SubgroupOriginTest`): + +- one case per entity type, asserting parent, name and ref; +- a blank-named entity yields a blank candidate name rather than inventing one, + so the picker's "Give the subgroup a name." refusal still fires; +- `Ref` holds a kind and an id and there is no constructor that holds one. + +## Phase 2 — a dialect gets a screen + +One commit. No new machinery, two new queries, and the only phase here that is +not about subgroups at all. + +Artifacts, translations and projects each have a route, a UI state, a view model +and a screen. A dialect has a `ListItem` inside `ChatRoomDetailScreen`'s +`LazyColumn` with no `onClick`, so it is the one entity in a room that cannot be +opened. Phase 3 needs somewhere to put an action and Phase 7 needs somewhere to +put two lists, and this is that place. + +Follow `ArtifactDetailViewModel` exactly — it is the closest sibling and the +shortest, which is the point of copying it rather than the richer ones: + +| piece | shape | +|---|---| +| `DialectDetailRoute` | `activeUserPublicKey`, `dialectId`, `chatRoomId`, `relayHint` | +| `DialectDetailUIState` | sealed `Loading` / `Error` / `Loaded` | +| `DialectDetailViewModel` | `initiateDialectDetail()`, a `factory`, `MantraRepository` | +| `DialectDetailScreen` | registered in `MantraNavHost` beside `ArtifactDetailRoute` | + +`Loaded` carries the dialect, what is written *in* it, and the capability flag: + +```kotlin +data class Loaded( + val dialect: MantraDialect, + val artifacts: List = emptyList(), + val translations: List = emptyList(), + val canAddSubgroup: Boolean = false, +) : DialectDetailUIState +``` + +Those two lists are what a dialect screen is *for* — a dialect on its own is +three strings and a screen showing only them has no reason to exist. They are +also the two tables that carry a `dialectId`: +`MantraArtifact.dialectId` is the dialect an artifact is written in, and +`MantraTranslationArtifactVersion.dialectId` is the dialect a translation is +into. Both are plain columns and neither has a query yet: + +- `MantraDialectDao` keeps its two queries unchanged. +- `MantraArtifactDao` gains `getArtifactsByDialectId` and + `MantraTranslationArtifactVersionDao` gains `getTranslationsByDialectId`. Both + `WHERE dialectId = :dialectId ORDER BY name`, matching + `getDialectsByChatRoomId`'s shape. +- `MantraRepository` gains `getArtifactsForDialect` and + `getTranslationsForDialect`, and `NO_OP` gains both returning `emptyList()`. + +**Scope it to the dialect, not to the room.** A `dialectId` is a foreign key +inside one room today — see the one-room scope decision in +`docs/project-feature.md` — so filtering by the dialect alone cannot reach +another room's rows, and adding `AND chatRoomId = :chatRoomId` would be a second +spelling of a constraint the key already enforces. The day projects go +cross-room that stops being true, and the query is then one of the places that +has to learn about it. + +The screen itself: `TopAppBar` with `NavigateBackButton(onNavigateBack)`, +because this is a pushed screen and the desktop target has no system back — +twenty-two screens were dead ends before that rule. `ScreenStateTransition` over +the state `when`, as the whole composable's body. Four states, with `EmptyState` +given a real message for each of the two lists, since one shared default is how +five different absences all came to say the same thing. Language and country +through the shared `DetailRow` widget. `readableContent()` on the content root. + +Then the dialect card in `ChatRoomDetailScreen` gets the `onClick` it never had, +pointing at the new route — which is a fix independent of everything else here +and is the reason this phase stands alone. + +Tests (`jvmTest`, `DialectDetailJvmTest`): a dialect with two artifacts and one +translation in it loads all three; an artifact in a *different* dialect in the +same room is not listed; a missing dialect id yields `Error` rather than an empty +`Loaded`. + +## Phase 3 — four entry points + +One commit. This is where Option A is complete and shippable. + +`SelectSubgroupAdminsRoute` grows three nullable fields: + +```kotlin +val name: String? = null, +val originKind: Int? = null, +val originId: String? = null, +``` + +Flat strings because a route is `@Serializable` and carries no domain types. +Nullable because the room's own "Add subgroup" button passes none and must keep +working unchanged. + +`SelectSubgroupAdminsViewModel` takes them, seeds `name.value` from `name` on +construction rather than in `initiate()` — a field the user may already be typing +into must not be overwritten by a load completing — and holds the `Ref` for +Phase 5. The name stays editable: a pre-fill is a suggestion, and a group +renaming its own room is not this plan's business to prevent. + +The four buttons, each `enabled = canAddSubgroup` beside the actions already +there: + +| screen | where | +|---|---| +| `ArtifactDetailScreen` | beside the existing signing actions | +| `ProjectDetailScreen` | same | +| `TranslationArtifactVersionDetailScreen` | same | +| `DialectDetailScreen` | the screen Phase 2 built | + +The gate is `canAddSubgroup`'s, not `canSign` alone: a share of the key **and** +`mlsGroupState != null`, because a NIP-17 room has no admins to be a parent's and +these entities can live in one. Name the flag `canAddSubgroup` on all four states +so there is one spelling of one rule. + +`canSign` is the half that is already there, and `ProjectDetailUIState` and +`TranslationArtifactVersionDetailUIState` already carry it beside the signing +actions they already draw. `ArtifactDetailUIState` does **not** — it has +`artifact`, `versions`, `chapters` and `translations` and no capability flag at +all — so it gains one, read the same way the other two read it, from +`frostSigningRepository.canSign`. That is a genuine addition and the one place +this phase is more than a button. `DialectDetailUIState` carries the flag from +the moment Phase 2 writes it, so only the artifact screen is short. + +It is not the whole guard — `refuseSubgroup` still runs in the picker and again +before anything is published — but it is the one that decides whether the button +is drawn. + +M3, per [CLAUDE.md](../CLAUDE.md): one new string in +`composeResources/values/strings.xml`, sentence case; spacing from +`MaterialTheme.spacing`; the dialect card's icon button gets a real +`contentDescription`, since "make a subgroup" is not repeated by any text beside +it; the card's existing decorative icons stay `Decorative`. + +## Phase 4 — the tag + +One commit. + +`nostr/subgroup/tags/SubgroupOriginTag.kt`, following `PayloadKindTag`'s shape +exactly — `TAG_NAME`, `parse` with `ensure(tag.has(2))`, `toIntOrNull()` for the +kind, `assemble`. A tag with one of the two values parses to null, because half a +reference is a reference with the checkable part removed. + +`SubgroupBirthCertificateEvent`: + +- `assembleTags` takes `origin: SubgroupOrigin.Ref? = null` and appends the tag + when it is there. **Appended after the name and before the `p` tags**, and the + position is fixed for the same reason the lineage's order is fixed in + `SubgroupLineage`: a reader that predates this takes the tags it knows off the + front and ignores the rest, and a tag inserted *into* the run of `p` tags would + split the admin list for anybody parsing positionally. +- `parseOrigin(tags)`, returning `Ref?`. +- `certifies` is **not touched**. Add the case to the test that guards the + founding-roster rule: a certificate whose origin names an entity this device + does not hold still certifies. + +Tests (`commonTest`, extending `SubgroupBirthCertificateEventTest`): + +- assemble → parse round trip for each of the four kinds; +- a certificate with no origin tag parses to null and still certifies; +- a malformed origin (missing id, non-numeric kind, 63-hex id) parses to null + rather than to a half-reference; +- the tag does not disturb `parseAdminPublicKeys` or `parseName`. + +## Phase 5 — carrying it through the ceremony, and schema 23 + +One commit. This is the only phase with a migration. + +The certificate is proposed *after* the ceremony and may be proposed by an admin +who is not the coordinator — `SubgroupManager`'s whole shape is that every step +reads stored rows rather than a session object handed down from the screen that +started it. So the origin has to survive on the `DkgSession`, and it has to reach +every participant's session, not only the coordinator's. + +The precedent is exact. v19 added `subject` and `participantPublicKeys` to +`DkgSession` for precisely this reason, and v17 added `parentChatRoomId`; all +three are nullable, none is a foreign key, and all three are read off the ritual +proposal's tags. This follows them line for line: + +- `DkgSession` gains `originKind: Int?` and `originId: HexKey?`. Nullable, no + default, not foreign keys — an origin routinely names a row a subgroup member + does not hold, so a dangling value is the normal state and a cascade would mean + deleting a project deletes a room. +- `MantraDatabase` → `version = 23`, `AutoMigration(from = 22, to = 23)`, with a + comment in the house style saying what reads back null and why that is the true + answer for those rows: a ceremony that ran before this was opened for no entity. +- `DkgRitualEvents.assembleTags` takes the ref and emits the same + `SubgroupOriginTag`; `parseOrigin` reads it. The doc comment says what + `parseParentChatRoomId`'s says and for the same reason — **this authenticates + nothing**, anybody can put any id here, and the claim that decides is the + certificate two steps later. +- `ChillDkgRitualManager.proposeRitual` and `acceptProposal` file it, the way they + file `subject`. +- `DkgRepository.proposeRitual` grows the parameter; `NO_OP` grows it too. +- `SelectSubgroupAdminsViewModel.confirm` passes the `Ref` it has held since + Phase 3. +- `SubgroupManager.proposeBirthCertificate` takes `origin: SubgroupOrigin.Ref?` + and passes it to `assembleTags`; `DkgRitualViewModel.proposeBirthCertificate` + reads it **off the session** — `session.originKind`/`session.originId` — never + off a route, for the same reason it reads the parent off the state and not the + route. + +Tests (`jvmTest`): + +- `SubgroupDaoJvmTest`: a session round trips both columns, and a row with both + null reads back as no origin; +- `SubgroupCeremonyInParentRoomTest` or a new `SubgroupOriginJvmTest`: proposal → + every participant's session → certificate tags, asserting the id that comes out + of the signed certificate is the one that went into the picker; +- a ceremony opened with no origin produces a certificate with no origin tag, + which is the room's own "Add subgroup" path and must not change. + +## Phase 6 — what the signer and the room see + +One commit, no schema. + +`SubgroupManager.Subgroup` gains `origin: SubgroupOrigin.Ref?`, parsed from the +certificate it already parses the name and admins from. Nothing else about that +type changes, so every existing reader keeps working. + +`ProposedEvent.summarize`'s 30329 branch gains the origin, resolved to a name +where the device holds the row and shown as a kind plus a short hash where it does +not — `describe`'s existing bargain, and the comment there already says why a +short hash in front of somebody deciding whether to sign is worth stating rather +than hiding. This is the phase that pays for the tag: a parent admin reads "a new +subgroup of this group · Setswana Luke · for the translation Setswana Luke · run +by 4 members" instead of a name and a count. + +`SubgroupCard` in `ChatRoomDetailScreen` gains a supporting line saying what the +subgroup is for, on the same terms. + +Resolution needs a lookup by id across four tables. `MantraRepository` already +has `getDialect`, `getArtifact`, `getTranslation` and `getProject`, each taking +an id — so this is one `when` on the kind over functions that exist, and +deliberately not a new DAO query. + +## Phase 7 — the other direction + +One commit, no schema. The phase that makes the link worth having. + +Each of the four entity surfaces shows the subgroups made for it, and in-flight +ceremonies for it: + +- **Made.** Filter `chatRepository.subgroupsOf(entity.chatRoomId)` on + `origin == SubgroupOrigin.of(entity).origin`. No new query: the parent's + certificates are already loaded by the room's screen, and the entity's screen + reads the same list. A subgroup whose room this device does not hold still + shows, because `Subgroup.room` is already nullable and already drawn that way. +- **In flight.** `DkgSession` rows in that room with that origin whose stage is + neither `FAILED` nor `COMPLETE`. Two admins both open the project's screen, + both see no room, and the group gets two ceremonies and two rooms for one + project — a race this feature would hit on first use, because the screen that + invites the act is the screen that cannot see the act in progress. Making the + open ceremony readable at the moment of proposing is what stops it, and it is + cheap: `getLatestSubgroupSessionFor` is already the shape of the query. + +Nothing is refused (Phase 0.4). The button reads differently when a room exists — +"open the project's room", or the add action beside a list of one — and that is +the whole intervention. + +Tests (`jvmTest`): a VM whose room holds two subgroups, one for this entity and +one for another, lists exactly one; an entity with a ceremony in flight shows it; +a completed ceremony is not shown as in flight. + +## Phase 8 — docs and the audit + +One commit. + +- This file, updated to say what the implementation chose differently. Every doc + in `docs/` carries that table and it is the most-read part of them. +- A pointer from `docs/subgroups.md` beside the multiple-parents one. +- A line in `docs/project-feature.md` under "what this does not do", since a + project's room is the first thing that file says it lacks. +- The M3 floors move. `docs/material-design-conformance.md` states "at least 12 + adaptive API uses, at least 2 navigation components", checked from *below* + because they regress by being removed. Phase 2 adds a screen with + `readableContent()` and a `NavigateBackButton`, so the floor can be raised — + and should be, or a screen deleted later takes the guarantee with it silently. + +## Verifying + +In a fresh worktree, bootstrap the submodule chain first — four nested +submodules, and gradle otherwise dies on `':library' not found`. + +```bash +ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid +``` + +```bash +ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:jvmTest :composeApp:testDebugUnitTest +``` + +```bash +./gradlew :composeApp:m3Audit +``` + +`IdentitySwitchJvmTest`'s "last profile opened" case fails about one run in nine +on a clean tree; check the base before blaming a change here. + +## What this does not do + +- **It does not make a subgroup cheaper.** Four multi-party rounds, every one + needing every selected admin present. The convenience is in the naming and the + link, not in the ceremony, and a group that wants a room per chapter is going to + find that out. +- **It does not move the entity.** A project whose room is a subgroup is still a + row in the *parent's* tables, signed by the parent's quorum, and the child's + members may hold none of it. Giving the child its own copy is a cross-room + question and is Phase 9 of `docs/project-feature.md`, not this. +- **It does not resolve an origin across rooms.** A certificate may name an entity + this device cannot see, and the UI shows a kind and a short hash rather than + pretending. +- **It does not make the origin evidence.** The parent asserts what the room is + for. Nothing here checks that the entity exists, and nothing can. +``` From 153e92f558db9c525004316184867ca9420e2570 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:13:19 +0200 Subject: [PATCH 20/28] feat(subgroups): read a subgroup out of the thing it is for A dialect, an artifact, a translation and a project already carry the three facts a subgroup needs -- the room they live in, a name, and an id -- so `SubgroupOrigin.of` reads them and hands back a candidate. That is the whole of the convenience, and it is deliberately only that: no ceremony is opened, no refusal is re-decided, and the admins and the threshold are still settled in the picker and nowhere else. `SubgroupOriginEntity` is an interface rather than four overloads so that "which things can a subgroup be made for" is a question the compiler answers. A fifth entity gains the capability by implementing it. The three fields are the ones already on the rows; the only new one is the kind, as a computed property so Room never sees it -- a kind is a fact about the type, and a stored copy could disagree with the event the row came from. The kind is carried because an id alone would have to be looked up in four tables to be understood, and a reader without it cannot tell "a project this device does not hold" from "not a project at all". `Ref` holds both or neither for the same reason. Two things the tests are really for. The name comes back bare, because `MarmotGroupName.of` adds the `#` where the name is used -- into the certificate the parent signs and onto the room -- and a third place that has to agree is a third place that can disagree, most easily with the field the user is typing in. And a blank name stays blank: the picker already refuses to confirm without one, and inventing a name here would route around a refusal whose job is to be hit. Phase 1 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/MantraArtifact.kt | 19 ++- .../compose/database/model/MantraDialect.kt | 19 ++- .../compose/database/model/MantraProject.kt | 19 ++- .../model/MantraTranslationArtifactVersion.kt | 19 ++- .../model/traits/SubgroupOriginEntity.kt | 51 ++++++ .../mantra/compose/managers/SubgroupOrigin.kt | 78 +++++++++ .../compose/managers/SubgroupOriginTest.kt | 160 ++++++++++++++++++ 7 files changed, 349 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/traits/SubgroupOriginEntity.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupOrigin.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SubgroupOriginTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt index a8194481..6ccf2fdd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraArtifact.kt @@ -4,11 +4,13 @@ import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionMetadataTag @@ -40,16 +42,16 @@ import kotlin.time.Instant ) data class MantraArtifact( @PrimaryKey - val id: HexKey, + override val id: HexKey, val publicKey: String, - val name: String, + override val name: String, val url: String, // Hash this to get the dTag... val visibility: String, // public, private, permissioned (public, but only members can contribute) val dialectId: String, val license: String, // val artifactApprovalId: String, - val chatRoomId: String, + override val chatRoomId: String, /** * Signature will be an empty string if this is a rumor @@ -68,7 +70,16 @@ data class MantraArtifact( override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = ArtifactEvent.KIND /** * The event the group signed, rebuilt from this row. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt index 0bc37ba8..a6dc0bae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraDialect.kt @@ -4,11 +4,13 @@ import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.tags.CountryTag @@ -32,15 +34,15 @@ import kotlin.time.Instant ) data class MantraDialect( @PrimaryKey - val id: HexKey, + override val id: HexKey, val publicKey: HexKey, - val name: String, + override val name: String, val country: String, val language: String, val signature: String, - val chatRoomId: String, + override val chatRoomId: String, val relayHint: String? = null, val marmotGroupEventId: HexKey? = null, @@ -54,7 +56,16 @@ data class MantraDialect( override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = DialectEvent.KIND @Deprecated( "Chronicle fallback for work signed before the GroupSignedEvent table. Read the " + diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt index abbf505f..34ab0055 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt @@ -5,9 +5,11 @@ import androidx.room3.ForeignKey import androidx.room3.Index import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.ProjectEvent import kotlin.time.Clock @@ -39,12 +41,12 @@ import kotlin.time.Instant ) data class MantraProject( @PrimaryKey - val id: HexKey, + override val id: HexKey, /** The room's own key on a group-signed row, which is every project. */ val publicKey: HexKey, - val name: String, + override val name: String, val description: String, /** @@ -73,7 +75,7 @@ data class MantraProject( */ val ownerPublicKeys: String, - val chatRoomId: String, + override val chatRoomId: String, /** Signature will be an empty string if this is a rumor */ val signature: String, @@ -89,7 +91,16 @@ data class MantraProject( override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = ProjectEvent.KIND /** The owners as the set they are. Empty only on a row written around this. */ fun ownerPublicKeySet(): Set = ownerPublicKeys diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt index 06f05451..eaaeb5f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraTranslationArtifactVersion.kt @@ -4,11 +4,13 @@ import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.PrimaryKey import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag @@ -46,11 +48,11 @@ import kotlin.time.Instant ) data class MantraTranslationArtifactVersion( @PrimaryKey - val id: HexKey, + override val id: HexKey, val publicKey: HexKey, val artifactVersionId: HexKey, val dialectId: HexKey, - val name: String, + override val name: String, val visibility: String, val license: String, @@ -59,7 +61,7 @@ data class MantraTranslationArtifactVersion( */ val signature: String, - val chatRoomId: String, + override val chatRoomId: String, val relayHint: String? = null, val marmotGroupEventId: HexKey? = null, @@ -73,7 +75,16 @@ data class MantraTranslationArtifactVersion( override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt -): OptionalNostrEventEntity, TimestampedEntity { +): OptionalNostrEventEntity, SubgroupOriginEntity, TimestampedEntity { + + /** + * Which kind a reader holding only this row's id is looking at. + * + * A computed property, so it is not a column and Room never sees it -- + * the kind is a fact about the type rather than about the row, and a + * stored copy could disagree with the event this row came from. + */ + override val subgroupOriginKind: Kind get() = TranslationArtifactVersionEvent.KIND @Deprecated( "Chronicle fallback for work signed before the GroupSignedEvent table. Read the " + diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/traits/SubgroupOriginEntity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/traits/SubgroupOriginEntity.kt new file mode 100644 index 00000000..676f8e8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/traits/SubgroupOriginEntity.kt @@ -0,0 +1,51 @@ +package press.mantra.compose.database.model.traits + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind + +/** + * A thing a group can decide deserves a room of its own. + * + * Four rows in four tables -- a dialect, an artifact, a translation, a project -- + * already carry the same three fields, and a subgroup made for any of them needs + * exactly those three: the room it lives in becomes the parent, its name becomes + * the proposed name, and its id and kind become what the parent's quorum + * certifies the new room is *for*. + * + * It is an interface rather than four overloads of `SubgroupOrigin.of` so that + * "which things can a subgroup be made for" is a question the compiler answers. + * A fifth entity gains the capability by implementing this, and the picker, the + * certificate and both directions of the UI pick it up without being told. + * + * ### Why `name` is here and is not an identity + * + * [name] is for naming a room and nothing else. It is deliberately not treated + * as identifying the entity anywhere: a dialect's identity is its + * `(language, country)` pair, since nothing in the schema 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. Two rooms that typed + * "Southern Sotho" and "Sesotho" hold the same dialect under two names. So the + * link a subgroup carries is [id] and [subgroupOriginKind], and the name is only + * ever what the room is called. + * + * ### Why the kind is on the row + * + * A reader holding an id alone would have to try four tables to find out what it + * is looking at, and could not tell "a project this device does not hold" from + * "not a project at all". Every one of these rows already knows its own event + * kind -- most of them build one in `toXEvent()` -- so stating it costs a line + * and saves the lookup. See `SubgroupOrigin`. + */ +interface SubgroupOriginEntity { + /** The entity's own id: a nostr event id, content-hashed. */ + val id: HexKey + + /** What to call the room. Never read as an identity -- see the class comment. */ + val name: String + + /** The room this entity lives in, which is the room that would be the parent. */ + val chatRoomId: String + + /** The nip30303 kind of the event this row is, for a reader holding only the id. */ + val subgroupOriginKind: Kind +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupOrigin.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupOrigin.kt new file mode 100644 index 00000000..12b27866 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupOrigin.kt @@ -0,0 +1,78 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import press.mantra.compose.database.model.traits.SubgroupOriginEntity + +/** + * What a subgroup was made for, and everything a picker needs to start one. + * + * Making a room for a dialect, an artifact, a translation or a project takes + * three facts, and all three are already sitting on the row the user was looking + * at when they asked: the room it lives in is the parent, its name is the name to + * propose, and its id and kind are what the parent's quorum certifies the new + * room is for. [of] reads them, and that is the whole of the convenience. + * + * Pure, and parallel to `TranslationScaffold` and `ChapterScaffolding` -- no + * database, no Compose, no repository. Nothing here opens a ceremony: the picker + * still does that through `DkgRepository.proposeRitual`, and every refusal is + * still `SubgroupManager.refuseSubgroup`'s. What this removes is a user retyping + * a name the app already knew and a room ending up related to its entity by + * having been named after it. + * + * See `docs/subgroups-for-entities.md`. + */ +object SubgroupOrigin { + /** + * The entity a subgroup is for: a kind and an id, together or not at all. + * + * There is no constructor that holds one of the two, which is the point of + * the type. An id with no kind is unresolvable without trying four tables; a + * kind with no id names a category rather than a thing. `SubgroupOriginTag` + * refuses to parse half of this for the same reason. + * + * Holding one is not evidence of anything. The parent's quorum signs that it + * *intends* a room to be for this entity; it does not vouch that the entity + * exists, and a device that holds the certificate routinely holds none of the + * parent's rows -- a subgroup's members need never have been in the parent. + */ + data class Ref( + val kind: Kind, + val id: HexKey, + ) + + /** + * Everything the subgroup picker needs, read off [entity]. + * + * [Candidate.name] is bare -- no `#`. The convention is applied by + * `MarmotGroupName.of` where the name is actually used, twice and + * idempotently: once into the birth certificate the parent's quorum signs and + * once onto the room itself. Normalising here as well would be a third place + * that has to agree, and the one it would most easily disagree with is the + * name field the user is still typing in. + * + * A blank name comes back blank rather than becoming "a group" or the + * entity's id. The picker already refuses to confirm without a name -- "Give + * the subgroup a name." -- and inventing one here would route around a + * refusal whose whole job is to be hit. + */ + fun of(entity: SubgroupOriginEntity): Candidate = Candidate( + parentChatRoomId = entity.chatRoomId, + name = entity.name.trim(), + origin = Ref(kind = entity.subgroupOriginKind, id = entity.id), + ) + + /** + * A subgroup that could be made, before anybody has picked who runs it. + * + * Deliberately not a proposal and not a session. Who the admins are and what + * the threshold is are settled in the picker and nowhere else -- ChillDKG + * hashes the threshold into the session identity, so there is no later screen + * where it could change -- and this type stops short of all of it. + */ + data class Candidate( + val parentChatRoomId: String, + val name: String, + val origin: Ref, + ) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SubgroupOriginTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SubgroupOriginTest.kt new file mode 100644 index 00000000..e39c4157 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SubgroupOriginTest.kt @@ -0,0 +1,160 @@ +package press.mantra.compose.managers + +import kotlin.test.Test +import kotlin.test.assertEquals +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.traits.SubgroupOriginEntity +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent + +/** + * Reading a subgroup out of the thing it is for. + * + * Four entities, one answer each, and the answer is three fields the row already + * carried. What these cases are really guarding is that the three keep meaning + * the same thing: the parent is the room the entity lives in and never the + * entity's id, the name is bare and un-normalised, and the kind is the entity's + * own kind rather than a subgroup kind. + */ +class SubgroupOriginTest { + + private val room = "r".repeat(64) + private val entityId = "e".repeat(64) + private val key = "p".repeat(64) + + private fun dialect(name: String = "Sesotho") = MantraDialect( + id = entityId, + publicKey = key, + name = name, + country = "Lesotho", + language = "st", + signature = "", + chatRoomId = room, + ) + + private fun artifact(name: String = "Luke") = MantraArtifact( + id = entityId, + publicKey = key, + name = name, + url = "https://example.invalid/luke", + visibility = "public", + dialectId = "d".repeat(64), + license = "CC0", + chatRoomId = room, + signature = "", + ) + + private fun translation(name: String = "Setswana Luke") = MantraTranslationArtifactVersion( + id = entityId, + publicKey = key, + artifactVersionId = "v".repeat(64), + dialectId = "d".repeat(64), + name = name, + visibility = "public", + license = "CC0", + signature = "", + chatRoomId = room, + ) + + private fun project(name: String = "Southern Africa") = MantraProject( + id = entityId, + publicKey = key, + name = name, + description = "Four dialects, one gospel.", + creatorPublicKey = key, + ownerPublicKeys = key, + chatRoomId = room, + signature = "", + ) + + private fun assertCandidate(entity: SubgroupOriginEntity, kind: Int, name: String) { + val candidate = SubgroupOrigin.of(entity) + + assertEquals(room, candidate.parentChatRoomId) + assertEquals(name, candidate.name) + assertEquals(SubgroupOrigin.Ref(kind = kind, id = entityId), candidate.origin) + } + + @Test + fun `a dialect names its room, its name and kind 30304`() { + assertCandidate(dialect(), DialectEvent.KIND, "Sesotho") + } + + @Test + fun `an artifact names its room, its name and kind 30300`() { + assertCandidate(artifact(), ArtifactEvent.KIND, "Luke") + } + + @Test + fun `a translation names its room, its name and kind 30306`() { + assertCandidate(translation(), TranslationArtifactVersionEvent.KIND, "Setswana Luke") + } + + @Test + fun `a project names its room, its name and kind 30330`() { + assertCandidate(project(), ProjectEvent.KIND, "Southern Africa") + } + + /** + * The four kinds are four, which is the whole reason the ref carries one. An + * id alone would have to be looked up in four tables, and this is the case + * that fails if somebody gives two entities the same kind by copying a + * sibling. + */ + @Test + fun `the four kinds are distinct`() { + val kinds = listOf(dialect(), artifact(), translation(), project()) + .map { SubgroupOrigin.of(it).origin.kind } + + assertEquals(4, kinds.distinct().size, "two entities report the same kind: $kinds") + } + + /** + * Bare, with no `#`. `MarmotGroupName.of` adds it where the name is used -- + * into the certificate the parent signs, and onto the room -- and it is + * idempotent, so a name normalised here as well would still come out right. + * It is kept out anyway because this name goes into a text field the user is + * about to type in, and the `#` is drawn beside that field rather than put + * inside it. + */ + @Test + fun `the name carries no group prefix`() { + assertEquals("Sesotho", SubgroupOrigin.of(dialect()).name) + assertEquals("#Sesotho", MarmotGroupName.of(SubgroupOrigin.of(dialect()).name)) + } + + @Test + fun `surrounding space is trimmed`() { + assertEquals("Sesotho", SubgroupOrigin.of(dialect(" Sesotho ")).name) + } + + /** + * A blank name stays blank rather than becoming "a group" or the entity's id. + * The picker refuses to confirm without one -- "Give the subgroup a name." -- + * and inventing a name here would route around a refusal whose whole job is + * to be hit. + */ + @Test + fun `a blank name is handed back blank, not invented`() { + assertEquals("", SubgroupOrigin.of(dialect(" ")).name) + assertEquals("", SubgroupOrigin.of(project("")).name) + } + + /** + * The parent is the room, not the entity. Easy to get backwards -- both are + * 64-hex strings on the same row -- and getting it backwards would open a + * ceremony in a room that does not exist, which fails late and unhelpfully. + */ + @Test + fun `the parent is the room the entity lives in`() { + val candidate = SubgroupOrigin.of(artifact()) + + assertEquals(room, candidate.parentChatRoomId) + assertEquals(entityId, candidate.origin.id) + } +} From 0727a04bae90056abe8c09263da50d1f887687ee Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:19:22 +0200 Subject: [PATCH 21/28] feat(dialects): a dialect is a thing you can open Artifacts, translations and projects each have a route, a state, a view model and a screen. A dialect had a `ListItem` inside `ChatRoomDetailScreen`'s `LazyColumn` with no `onClick`, which made it the one entity in a room a member could look at and not open. This is that screen, and the card finally gets the `onClick` it never had. The two lists are the reason it exists. A dialect is a name, a language code and a country; those three sit at the bottom under the details the way they do everywhere else, and what a member opens a dialect to find out is what the group has written in it. `MantraArtifact.dialectId` and `MantraTranslationArtifactVersion.dialectId` are the only two columns in the app that point at a dialect, and neither had a query. Both new queries are scoped by the dialect alone, with no `chatRoomId` beside it. `dialectId` is a foreign key into this room's own rows, so the key already confines the read to one room and a second filter would be a second spelling of a constraint that is already enforced. That is the one-room scope decision made load-bearing rather than restated; when a dialect can be named across rooms -- Phase 9 of docs/project-feature.md -- these two queries are among the places that have to learn about it, which is why the reasoning is on them rather than here. `Loaded` also carries `canAddSubgroup`, which Phase 3 needs and which is the same two conditions `ChatRoomDetailUIState` already states: a Marmot room, and a share of its key. It reads the room off the *dialect's* `chatRoomId` rather than the route's, so the parent of a subgroup made here is the room the dialect actually lives in even if a route is ever built by hand. A missing dialect is `Error` and not an empty `Loaded`, because "this room has written nothing in Sesotho" and "there is no such dialect here" would otherwise look identical and only one of them is worth a retry button. The test that earns its place is the two-dialects-in-one-room case: if either query ever drifted to scoping by room, every single-dialect test would still pass and that one would not. Phase 2 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 3 + .../compose/database/dao/MantraArtifactDao.kt | 13 + .../MantraTranslationArtifactVersionDao.kt | 16 + .../repository/DatabaseMantraRepository.kt | 8 + .../compose/repository/MantraRepository.kt | 14 + .../ui/composable/ChatRoomDetailScreen.kt | 22 +- .../ui/composable/DialectDetailScreen.kt | 317 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 22 ++ .../navigation/routes/DialectDetailRoute.kt | 19 ++ .../ui/view/model/DialectDetailViewModel.kt | 99 ++++++ .../ui/view/state/DialectDetailUIState.kt | 47 +++ .../ui/view/model/DialectDetailJvmTest.kt | 203 +++++++++++ 12 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DialectDetailRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a0aa8b78..b52173d7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -89,6 +89,7 @@ Currently no messages have been shared.\nBreak the ice. Delete group Details + Dialect detail Dialect name Dialects Direct message detail @@ -183,6 +184,8 @@ No one selected yet No one to add yet. No projects exists in this group. + No artifacts are written in this dialect yet. + Nothing has been translated into this dialect yet. No translations yet. No versions. Not now diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt index b9cc1758..ac99493e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraArtifactDao.kt @@ -15,4 +15,17 @@ interface MantraArtifactDao { @Query("SELECT * FROM MantraArtifact WHERE id = :id") suspend fun getArtifactById(id: String): MantraArtifact? + + /** + * The artifacts written in [dialectId], newest first. + * + * Scoped by the dialect alone and not also by a room. `dialectId` is a + * foreign key into this room's own rows, so it cannot reach another room's + * artifacts and a second `chatRoomId` filter would be a second spelling of a + * constraint the key already enforces. The day a dialect can be named across + * rooms -- see Phase 9 of docs/project-feature.md -- this is one of the + * places that has to learn about it. + */ + @Query("SELECT * FROM MantraArtifact WHERE dialectId = :dialectId ORDER BY createdAt DESC") + suspend fun getArtifactsByDialectId(dialectId: String): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt index 160d5960..9d57e584 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraTranslationArtifactVersionDao.kt @@ -41,4 +41,20 @@ interface MantraTranslationArtifactVersionDao { @Query("SELECT * FROM MantraTranslationArtifactVersion WHERE id = :id") suspend fun getTranslationById(id: String): MantraTranslationArtifactVersion? + + /** + * The translations into [dialectId], newest first. + * + * Scoped by the dialect alone, for the reason + * `MantraArtifactDao.getArtifactsByDialectId` gives: the foreign key already + * confines it to one room. + */ + @Query( + """ + SELECT * FROM MantraTranslationArtifactVersion + WHERE dialectId = :dialectId + ORDER BY createdAt DESC + """ + ) + suspend fun getTranslationsByDialectId(dialectId: String): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt index 8eafb679..c90a101f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseMantraRepository.kt @@ -104,6 +104,14 @@ class DatabaseMantraRepository( override suspend fun getDialect(id: String): MantraDialect? = database.mantraDialectDao().getDialectById(id) + override suspend fun getArtifactsForDialect(dialectId: String): List = + database.mantraArtifactDao().getArtifactsByDialectId(dialectId) + + override suspend fun getTranslationsForDialect( + dialectId: String + ): List = + database.mantraTranslationArtifactVersionDao().getTranslationsByDialectId(dialectId) + override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt index 3d197ad5..284b8971 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -57,6 +57,12 @@ interface MantraRepository { suspend fun getDialect(id: String): MantraDialect? + /** The artifacts written in a dialect, newest first. */ + suspend fun getArtifactsForDialect(dialectId: String): List + + /** The translations into a dialect, newest first. */ + suspend fun getTranslationsForDialect(dialectId: String): List + suspend fun getProjects(chatRoomId: String): List suspend fun getProject(id: String): MantraProject? @@ -128,6 +134,14 @@ interface MantraRepository { override suspend fun getDialect(id: String): MantraDialect? = null + override suspend fun getArtifactsForDialect( + dialectId: String + ): List = emptyList() + + override suspend fun getTranslationsForDialect( + dialectId: String + ): List = emptyList() + override suspend fun getProjects(chatRoomId: String): List = emptyList() override suspend fun getProject(id: String): MantraProject? = null 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 0800c27d..609005de 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 @@ -105,6 +105,7 @@ import mantra.composeapp.generated.resources.subgroups import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager import mantra.composeapp.generated.resources.Res @@ -566,12 +567,29 @@ fun ChatRoomDetailScreen( items = chatRoomDetailUIState.dialects, key = { dialect -> dialect.id } ) { dialect -> - Card { + Card( + onClick = { + onNavigateToRoute.invoke( + DialectDetailRoute( + activeUserPublicKey = activeUserPublicKey, + dialectId = dialect.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { ListItem( leadingContent = { Icon( Icons.Default.Translate, - contentDescription = "Dialect" + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative ) }, headlineContent = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt new file mode 100644 index 00000000..b60f1557 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt @@ -0,0 +1,317 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LibraryBooks +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.country +import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.dialect_detail +import mantra.composeapp.generated.resources.language +import mantra.composeapp.generated.resources.library +import mantra.composeapp.generated.resources.no_artifacts_are_written_in_this_dialect +import mantra.composeapp.generated.resources.no_translations_are_into_this_dialect +import mantra.composeapp.generated.resources.translations +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.DetailRow +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.DialectDetailViewModel +import press.mantra.compose.ui.view.state.DialectDetailUIState + +/** + * A dialect, and what the group has written in it. + * + * The two lists are the screen. A dialect is a name, a language code and a + * country, and those three are at the bottom under the details the way they are + * everywhere else; what a member opens a dialect for is the artifacts written in + * it and the translations into it, which are the only two things in this app that + * point at a dialect at all. + * + * Until this screen the dialect card in `ChatRoomDetailScreen` had no `onClick`, + * which made a dialect the one entity in a room a member could not open. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DialectDetailScreen( + activeUserPublicKey: HexKey, + dialectId: String, + chatRoomId: String, + relayHint: String?, + initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val dialectDetailViewModel: DialectDetailViewModel = viewModel( + factory = DialectDetailViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + dialectId = dialectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialDialectDetailUIState = initialDialectDetailUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + ) + ) + + ScreenStateTransition(dialectDetailViewModel.dialectDetailUIState) { uiState -> + when (val dialectDetailUIState = uiState) { + is DialectDetailUIState.Error -> ErrorState( + message = dialectDetailUIState.message, + // Reloading is worth offering even though the usual cause is a + // dialect this device does not hold: a room's rows arrive on the + // group's schedule, so the second attempt can genuinely find what + // the first could not. + onRetry = { dialectDetailViewModel.initiateDialectDetail() } + ) + + is DialectDetailUIState.Loaded -> { + val dialect = dialectDetailUIState.dialect + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(dialect.name) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.relatedGap + ) + ) { + item { + Text( + text = stringResource(Res.string.library), + style = MaterialTheme.typography.labelMedium + ) + } + + if (dialectDetailUIState.artifacts.isEmpty()) { + item { + EmptyState( + message = stringResource( + Res.string.no_artifacts_are_written_in_this_dialect + ), + icon = Icons.Default.LibraryBooks, + ) + } + } else { + items( + items = dialectDetailUIState.artifacts, + key = { artifact -> artifact.id } + ) { artifact -> + Card( + onClick = { + onNavigateToRoute.invoke( + ArtifactDetailRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.LibraryBooks, + contentDescription = Decorative + ) + }, + headlineContent = { Text(artifact.name) }, + supportingContent = { Text(artifact.url) } + ) + } + } + } + + item { HorizontalDivider() } + + item { + Text( + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium + ) + } + + if (dialectDetailUIState.translations.isEmpty()) { + item { + EmptyState( + message = stringResource( + Res.string.no_translations_are_into_this_dialect + ), + icon = Icons.Default.Translate, + ) + } + } else { + items( + items = dialectDetailUIState.translations, + key = { translation -> translation.id } + ) { translation -> + Card( + onClick = { + onNavigateToRoute.invoke( + TranslationArtifactVersionDetailRoute( + activeUserPublicKey = activeUserPublicKey, + translationArtifactVersionId = translation.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = Decorative + ) + }, + headlineContent = { Text(translation.name) }, + supportingContent = { Text(translation.visibility) } + ) + } + } + } + + item { HorizontalDivider() } + + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + Card { + // The pair that is actually the dialect's + // identity, and the name that is not: nothing in + // the schema makes a dialect unique, so two rooms + // naming `st`-in-Lesotho differently hold the same + // dialect under two names. + DetailRow( + label = stringResource(Res.string.language), + value = dialect.language + ) + DetailRow( + label = stringResource(Res.string.country), + value = dialect.country + ) + } + } + } + } + } + + DialectDetailUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.containerPadding + ) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.dialect_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialDialectDetailUIState == DialectDetailUIState.Loading) { + dialectDetailViewModel.initiateDialectDetail() + } + } +} + +@ConformancePreviews +@Composable +private fun DialectDetailScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + DialectDetailScreen( + activeUserPublicKey = "", + dialectId = "dialectId", + chatRoomId = "chatRoomId", + relayHint = null, + initialDialectDetailUIState = DialectDetailUIState.Loaded( + dialect = MantraDialect( + id = "dialectId", + publicKey = "author", + name = "Sesotho", + country = "Lesotho", + language = "st", + signature = "", + chatRoomId = "chatRoomId", + ) + ), + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + 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 444eaec2..9e1e5728 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 @@ -151,6 +151,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute @@ -165,6 +166,7 @@ import press.mantra.compose.ui.composable.FrostSigningScreen import press.mantra.compose.ui.composable.ProposalListScreen import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen import press.mantra.compose.ui.composable.ChapterDetailScreen +import press.mantra.compose.ui.composable.DialectDetailScreen import press.mantra.compose.ui.composable.TranslateChunkScreen import press.mantra.compose.ui.composable.TranslationChapterScreen import press.mantra.compose.ui.composable.TranslationArtifactVersionDetailScreen @@ -1326,6 +1328,26 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DialectDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + dialectId = route.dialectId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + 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/DialectDetailRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DialectDetailRoute.kt new file mode 100644 index 00000000..d09acaed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DialectDetailRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * A dialect, and what is written in it. + * + * [chatRoomId] rides along although the dialect row already names its room, + * because every sibling detail route carries it and the screens pass it on to + * the routes they open. Reading it off the row instead would make this the one + * route that cannot be navigated to before its entity has loaded. + */ +@Serializable +data class DialectDetailRoute( + val activeUserPublicKey: String, + val dialectId: String, + val chatRoomId: String, + val relayHint: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt new file mode 100644 index 00000000..c5e1b6b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt @@ -0,0 +1,99 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.view.state.DialectDetailUIState + +/** + * A dialect, what is written in it, and whether it could have a room of its own. + * + * The two lists are the reason this screen exists: a dialect is a name, a + * language code and a country, and the thing a member opens one to find out is + * what the group has written in it. Both are read by the dialect's id alone -- + * see `MantraArtifactDao.getArtifactsByDialectId` for why no room is named + * beside it. + */ +class DialectDetailViewModel( + val dialectId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialDialectDetailUIState: DialectDetailUIState, + val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, +): ViewModel() { + + var dialectDetailUIState: DialectDetailUIState by mutableStateOf(initialDialectDetailUIState) + private set + + private val logger = Logger.withTag(TAG) + + fun initiateDialectDetail() { + logger.d("initiateDialectDetail: $dialectId") + viewModelScope.launch(Dispatchers.IO) { + val dialect = mantraRepository.getDialect(dialectId) + + dialectDetailUIState = if (dialect == null) { + // Error rather than an empty `Loaded`, because the two are + // different answers: "this room has written nothing in Sesotho" + // and "there is no such dialect here" would otherwise look + // identical, and only one of them is worth retrying. + DialectDetailUIState.Error("Couldn't find the dialect") + } else { + // The dialect's own room, not the route's. They are the same + // room in every path that exists today, and reading it off the + // row is what keeps that true if a route is ever constructed by + // hand -- the parent of a subgroup made here must be the room + // the dialect actually lives in. + val room = chatRepository.getChatRoomByIdentifier(dialect.chatRoomId) + + DialectDetailUIState.Loaded( + dialect = dialect, + artifacts = mantraRepository.getArtifactsForDialect(dialectId), + translations = mantraRepository.getTranslationsForDialect(dialectId), + canAddSubgroup = room?.chatRoom?.mlsGroupState != null && + chatRepository.canSign(dialect.chatRoomId), + ) + } + } + } + + companion object { + private const val TAG = "DialectDetailViewModel" + + fun factory( + activeUserPublicKey: HexKey, + dialectId: String, + chatRoomId: String, + relayHint: String?, + initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + DialectDetailViewModel( + activeUserPublicKey = activeUserPublicKey, + dialectId = dialectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialDialectDetailUIState = initialDialectDetailUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt new file mode 100644 index 00000000..0c5c3adf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion + +sealed interface DialectDetailUIState { + data class Loaded( + val dialect: MantraDialect, + + /** + * The artifacts written in this dialect. + * + * This and [translations] are what the screen is for. A dialect on its + * own is three strings -- a name, a language code and a country -- and a + * screen showing only those has no reason to exist. These are the two + * tables that carry a `dialectId`, and between them they answer the only + * question a member opens a dialect to ask. + */ + val artifacts: List = emptyList(), + + /** The translations into this dialect. */ + val translations: List = emptyList(), + + /** + * Whether this device could open a subgroup for this dialect at all. + * + * Two conditions, both about capability rather than permission, and the + * same two `ChatRoomDetailUIState.canAddSubgroup` states: the room has to + * be a Marmot one, since a NIP-17 room has no admins to be a parent's, + * and this device has to hold a share of the group's key, because + * certifying a child is a signature by the parent and + * `FrostSigningManager.proposeSigningBatch` throws for a device that + * cannot make one. + * + * The rest of the refusals are the picker's, where they can name who is + * missing and what to do about it. + */ + val canAddSubgroup: Boolean = false, + ): DialectDetailUIState + + data class Error( + val message: String + ): DialectDetailUIState + + data object Loading: DialectDetailUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt new file mode 100644 index 00000000..9418b1b9 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DialectDetailJvmTest.kt @@ -0,0 +1,203 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.ui.view.state.DialectDetailUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What a dialect's screen is looking at, and what it leaves out. + * + * The lists are read by the dialect's id alone -- no room is named beside it, + * because `dialectId` is a foreign key into this room's own rows and a second + * filter would restate a constraint the key already enforces. So the case worth + * having is a room holding *two* dialects: if the query ever drifted to scoping + * by room instead, every test with one dialect in it would still pass and this + * one would not. + * + * The other half is that a missing dialect is an error rather than an empty + * screen. "This room has written nothing in Sesotho" and "there is no such + * dialect here" would otherwise look identical, and only one of them is worth + * offering a retry for. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DialectDetailJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + private val otherDialectId = "9".repeat(64) + private val unusedDialectId = "d".repeat(64) + + private fun viewModel(dialectId: String = room.dialectId) = DialectDetailViewModel( + dialectId = dialectId, + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + relayHint = null, + initialDialectDetailUIState = DialectDetailUIState.Loading, + mantraRepository = DatabaseMantraRepository(db, scope), + chatRepository = DatabaseChatRepository(db, scope), + ) + + private suspend fun loaded(dialectId: String = room.dialectId): DialectDetailUIState.Loaded { + val viewModel = viewModel(dialectId) + viewModel.initiateDialectDetail() + return awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Loaded } + } + + /** A second dialect in the same room, with its own artifact and translation. */ + private suspend fun seedSecondDialect() { + db.mantraDialectDao().upsert( + MantraDialect( + id = otherDialectId, + publicKey = room.roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = room.signature, + chatRoomId = room.roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = "8".repeat(64), + publicKey = room.roomId, + name = "Another book", + url = "https://example.invalid/another", + visibility = "private", + dialectId = otherDialectId, + license = "cc", + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = "4".repeat(64), + publicKey = room.roomId, + artifactVersionId = room.versionId, + dialectId = otherDialectId, + name = "Setswana Mockingbird", + visibility = "private", + license = "cc", + signature = room.signature, + chatRoomId = room.roomId, + ) + ) + } + + @Test + fun `the dialect loads with what is written in it`() = runBlocking { + room.seed() + + val state = loaded() + + assertEquals("Sesotho", state.dialect.name) + assertEquals("st", state.dialect.language) + assertEquals(listOf(room.artifactId), state.artifacts.map { it.id }) + assertEquals(listOf(room.translationId), state.translations.map { it.id }) + } + + /** + * The case that fails if the queries ever scope by room instead of by + * dialect. Both dialects are in one room, so a room-scoped read returns all + * four rows and reads as a working screen. + */ + @Test + fun `another dialect in the same room is left out`() = runBlocking { + room.seed() + seedSecondDialect() + + val sesotho = loaded() + + assertEquals(listOf(room.artifactId), sesotho.artifacts.map { it.id }) + assertEquals(listOf(room.translationId), sesotho.translations.map { it.id }) + + val setswana = loaded(otherDialectId) + + assertEquals(listOf("8".repeat(64)), setswana.artifacts.map { it.id }) + assertEquals(listOf("4".repeat(64)), setswana.translations.map { it.id }) + } + + /** + * The empty state, which is a normal state and not a failure: a room defines + * a dialect before it has anything in it, and that is the whole point of + * being able to define one. + */ + @Test + fun `a dialect nothing is written in loads empty rather than failing`() = runBlocking { + room.seed() + db.mantraDialectDao().upsert( + MantraDialect( + id = unusedDialectId, + publicKey = room.roomId, + name = "isiZulu", + country = "ZA", + language = "zu", + signature = room.signature, + chatRoomId = room.roomId, + ) + ) + + val state = loaded(unusedDialectId) + + assertEquals("isiZulu", state.dialect.name) + assertTrue(state.artifacts.isEmpty()) + assertTrue(state.translations.isEmpty()) + } + + @Test + fun `a dialect this device does not hold is an error, not an empty screen`() = runBlocking { + room.seed() + + val viewModel = viewModel(dialectId = "c".repeat(64)) + viewModel.initiateDialectDetail() + + val state = awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Error } + + assertEquals("Couldn't find the dialect", state.message) + } + + /** + * The fixture's room has no MLS state, so no subgroup can be made from it -- + * a NIP-17 room has no admins to be a parent's. The flag says so instead of + * the button failing when pressed. + */ + @Test + fun `a room with no group state cannot have a subgroup made for its dialect`() = runBlocking { + room.seed() + + assertFalse(loaded().canAddSubgroup) + } +} From 2c3c949b86f3a68d94791be182a67d7fd71b3efc Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:27:47 +0200 Subject: [PATCH 22/28] feat(subgroups): ask for a room from the thing that needs one Four buttons, one per entity, each opening the existing picker with the name already in it. This is the cheap version of the feature complete: a member looking at a translation can give it a room without backing out to the group, finding "Add subgroup" and retyping a name the app already knew. `SelectSubgroupAdminsRoute` grows three nullable fields. The name is a suggestion and stays editable -- a group renaming its own room is not a route's business to prevent -- and it is seeded in the view model's constructor rather than in `initiate`, because a field somebody may already be typing into must not be overwritten by a load completing underneath them. The origin is two flat fields rather than one type, because a route is `@Serializable` and is rebuilt from a bundle. The nav host reassembles them and requires both or neither: half a reference is a reference with the resolvable part removed. The view model then only holds it. Riding it onto the ceremony needs a `DkgSession` column and a tag on the proposal, which is Phase 5, and the comment at the `proposeRitual` call says so rather than leaving a parameter that looks forgotten. The gate is `canAddSubgroup` on all four states and not `canSign`: a share of the key *and* `mlsGroupState != null`, because a NIP-17 room has no admins to be a parent's and these entities can live in one. Three states had to learn it. `ProjectDetailUIState` and `TranslationArtifactVersionDetailUIState` already carried `canSign`; `ArtifactDetailUIState` carried no capability flag at all, so the artifact screen and its view model gained a `ChatRepository` as well -- the one place this phase is more than a button. Each flag is computed off `SubgroupOrigin.of(entity).parentChatRoomId` rather than the route's `chatRoomId`. They are the same room in every path that exists, and reading it through the function is what keeps the parent of a subgroup the room the entity actually lives in if a route is ever built by hand. Hidden rather than disabled where it cannot work, which is what the room's own subgroup entry does and for the same reason: a device with no share cannot open the certificate session at all, and `proposeSigningBatch` throws rather than refusing. Phase 3 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 1 + .../ui/composable/ArtifactDetailScreen.kt | 51 +++++++++++++++++++ .../ui/composable/DialectDetailScreen.kt | 48 +++++++++++++++++ .../ui/composable/ProjectDetailScreen.kt | 50 ++++++++++++++++++ .../composable/SelectSubgroupAdminsScreen.kt | 7 +++ .../TranslationArtifactVersionDetailScreen.kt | 48 +++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 10 ++++ .../routes/SelectSubgroupAdminsRoute.kt | 26 +++++++++- .../ui/view/model/ArtifactDetailViewModel.kt | 24 +++++++++ .../ui/view/model/ProjectDetailViewModel.kt | 24 +++++++++ .../model/SelectSubgroupAdminsViewModel.kt | 42 +++++++++++++-- ...anslationArtifactVersionDetailViewModel.kt | 20 ++++++++ .../ui/view/state/ArtifactDetailUIState.kt | 16 ++++++ .../ui/view/state/ProjectDetailUIState.kt | 9 ++++ ...TranslationArtifactVersionDetailUIState.kt | 8 +++ 15 files changed, 380 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b52173d7..cf280ebb 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,6 +32,7 @@ Add chapter Add dialect Add to group + Give this a room of its own Add translation After this there is no turning back. All broadcasts are queued so that we can manage data usage on metered connections. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 06c128fd..f0150cb7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.Translate @@ -35,7 +36,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.DetailRow import androidx.compose.material.icons.filled.UploadFile import mantra.composeapp.generated.resources.upload_chapters @@ -44,6 +48,7 @@ import press.mantra.compose.ui.composable.navigation.routes.UploadChaptersRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.MantraTheme @@ -57,6 +62,7 @@ import mantra.composeapp.generated.resources.add_translation import mantra.composeapp.generated.resources.artifact_detail import mantra.composeapp.generated.resources.chapters import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.give_this_a_room_of_its_own import mantra.composeapp.generated.resources.no_chapters_yet import mantra.composeapp.generated.resources.no_translations_yet import mantra.composeapp.generated.resources.no_versions @@ -81,6 +87,7 @@ fun ArtifactDetailScreen( relayHint: String?, initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -92,6 +99,7 @@ fun ArtifactDetailScreen( relayHint = relayHint, initialArtifactDetailUIState = initialArtifactDetailUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, ) ) @@ -291,6 +299,48 @@ fun ArtifactDetailScreen( item { HorizontalDivider() } + item { + // Hidden rather than disabled where it cannot work, + // the way the room's own subgroup entry is: a device + // with no share of the key cannot open the + // certificate session at all, and a NIP-17 room has + // no admins to be a parent's. + if (artifactDetailUIState.canAddSubgroup) { + TextButton( + onClick = { + val candidate = SubgroupOrigin.of(artifact) + + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = candidate.parentChatRoomId, + relayHint = relayHint, + name = candidate.name, + originKind = candidate.origin.kind, + originId = candidate.origin.id, + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.itemGap + ) + ) + Text( + stringResource( + Res.string.give_this_a_room_of_its_own + ) + ) + } + } + } + item { HorizontalDivider() } + // Details item { Text( @@ -386,6 +436,7 @@ private fun ArtifactDetailScreenPreview() { ) ), mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, onNavigateToRoute = {}, onNavigateBack = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt index b60f1557..c636103e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt @@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.Card @@ -21,6 +23,7 @@ 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 @@ -33,6 +36,7 @@ import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.country import mantra.composeapp.generated.resources.details import mantra.composeapp.generated.resources.dialect_detail +import mantra.composeapp.generated.resources.give_this_a_room_of_its_own import mantra.composeapp.generated.resources.language import mantra.composeapp.generated.resources.library import mantra.composeapp.generated.resources.no_artifacts_are_written_in_this_dialect @@ -40,10 +44,12 @@ import mantra.composeapp.generated.resources.no_translations_are_into_this_diale import mantra.composeapp.generated.resources.translations import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.DetailRow @@ -228,6 +234,48 @@ fun DialectDetailScreen( item { HorizontalDivider() } + item { + // Hidden rather than disabled where it cannot work, + // the way the room's own subgroup entry is: a device + // with no share of the key cannot open the + // certificate session at all, and a NIP-17 room has + // no admins to be a parent's. + if (dialectDetailUIState.canAddSubgroup) { + TextButton( + onClick = { + val candidate = SubgroupOrigin.of(dialect) + + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = candidate.parentChatRoomId, + relayHint = relayHint, + name = candidate.name, + originKind = candidate.origin.kind, + originId = candidate.origin.id, + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.itemGap + ) + ) + Text( + stringResource( + Res.string.give_this_a_room_of_its_own + ) + ) + } + } + } + item { HorizontalDivider() } + item { Text( text = stringResource(Res.string.details), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt index 6f03a595..e39f5838 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.Translate @@ -38,6 +39,7 @@ import mantra.composeapp.generated.resources.add_source_version_to_project import mantra.composeapp.generated.resources.add_translation_to_project import mantra.composeapp.generated.resources.created_by import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.give_this_a_room_of_its_own import mantra.composeapp.generated.resources.no_source_versions_in_this_project import mantra.composeapp.generated.resources.no_translations_in_this_project import mantra.composeapp.generated.resources.owned_by_this_group @@ -49,11 +51,14 @@ import mantra.composeapp.generated.resources.version import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.Profile import press.mantra.compose.extensions.shortened +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.AddToProjectRoute import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.DetailRow @@ -87,6 +92,7 @@ fun ProjectDetailScreen( mantraRepository: MantraRepository, nostrRepository: NostrRepository, frostSigningRepository: FrostSigningRepository, + chatRepository: ChatRepository, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -100,6 +106,7 @@ fun ProjectDetailScreen( mantraRepository = mantraRepository, nostrRepository = nostrRepository, frostSigningRepository = frostSigningRepository, + chatRepository = chatRepository, ) ) @@ -258,6 +265,48 @@ fun ProjectDetailScreen( item { HorizontalDivider() } + item { + // Hidden rather than disabled where it cannot work, + // the way the room's own subgroup entry is: a device + // with no share of the key cannot open the + // certificate session at all, and a NIP-17 room has + // no admins to be a parent's. + if (projectDetailUIState.canAddSubgroup) { + TextButton( + onClick = { + val candidate = SubgroupOrigin.of(project) + + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = candidate.parentChatRoomId, + relayHint = relayHint, + name = candidate.name, + originKind = candidate.origin.kind, + originId = candidate.origin.id, + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.itemGap + ) + ) + Text( + stringResource( + Res.string.give_this_a_room_of_its_own + ) + ) + } + } + } + item { HorizontalDivider() } + // Who it belongs to item { Text( @@ -359,6 +408,7 @@ private fun ProjectDetailScreenPreview() { mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, onNavigateToRoute = {}, onNavigateBack = {}, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index a104bc1e..2500436b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -53,6 +53,7 @@ import org.jetbrains.compose.resources.stringResource import press.mantra.compose.managers.MarmotGroupName import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.ErrorState @@ -85,6 +86,10 @@ fun SelectSubgroupAdminsScreen( activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, + /** A name to open the field with, where the caller knows one. */ + suggestedName: String? = null, + /** What the subgroup is for, where it was asked for from an entity. */ + origin: SubgroupOrigin.Ref? = null, onNavigateToRoute: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -94,6 +99,8 @@ fun SelectSubgroupAdminsScreen( parentChatRoomId = parentChatRoomId, initialUIState = initialUIState, activeIdentityStateFlow = activeIdentityStateFlow, + suggestedName = suggestedName, + origin = origin, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 732a9f32..65ccf4c0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material3.Card @@ -32,12 +34,15 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.managers.SubgroupOrigin 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.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute +import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.DetailRow import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.MantraTheme @@ -48,6 +53,7 @@ import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.chapters import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.give_this_a_room_of_its_own import mantra.composeapp.generated.resources.no_chapters import mantra.composeapp.generated.resources.propose import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot @@ -228,6 +234,48 @@ fun TranslationArtifactVersionDetailScreen( item { HorizontalDivider() } + item { + // Hidden rather than disabled where it cannot work, + // the way the room's own subgroup entry is: a device + // with no share of the key cannot open the + // certificate session at all, and a NIP-17 room has + // no admins to be a parent's. + if (translationDetailUIState.canAddSubgroup) { + TextButton( + onClick = { + val candidate = SubgroupOrigin.of(translation) + + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = candidate.parentChatRoomId, + relayHint = relayHint, + name = candidate.name, + originKind = candidate.origin.kind, + originId = candidate.origin.id, + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.itemGap + ) + ) + Text( + stringResource( + Res.string.give_this_a_room_of_its_own + ) + ) + } + } + } + item { HorizontalDivider() } + // Details item { Text( 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 9e1e5728..7ffcc252 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 @@ -31,6 +31,7 @@ import press.mantra.compose.database.repository.DatabaseMarmotRepository import press.mantra.compose.database.repository.DatabaseNostrRepository import press.mantra.compose.database.repository.DatabaseSearchRepository import press.mantra.compose.managers.AuxDatabaseManager +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.ui.composable.ActiveProfileScreen import press.mantra.compose.ui.composable.AddMemberToChatRoomConfirmationScreen import press.mantra.compose.ui.composable.ChatRoomCreationScreen @@ -605,6 +606,13 @@ fun MantraNavHost( activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, + suggestedName = route.name, + // Both or neither. A route carrying one half of a reference + // is a reference with the resolvable part missing, and + // reassembling it here is the one place that can say so. + origin = route.originKind?.let { kind -> + route.originId?.let { id -> SubgroupOrigin.Ref(kind, id) } + }, onNavigateToRoute = { nextRoute -> navController.navigate(route = nextRoute) }, @@ -1318,6 +1326,7 @@ fun MantraNavHost( chatRoomId = route.chatRoomId, relayHint = route.relayHint, mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, onNavigateToRoute = { actionRoute -> navController.navigate( route = actionRoute @@ -1359,6 +1368,7 @@ fun MantraNavHost( mantraRepository = databaseMantraRepository, nostrRepository = databaseNostrRepository, frostSigningRepository = databaseFrostSigningRepository, + chatRepository = databaseChatRepository, onNavigateToRoute = { actionRoute -> navController.navigate( route = actionRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt index 939abe8e..533cce36 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt @@ -16,5 +16,29 @@ import kotlinx.serialization.Serializable data class SelectSubgroupAdminsRoute( val activeUserPublicKey: String, val parentChatRoomId: String, - val relayHint: String? = null + val relayHint: String? = null, + + /** + * A name to open the field with, where the caller knows one. + * + * Set when the subgroup was asked for from a dialect, an artifact, a + * translation or a project -- the entity's own name, bare, with no `#`; see + * `SubgroupOrigin`. It is a suggestion and stays editable: a group naming its + * own room something else is not this route's business to prevent. + * + * Null from the room's own "Add subgroup", which knows nothing to suggest. + */ + val name: String? = null, + + /** + * The entity the subgroup is for, flattened: a kind and an event id. + * + * Two nullable fields rather than one type, because a route carries no + * domain types -- it is `@Serializable` and is reconstructed from a bundle. + * They are reassembled into a `SubgroupOrigin.Ref` by the view model, which + * requires both or neither: half a reference is a reference with the + * resolvable part removed. + */ + val originKind: Int? = null, + val originId: String? = null, ): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt index bc1086a9..339f469a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt @@ -13,6 +13,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import press.mantra.compose.database.model.traits.SubgroupOriginEntity +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.view.state.ArtifactDetailUIState @@ -23,6 +26,7 @@ class ArtifactDetailViewModel( val relayHint: String?, initialArtifactDetailUIState: ArtifactDetailUIState, val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, ): ViewModel() { var artifactDetailUIState: ArtifactDetailUIState by mutableStateOf(initialArtifactDetailUIState) @@ -43,11 +47,29 @@ class ArtifactDetailViewModel( versions = mantraRepository.getArtifactVersions(artifactId), chapters = mantraRepository.getChaptersForArtifact(artifactId), translations = mantraRepository.getTranslationsForArtifact(artifactId), + canAddSubgroup = canAddSubgroupFor(artifact), ) } } } + + /** + * Whether a subgroup could be opened for [entity] at all. + * + * The two conditions `ChatRoomDetailUIState.canAddSubgroup` states, read off + * the entity's *own* room rather than the route's: the parent of a subgroup + * made here is the room the entity actually lives in, and + * `SubgroupOrigin.of` is the one place that says which room that is. + */ + private suspend fun canAddSubgroupFor(entity: SubgroupOriginEntity): Boolean { + val parentChatRoomId = SubgroupOrigin.of(entity).parentChatRoomId + val room = chatRepository.getChatRoomByIdentifier(parentChatRoomId) + + return room?.chatRoom?.mlsGroupState != null && + chatRepository.canSign(parentChatRoomId) + } + companion object { private const val TAG = "ArtifactDetailViewModel" @@ -58,6 +80,7 @@ class ArtifactDetailViewModel( relayHint: String?, initialArtifactDetailUIState: ArtifactDetailUIState = ArtifactDetailUIState.Loading, mantraRepository: MantraRepository, + chatRepository: ChatRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { ArtifactDetailViewModel( @@ -67,6 +90,7 @@ class ArtifactDetailViewModel( relayHint = relayHint, initialArtifactDetailUIState = initialArtifactDetailUIState, mantraRepository = mantraRepository, + chatRepository = chatRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt index 616a2cf3..f386d59f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt @@ -15,6 +15,9 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.Profile import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.database.model.traits.SubgroupOriginEntity +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.ProjectDetailUIState @@ -28,6 +31,7 @@ class ProjectDetailViewModel( val mantraRepository: MantraRepository, val nostrRepository: NostrRepository, val frostSigningRepository: FrostSigningRepository, + val chatRepository: ChatRepository, ): ViewModel() { var projectDetailUIState: ProjectDetailUIState by mutableStateOf(initialProjectDetailUIState) @@ -54,11 +58,29 @@ class ProjectDetailViewModel( project.ownerPublicKeySet() + project.creatorPublicKey ), canSign = frostSigningRepository.canSign(chatRoomId), + canAddSubgroup = canAddSubgroupFor(project), ) } } } + + /** + * Whether a subgroup could be opened for [entity] at all. + * + * The two conditions `ChatRoomDetailUIState.canAddSubgroup` states, read off + * the entity's *own* room rather than the route's: the parent of a subgroup + * made here is the room the entity actually lives in, and + * `SubgroupOrigin.of` is the one place that says which room that is. + */ + private suspend fun canAddSubgroupFor(entity: SubgroupOriginEntity): Boolean { + val parentChatRoomId = SubgroupOrigin.of(entity).parentChatRoomId + val room = chatRepository.getChatRoomByIdentifier(parentChatRoomId) + + return room?.chatRoom?.mlsGroupState != null && + chatRepository.canSign(parentChatRoomId) + } + private suspend fun peopleBehind(publicKeys: Set): Map = publicKeys.mapNotNull { publicKey -> nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it } @@ -76,6 +98,7 @@ class ProjectDetailViewModel( mantraRepository: MantraRepository, nostrRepository: NostrRepository, frostSigningRepository: FrostSigningRepository, + chatRepository: ChatRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { ProjectDetailViewModel( @@ -87,6 +110,7 @@ class ProjectDetailViewModel( mantraRepository = mantraRepository, nostrRepository = nostrRepository, frostSigningRepository = frostSigningRepository, + chatRepository = chatRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index e4ce6ff2..52a1b464 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute @@ -53,6 +54,23 @@ class SelectSubgroupAdminsViewModel( val activeIdentityStateFlow: StateFlow, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, + /** + * A name to open the field with, or null when the caller has nothing to + * suggest. + * + * Bare, with no `#` -- see [name] for where the convention is applied. + */ + val suggestedName: String? = null, + /** + * What this subgroup is being made for, when it was asked for from a dialect, + * an artifact, a translation or a project. + * + * Held from here until [confirm] rides it onto the ceremony, which is the + * only thing this class does with it: the picker neither reads the entity nor + * checks that it exists. A room's own "Add subgroup" passes null and the + * subgroup is for nothing in particular, which is a normal subgroup. + */ + val origin: SubgroupOrigin.Ref? = null, ): ViewModel() { var uiState: SelectSubgroupAdminsUIState by mutableStateOf(initialUIState) @@ -73,8 +91,13 @@ class SelectSubgroupAdminsViewModel( * halfway through a word. It is added by `MarmotGroupName.of` where the * subgroup's name is actually used -- in the certificate the parent signs and * on the room itself -- which is idempotent, so the two agree. + * + * Seeded from [suggestedName] at construction rather than in [initiate], + * because a field the user may already be typing into must not be + * overwritten by a load completing underneath them. It stays editable after + * that: a pre-filled name is a suggestion, not a decision. */ - val name: MutableState = mutableStateOf("") + val name: MutableState = mutableStateOf(suggestedName?.trim().orEmpty()) val threshold: MutableState = mutableStateOf(ChatRoomType.MINIMUM_QUORUM) @@ -246,6 +269,15 @@ class SelectSubgroupAdminsViewModel( // being in it, and is not theirs to tick. participantPublicKeys = selectedPublicKeys.toSet(), subject = name.value.trim() + // [origin] is not on the ceremony yet. It will be, and for the + // same reason the name is: the certificate is proposed after the + // ceremony, possibly by an admin who was never on this screen, so + // anything it needs has to be on the stored session rather than + // in this lambda. That needs a `DkgSession` column and a tag on + // the proposal, which is Phase 5 of + // docs/subgroups-for-entities.md. Until then a subgroup made from + // an entity is named after it and nothing more, which is exactly + // the cheap version of this feature. ) isActionPending.value = false @@ -293,7 +325,9 @@ class SelectSubgroupAdminsViewModel( initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, - dkgRepository: DkgRepository + dkgRepository: DkgRepository, + suggestedName: String? = null, + origin: SubgroupOrigin.Ref? = null ): ViewModelProvider.Factory = viewModelFactory { initializer { SelectSubgroupAdminsViewModel( @@ -302,7 +336,9 @@ class SelectSubgroupAdminsViewModel( initialUIState = initialUIState, activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, - dkgRepository = dkgRepository + dkgRepository = dkgRepository, + suggestedName = suggestedName, + origin = origin ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt index d959e224..293a0bda 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt @@ -17,7 +17,9 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.traits.SubgroupOriginEntity import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.managers.TranslationScaffold import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository @@ -71,11 +73,29 @@ class TranslationArtifactVersionDetailViewModel( .getChaptersForArtifactVersion(translation.artifactVersionId), ), canSign = frostSigningRepository.canSign(chatRoomId), + canAddSubgroup = canAddSubgroupFor(translation), ) } } } + + /** + * Whether a subgroup could be opened for [entity] at all. + * + * The two conditions `ChatRoomDetailUIState.canAddSubgroup` states, read off + * the entity's *own* room rather than the route's: the parent of a subgroup + * made here is the room the entity actually lives in, and + * `SubgroupOrigin.of` is the one place that says which room that is. + */ + private suspend fun canAddSubgroupFor(entity: SubgroupOriginEntity): Boolean { + val parentChatRoomId = SubgroupOrigin.of(entity).parentChatRoomId + val room = chatRepository.getChatRoomByIdentifier(parentChatRoomId) + + return room?.chatRoom?.mlsGroupState != null && + chatRepository.canSign(parentChatRoomId) + } + /** * Asks the group to put the chapters this translation is missing into it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt index f390abf9..6fd75a0c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt @@ -11,6 +11,22 @@ sealed interface ArtifactDetailUIState { val versions: List = emptyList(), val chapters: List = emptyList(), val translations: List = emptyList(), + + /** + * Whether this device could open a subgroup for this artifact at all. + * + * The same two conditions `ChatRoomDetailUIState.canAddSubgroup` states, + * and both are about capability rather than permission: the room has to + * be a Marmot one, since a NIP-17 room has no admins to be a parent's, + * and this device has to hold a share of the group's key, because + * certifying a child is a signature by the parent and + * `FrostSigningManager.proposeSigningBatch` throws for a device that + * cannot make one. + * + * The first flag on this state, which is why it arrives with the + * subgroup button rather than before it. + */ + val canAddSubgroup: Boolean = false, ): ArtifactDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt index bb21e6c2..c2c228b8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt @@ -31,6 +31,15 @@ sealed interface ProjectDetailUIState { * pressed. */ val canSign: Boolean = false, + + /** + * Whether this device could open a subgroup for this project at all. + * + * [canSign] and a Marmot room, which is the same pair + * `ChatRoomDetailUIState.canAddSubgroup` states. Held separately rather + * than derived at the call site so there is one spelling of one rule. + */ + val canAddSubgroup: Boolean = false, ): ProjectDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt index af891f2c..05896461 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt @@ -38,6 +38,14 @@ sealed interface TranslationArtifactVersionDetailUIState { /** Whether the group holds a shared key, without which nothing can be proposed. */ val canSign: Boolean = false, + + /** + * Whether this device could open a subgroup for this translation at all. + * + * [canSign] and a Marmot room, the same pair + * `ChatRoomDetailUIState.canAddSubgroup` states. + */ + val canAddSubgroup: Boolean = false, ): TranslationArtifactVersionDetailUIState data class Error( From 8d1e341131533f570e6dc66759925e333e94b3e4 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:30:35 +0200 Subject: [PATCH 23/28] feat(subgroups): a certificate can say what the room is for One optional tag -- `["subgroupFor", "", ""]` -- and it is the whole of the link between a subgroup and the dialect, artifact, translation or project it was made for. The certificate is the only artefact in the relationship that is signed, travels, and is already read on both sides, so the link goes there rather than in a local column nobody else can see or a room name that is not an identity. The signature already covers the tags, since an event id hashes over them, so nothing is added to the claim by putting it there -- only to what a parent admin can see before agreeing. That is the same bargain `SubgroupKeyTag` made. Both halves or neither. An id alone would have to be looked up in four tables to be understood, and a reader without the kind cannot tell "a project this device does not hold" from "not a project at all"; a kind alone names a category. The pair follows `PayloadKindTag`/`PayloadIdTag`, which carry the same two for the same reason. The tag sits after the named tags and before the run of `p` tags, and the position is load bearing rather than incidental: a reader that predates this takes the tags it knows off the front and ignores the rest, while one inserted into the admin run would split the founding roster for anybody reading positionally. `SubgroupLineage` fixes its order for the same kind of reason, and a test asserts the position rather than trusting the comment. `certifies` is untouched, and that is the point of the third test. The origin joins the name and the `p` tags as founding history: a certificate may name a row this device will never hold, because a subgroup's members need never have been in the parent, and a certificate with no origin at all is valid because every one signed before today has none. What the quorum signs is the intention that the room be for that entity; nothing vouches that the entity exists, and nothing can. Phase 4 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../subgroup/SubgroupBirthCertificateEvent.kt | 39 ++++- .../nostr/subgroup/tags/SubgroupOriginTag.kt | 79 ++++++++++ .../SubgroupBirthCertificateEventTest.kt | 141 ++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupOriginTag.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt index 0554151d..8daa315d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt @@ -8,7 +8,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.nostr.subgroup.tags.SubgroupKeyTag +import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag /** @@ -59,6 +61,14 @@ import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag * somebody joined the child, and the rejection would look exactly like a * forgery. They are shown as history and are never a live roster. * + * The origin -- what the subgroup was made *for* -- is in the same position and + * is checked even less. A certificate may name a dialect, artifact, translation + * or project that this device does not hold and never will, because a subgroup's + * members need never have been in the parent; and a certificate with no origin at + * all is valid, which it has to be, since every one signed before the tag existed + * has none. What the parent's quorum signs is the *intention* that the room be + * for that entity. Nothing vouches that the entity exists, and nothing can. + * * ### Replaceable, by this app rather than by a relay * * Like every kind in this family it only ever exists inside an encryption a @@ -95,13 +105,22 @@ object SubgroupBirthCertificateEvent { thresholdPublicKey: HexKey, adminPublicKeys: List, name: String, - path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, + origin: SubgroupOrigin.Ref? = null ): Array> = arrayOf( DTag.assemble(subgroupChatRoomId), SubgroupParentTag.assemble(parentChatRoomId), SubgroupKeyTag.assemble(thresholdPublicKey), FrostDerivationPathTag.assemble(path), arrayOf(NAME_TAG_NAME, name) + ) + listOfNotNull( + // After the named tags and *before* the run of `p` tags, and the + // position is fixed rather than incidental. A reader that predates this + // takes the tags it knows off the front and ignores the rest; one + // inserted into the admin run would split the founding roster for + // anybody reading it positionally. The same reasoning fixes the order of + // a lineage's pairs in `SubgroupLineage`. + origin?.let { SubgroupOriginTag.assemble(kind = it.kind, id = it.id) } ) + adminPublicKeys.distinct().map { PTag.assemble(it, null) } /** The child this certificate is about, or null if it names none. */ @@ -135,6 +154,24 @@ object SubgroupBirthCertificateEvent { fun parseName(tags: Array>): String? = tags.firstOrNull { it.size > 1 && it[0] == NAME_TAG_NAME }?.get(1)?.ifBlank { null } + /** + * The dialect, artifact, translation or project this subgroup was made for, + * or null when it was made for nothing in particular. + * + * Null is the normal answer for two unrelated reasons and neither is a + * failure: a subgroup started from a group's own "Add subgroup" is for no + * entity, and every certificate signed before this tag existed carries none. + * Null for a *malformed* origin too -- see `SubgroupOriginTag.parse` -- since + * half a reference is not a weaker reference. + * + * Never consulted by [certifies]. See the class comment on the founding + * roster: a certificate is signed once, and nothing on it is compared against + * anything current. + */ + fun parseOrigin(tags: Array>): SubgroupOrigin.Ref? = + tags.firstNotNullOfOrNull(SubgroupOriginTag::parse) + ?.let { SubgroupOrigin.Ref(kind = it.kind, id = it.id) } + /** * Whether [event] is a certificate of [subgroupChatRoomId] that * [parentChatRoomId] actually signed. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupOriginTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupOriginTag.kt new file mode 100644 index 00000000..1854660c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/tags/SubgroupOriginTag.kt @@ -0,0 +1,79 @@ +package press.mantra.compose.nostr.subgroup.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * What a subgroup was made for: a kind, and the id of the event. + * + * A group's room holds dialects, artifacts, translations and projects, and a + * subgroup is often made for exactly one of them -- the room where that + * translation gets worked on. This is the tag that says so, and it is the only + * place the link exists: a room's name is not an identity, and a local column + * would be a fact one device holds. + * + * ### Both halves or neither + * + * [parse] refuses a tag with only one value. An id alone would have to be looked + * up in four tables to be understood, and a reader without the kind cannot tell + * "a project this device does not hold" from "not a project at all". A kind + * alone names a category rather than a thing. `SubgroupOrigin.Ref` has no + * constructor for half of this either, which is the same rule stated where a + * caller would otherwise assemble one by hand. + * + * The pair, and the order, follow `PayloadKindTag`/`PayloadIdTag`: a client + * deciding what it is looking at before parsing the thing itself. + * + * ### It authenticates nothing about the entity + * + * On a certificate the parent's quorum is signing that *it intends* the room to + * be for that entity. It is not vouching that the entity exists, that this + * device can resolve it, or that the entity's own signature is good -- which is + * the limit `docs/project-feature.md` states for a cross-room project + * membership, and it is the right limit here for the same reason. A subgroup's + * members need never have been in the parent, so a certificate naming a row they + * will never hold is the normal case rather than an error. + * + * On a ritual proposal it authenticates even less: anybody can put any id there. + * See `DkgRitualEvents.parseOrigin`. + * + * ### `certifies` never looks at it + * + * Deliberately. The name and the `p` tags on a certificate are the *founding* + * roster -- signed once, never compared against anything current -- and the + * origin joins them. A certificate with no origin at all is valid, which it has + * to be: every certificate signed before this feature has none. + */ +class SubgroupOriginTag( + val kind: Kind, + val id: HexKey, +) { + fun toTagArray() = assemble(kind = kind, id = id) + + companion object { + const val TAG_NAME = "subgroupFor" + + fun parse(tag: Array): SubgroupOriginTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + val kind = tag[1].toIntOrNull() ?: return null + + // The length check the nip30303 id tags make. An id of the wrong + // shape is not a thing to carry forward in the hope that something + // downstream refuses it -- nothing downstream would, since nothing + // resolves an origin before showing it. + ensure(tag[2].length == 64) { return null } + + return SubgroupOriginTag(kind = kind, id = tag[2]) + } + + fun assemble(kind: Kind, id: HexKey): Array = + arrayOf(TAG_NAME, kind.toString(), id) + + fun assemble(subgroupOriginTag: SubgroupOriginTag) = + assemble(kind = subgroupOriginTag.kind, id = subgroupOriginTag.id) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index ada04fb2..24300872 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -19,6 +19,12 @@ import kotlin.test.assertTrue import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag /** * What a birth certificate is allowed to convince a member of. @@ -582,6 +588,141 @@ class SubgroupBirthCertificateEventTest { ) } + // --- what the subgroup is for --- + + @Test + fun `an origin round trips for each of the four kinds`() { + listOf( + DialectEvent.KIND, + ArtifactEvent.KIND, + TranslationArtifactVersionEvent.KIND, + ProjectEvent.KIND + ).forEach { kind -> + val entityId = "a1".padEnd(64, '0') + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path, + origin = SubgroupOrigin.Ref(kind = kind, id = entityId) + ) + + assertEquals( + SubgroupOrigin.Ref(kind = kind, id = entityId), + SubgroupBirthCertificateEvent.parseOrigin(tags), + "kind $kind did not read back" + ) + } + } + + @Test + fun `a certificate made for nothing in particular has no origin`() { + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + + assertNull(SubgroupBirthCertificateEvent.parseOrigin(tags)) + assertTrue( + tags.none { it[0] == SubgroupOriginTag.TAG_NAME }, + "an origin tag was written for a subgroup that is for nothing" + ) + } + + /** + * Half a reference is not a weaker reference. A reader without the kind + * cannot tell a project it does not hold from something that is not a + * project, and an id of the wrong length cannot be resolved by anything -- + * nothing downstream would refuse it, because nothing resolves an origin + * before showing it. + */ + @Test + fun `a malformed origin reads as none rather than as half of one`() { + val entityId = "a1".padEnd(64, '0') + + listOf( + arrayOf(SubgroupOriginTag.TAG_NAME, "30330"), + arrayOf(SubgroupOriginTag.TAG_NAME, "not-a-kind", entityId), + arrayOf(SubgroupOriginTag.TAG_NAME, "30330", entityId.dropLast(1)), + arrayOf(SubgroupOriginTag.TAG_NAME, "30330", ""), + arrayOf(SubgroupOriginTag.TAG_NAME) + ).forEach { malformed -> + assertNull( + SubgroupBirthCertificateEvent.parseOrigin(arrayOf(malformed)), + "${malformed.toList()} parsed as an origin" + ) + } + } + + /** + * The origin joins the founding roster, and this is the second half of the + * test above that exists to fail loudly. A certificate naming an entity this + * device does not hold still certifies -- a subgroup's members need never + * have been in the parent, so a certificate pointing at a row they will never + * see is the normal case rather than a forgery. + */ + @Test + fun `a certificate for an entity this device cannot resolve still verifies`() { + val signed = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path, + origin = SubgroupOrigin.Ref( + kind = ProjectEvent.KIND, + id = "beef".padEnd(64, '0') + ) + ) + ) + + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = signed, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + /** + * The origin sits before the run of `p` tags, and the position is load + * bearing: a reader that predates it takes the tags it knows off the front, + * and one inserted into the admin run would split the founding roster for + * anybody reading positionally. + */ + @Test + fun `an origin disturbs neither the admins nor the name`() { + val tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path, + origin = SubgroupOrigin.Ref( + kind = DialectEvent.KIND, + id = "a1".padEnd(64, '0') + ) + ) + + assertEquals(admins, SubgroupBirthCertificateEvent.parseAdminPublicKeys(tags)) + assertEquals(name, SubgroupBirthCertificateEvent.parseName(tags)) + + val originAt = tags.indexOfFirst { it[0] == SubgroupOriginTag.TAG_NAME } + val firstAdminAt = tags.indexOfFirst { it[0] == "p" } + + assertTrue(originAt in 0 until firstAdminAt, "the origin is inside the admin run") + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( From c77187e78d8fec2f3e5cc810fc20c3fc9b01f204 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:39:11 +0200 Subject: [PATCH 24/28] feat(subgroups): schema 23 -- what a room is for survives the ceremony Two nullable DkgSession columns and one tag on the ritual proposal, so the thing a subgroup is being made for reaches the certificate. It has to travel this way rather than in the coordinator's head. The certificate is proposed *after* the ceremony and by whoever is able to -- any parent admin holding a share, who may never have seen the screen that started it -- which is why every step of a subgroup reads stored rows rather than a session handed down from a screen. The origin is one more row to read. And because it rides the proposal, every participant's session carries it and not only the coordinator's: an admin asked for the certificate reads what the room is for off their own session instead of taking the proposer's word. That is the same road `subject` and `participantPublicKeys` took in v19, for the same reason, and the migration comment says so. Neither column is a foreign key, following parentChatRoomId's precedent in v17 and with a sharper version of its reason: these entities live in the *parent's* tables, and a subgroup's admins need never have been in the parent, so naming a row this device does not hold is the normal state. A cascade would also mean deleting a project deletes a room. There is a test for the dangling case, since nothing in the schema would otherwise say it was intended. It authenticates nothing. Anybody can put any kind and any id on a proposal, as with the parent beside it, and `parseOrigin`'s comment says so where a reader will be standing. The claim that decides anything is the certificate, which the parent's quorum signs. One deviation from the plan, which said `proposeBirthCertificate` would take an `origin` parameter and the view model would pass it. It already takes the whole `DkgSession`, so it reads `key.origin()` instead -- a parameter would have been a second source for one fact, and the proposer's copy of it at that. Nothing in the repository or the ritual view model had to change. Schema 23 collides with an unmerged branch holding 23, 24 and 25; Phase 0.2 of docs/subgroups-for-entities.md records why this takes 23 anyway. Phase 5 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../23.json | 6152 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 27 +- .../compose/database/model/DkgSession.kt | 42 + .../repository/DatabaseDkgRepository.kt | 7 +- .../compose/managers/ChillDkgRitualManager.kt | 35 +- .../compose/managers/SubgroupManager.kt | 9 +- .../compose/nostr/dkg/DkgRitualEvents.kt | 28 +- .../compose/repository/DkgRepository.kt | 8 +- .../model/SelectSubgroupAdminsViewModel.kt | 18 +- .../database/dao/SubgroupDaoJvmTest.kt | 61 +- .../SubgroupCeremonyInParentRoomTest.kt | 79 +- 11 files changed, 6440 insertions(+), 26 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json new file mode 100644 index 00000000..252a357d --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/23.json @@ -0,0 +1,6152 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "a0a28e3d4985a5153bc7389cb5802d40", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatMessage_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "joinedGroupAt", + "columnName": "joinedGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `originKind` INTEGER, `originId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKeys", + "columnName": "participantPublicKeys", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "originKind", + "columnName": "originKind", + "affinity": "INTEGER" + }, + { + "fieldPath": "originId", + "columnName": "originId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyStateParent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "parentChatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyStateParent_parentChatRoomId", + "unique": false, + "columnNames": [ + "parentChatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupKeyState", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "chatRoomId" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `creatorPublicKey` TEXT NOT NULL, `ownerPublicKeys` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creatorPublicKey", + "columnName": "creatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKeys", + "columnName": "ownerPublicKeys", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProject_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProject_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_artifactVersionId", + "unique": false, + "columnNames": [ + "artifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_artifactVersionId` ON `${TABLE_NAME}` (`artifactVersionId`)" + }, + { + "name": "index_MantraProjectArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraProjectTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `projectId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`projectId`) REFERENCES `MantraProject`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MantraProjectTranslationArtifactVersion_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId", + "unique": false, + "columnNames": [ + "translationArtifactVersionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_translationArtifactVersionId` ON `${TABLE_NAME}` (`translationArtifactVersionId`)" + }, + { + "name": "index_MantraProjectTranslationArtifactVersion_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MantraProjectTranslationArtifactVersion_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "MantraProject", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "projectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a0a28e3d4985a5153bc7389cb5802d40')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index c0c690ba..65939679 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -188,7 +188,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 22, + version = 23, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -364,6 +364,31 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // than a fourth table, which is what DkgSession.participantPublicKeys // already does with a set of keys and for the same reason. AutoMigration(from = 21, to = 22), + // v23 adds two nullable DkgSession columns saying what the subgroup a + // ceremony is running for is *for* -- the kind and id of a dialect, an + // artifact, a translation or a project. See + // docs/subgroups-for-entities.md. + // + // They are here rather than only on the certificate because the + // certificate is proposed *after* the ceremony, and by whoever is able + // to: any parent admin holding a share, who may never have seen the + // screen that started it. Every step of a subgroup reads stored rows for + // that reason, and this is one more row to read. They come off the ritual + // proposal, so every participant's session has them and not just the + // coordinator's -- the same route `subject` and `participantPublicKeys` + // took in v19. + // + // **Neither is a foreign key**, following parentChatRoomId's precedent in + // v17 and for a sharper version of its reason: these entities live in the + // *parent's* tables, and a subgroup's admins need never have been in the + // parent, so a value naming a row this device does not hold is the normal + // state rather than an error. A cascade would also mean deleting a + // project deletes a room. + // + // Rows written before this read back null, which is the true answer for + // all of them: a ceremony that ran before this column existed was opened + // for no entity. Nullable additions are a shape Room migrates itself. + AutoMigration(from = 22, to = 23), ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt index 9c1abc09..b76c1ce9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt @@ -5,6 +5,7 @@ import androidx.room3.ForeignKey import androidx.room3.Index import androidx.room3.PrimaryKey import press.mantra.compose.database.model.traits.LocalStoreEntity +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.database.model.types.DkgRitualStage @@ -193,12 +194,53 @@ data class DkgSession( */ val parentChatRoomId: HexKey? = null, + /** + * The kind of the thing this subgroup is being made for, or null for a + * subgroup that is for nothing in particular. + * + * The pair with [originId], and read together or not at all -- see + * [origin]. Both come off the ritual proposal, so every participant's + * session carries them and not only the coordinator's, which is what makes + * the certificate proposable by somebody who was never on the picker screen. + * + * **This authenticates nothing**, exactly as [parentChatRoomId] does not: + * anybody can put any id on a proposal. What it is for is the certificate + * two steps later, which the parent's quorum signs, and the screens that say + * what a ceremony is for while it runs. Nothing may be granted on the + * strength of these two columns. + * + * Not foreign keys. The row named here is routinely one this device does not + * hold -- a subgroup's admins need never have been in the parent, and these + * entities live in the parent's tables -- so a dangling value is the normal + * state rather than an error, and a cascade would mean deleting a project + * deletes a room. + */ + val originKind: Int? = null, + + /** The id of the thing this subgroup is for. The pair with [originKind]. */ + val originId: HexKey? = null, + override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = createdAt, ): TimestampedEntity, LocalStoreEntity { fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey + /** + * What this subgroup is for, or null when it is for nothing in particular. + * + * Both columns or neither: half a reference is a reference with the + * resolvable part removed, and a row holding one is a row written by a + * version that disagreed with this one. Reading them through here rather than + * separately is what stops a caller assembling half of one. + */ + fun origin(): SubgroupOrigin.Ref? { + val kind = originKind ?: return null + val id = originId ?: return null + + return SubgroupOrigin.Ref(kind = kind, id = id) + } + /** The participants' public shares in participant order, or null if unrecorded. */ fun publicShareList(): List? = publicShares ?.split(",") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index fcb53e1f..d611987d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -13,6 +13,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -64,7 +65,8 @@ class DatabaseDkgRepository( threshold: Int, parentChatRoomId: HexKey?, participantPublicKeys: Set?, - subject: String? + subject: String?, + origin: SubgroupOrigin.Ref? ): DkgSession? = try { ChillDkgRitualManager.proposeRitual( database = database, @@ -74,7 +76,8 @@ class DatabaseDkgRepository( threshold = threshold, parentChatRoomId = parentChatRoomId, participantPublicKeys = participantPublicKeys, - subject = subject + subject = subject, + origin = origin ) } catch (e: Throwable) { logger.e("Error proposing DKG ritual for ${localChatRoom.chatRoom.id}", e) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 2f80f727..793dab4e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -183,7 +183,8 @@ object ChillDkgRitualManager { threshold: Int, parentChatRoomId: HexKey? = null, participantPublicKeys: Set? = null, - subject: String? = null + subject: String? = null, + origin: SubgroupOrigin.Ref? = null ): DkgSession { // Built the way a receiver rebuilds it from the proposal — the p-tags // `broadcast` writes, plus this device — so both sides count the same `n` @@ -288,7 +289,14 @@ object ChillDkgRitualManager { // proposal -- unverified there and unverified here, and read for // nothing but a screen's title and a way back into an unfinished // subgroup flow. See `DkgSession.parentChatRoomId`. - parentChatRoomId = parentChatRoomId + parentChatRoomId = parentChatRoomId, + // And what the subgroup is for, on the same terms: the coordinator's + // own copy of a claim they are about to put on the proposal, + // unverified on both sides. It is here rather than only in the + // coordinator's head because the certificate is proposed later and + // by whoever can -- see `DkgSession.originKind`. + originKind = origin?.kind, + originId = origin?.id ) database.dkgSessionDao().upsert(session) announceStarted(database, session) @@ -315,7 +323,12 @@ object ChillDkgRitualManager { // is routinely that payload. For a subgroup it is the only way the // name travels at all, since the child has no room yet and the room // this runs in is the parent's. - subject = ceremonySubject + subject = ceremonySubject, + // Only on the proposal, for the reason `includeParent` gives: a later + // message repeating it would be bytes per round to restate something + // settled, and would give a later message a say in what an opened + // ceremony is for. + includeOrigin = true ) publishHostKey(database, localChatRoom, session) @@ -471,7 +484,15 @@ object ChillDkgRitualManager { // is the p-tags and the threshold is the threshold. What it changes is // that the screen can say what the ceremony is for. The parent's own // signature turns up two steps later, on the birth certificate. - parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(innerEvent.tags) + parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(innerEvent.tags), + // Taken as said, on exactly the terms the parent above is taken as + // said. A proposal naming a project this member has never heard of + // opens the same ceremony it would have opened with no tag at all; + // what it changes is that the screen can say what the room is for, + // and that an admin asked for the certificate later reads it off + // their own session rather than off the proposer's word. + originKind = DkgRitualEvents.parseOrigin(innerEvent.tags)?.kind, + originId = DkgRitualEvents.parseOrigin(innerEvent.tags)?.id ) database.dkgSessionDao().upsert(session) announceStarted(database, session) @@ -1352,7 +1373,8 @@ object ChillDkgRitualManager { content: String, includeThreshold: Boolean = false, includeParent: Boolean = false, - subject: String? = null + subject: String? = null, + includeOrigin: Boolean = false ) { // Sorted so a receiver comparing two devices' proposals is comparing the // same bytes, and the sender is implied rather than tagged -- together @@ -1374,7 +1396,8 @@ object ChillDkgRitualManager { sessionId = session.id, threshold = if (includeThreshold) session.threshold else null, parentChatRoomId = if (includeParent) session.parentChatRoomId else null, - subject = subject + subject = subject, + origin = if (includeOrigin) session.origin() else null ) val createdAt = Clock.System.now().epochSeconds diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 7ca84bcf..faf20171 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -254,7 +254,14 @@ object SubgroupManager { thresholdPublicKey = thresholdPublicKey, adminPublicKeys = adminPublicKeys, name = subgroupName, - path = path + path = path, + // Off the ceremony, not off a parameter. [key] is already the + // stored session, and every step after the ceremony is open to + // somebody who was never on the screen that started it -- so + // what the room is for has to come from the same row the key + // does. A parameter here would be a second source for one fact, + // and the proposer's copy of it at that. + origin = key.origin() ), content = subgroupChatRoomId ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt index 2fe6ea3c..6a29aa61 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt @@ -4,6 +4,8 @@ import press.mantra.compose.nostr.dkg.tags.DkgSessionIdTag import press.mantra.compose.nostr.dkg.tags.DkgThresholdTag import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip14Subject.SubjectTag +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag /** @@ -76,12 +78,14 @@ object DkgRitualEvents { sessionId: String, threshold: Int? = null, parentChatRoomId: String? = null, - subject: String? = null + subject: String? = null, + origin: SubgroupOrigin.Ref? = null ): Array> = buildList { add(DkgSessionIdTag.assemble(sessionId)) threshold?.let { add(DkgThresholdTag.assemble(it)) } parentChatRoomId?.let { add(SubgroupParentTag.assemble(it)) } subject?.ifBlank { null }?.let { add(SubjectTag.assemble(it)) } + origin?.let { add(SubgroupOriginTag.assemble(kind = it.kind, id = it.id)) } }.toTypedArray() fun parseSessionId(tags: Array>): String? = @@ -124,4 +128,26 @@ object DkgRitualEvents { */ fun parseSubject(tags: Array>): String? = tags.firstNotNullOfOrNull(SubjectTag::parse) + + /** + * The dialect, artifact, translation or project the room this ceremony will + * make is for, or null for a subgroup that is for nothing in particular. + * + * **This authenticates nothing**, in exactly the way + * [parseParentChatRoomId] does not: anybody can put any kind and any id + * here, and nothing about the entity is checked when it is read. It is filed + * on the session by `acceptProposal` and shown by the screens, and that is + * the whole of what it is for. The claim that decides anything is the birth + * certificate, which the parent's own quorum signs. + * + * The reason to carry it is the reason the parent is carried. An admin asked + * to hold a key ceremony learns from this what the room is being made for, + * and later an admin asked to put the group's signature to a certificate + * reads it off their own stored session rather than taking the proposer's + * word for it. Being told is worth having even when the telling is not + * evidence. + */ + fun parseOrigin(tags: Array>): SubgroupOrigin.Ref? = + tags.firstNotNullOfOrNull(SubgroupOriginTag::parse) + ?.let { SubgroupOrigin.Ref(kind = it.kind, id = it.id) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index c61a9e85..3418ca36 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.managers.SubgroupOrigin import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -98,7 +99,9 @@ interface DkgRepository { threshold: Int, parentChatRoomId: HexKey? = null, participantPublicKeys: Set? = null, - subject: String? = null + subject: String? = null, + /** What the subgroup is for, where it is for anything in particular. */ + origin: SubgroupOrigin.Ref? = null ): DkgSession? /** What the ritual is waiting on this device's owner for, if anything. */ @@ -235,7 +238,8 @@ interface DkgRepository { threshold: Int, parentChatRoomId: HexKey?, participantPublicKeys: Set?, - subject: String? + subject: String?, + origin: SubgroupOrigin.Ref? ): DkgSession? = null override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index 52a1b464..70a89056 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -268,16 +268,14 @@ class SelectSubgroupAdminsViewModel( // coordinator is added by `proposeRitual`: opening a ceremony is // being in it, and is not theirs to tick. participantPublicKeys = selectedPublicKeys.toSet(), - subject = name.value.trim() - // [origin] is not on the ceremony yet. It will be, and for the - // same reason the name is: the certificate is proposed after the - // ceremony, possibly by an admin who was never on this screen, so - // anything it needs has to be on the stored session rather than - // in this lambda. That needs a `DkgSession` column and a tag on - // the proposal, which is Phase 5 of - // docs/subgroups-for-entities.md. Until then a subgroup made from - // an entity is named after it and nothing more, which is exactly - // the cheap version of this feature. + subject = name.value.trim(), + // What the room is for, carried the same way and for the same + // reason as the name: the certificate is proposed after the + // ceremony, possibly by an admin who was never on this screen, + // so it reads what it needs off the stored session. Null here is + // a subgroup for nothing in particular, which is the room's own + // "Add subgroup" and a perfectly ordinary subgroup. + origin = origin ) isActionPending.value = false diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt index 1e1a9e9f..c709b42f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt @@ -20,6 +20,7 @@ import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.managers.SubgroupOrigin /** * What schema 21 has to survive, and the two things it must refuse to do. @@ -315,6 +316,61 @@ class SubgroupDaoJvmTest { assertNull(db.dkgSessionDao().getSessionById("s2")?.parentChatRoomId) } + // --- schema 23: what the subgroup is for --- + + /** + * The two columns are read together or not at all. A row holding one of them + * was written by a version that disagreed with this one, and half a reference + * cannot be resolved by anything -- so `origin()` says no rather than handing + * back something a screen would try to look up. + */ + @Test + fun `an origin round trips, and half of one reads as none`() = runBlocking { + seedRooms() + + val entityId = "a1".padEnd(64, '0') + val ref = SubgroupOrigin.Ref(kind = 30330, id = entityId) + + db.dkgSessionDao().upsert(session(id = "for-a-project", origin = ref)) + db.dkgSessionDao().upsert(session(id = "for-nothing")) + db.dkgSessionDao().upsert( + session(id = "half").copy(originKind = 30330, originId = null) + ) + db.dkgSessionDao().upsert( + session(id = "other-half").copy(originKind = null, originId = entityId) + ) + + assertEquals(ref, db.dkgSessionDao().getSessionById("for-a-project")?.origin()) + assertNull(db.dkgSessionDao().getSessionById("for-nothing")?.origin()) + assertNull(db.dkgSessionDao().getSessionById("half")?.origin()) + assertNull(db.dkgSessionDao().getSessionById("other-half")?.origin()) + } + + /** + * Neither column is a foreign key, and that is deliberate rather than an + * omission. These entities live in the *parent's* tables and a subgroup's + * admins need never have been in the parent, so a session naming a project + * this device does not hold is the normal state. A key would make that row + * unwritable; a cascade would make deleting a project delete a room. + */ + @Test + fun `an origin may name a row this device does not hold`() = runBlocking { + seedRooms() + + val stranger = "f".repeat(64) + db.dkgSessionDao().upsert( + session( + id = "for-a-stranger", + origin = SubgroupOrigin.Ref(kind = 30304, id = stranger) + ) + ) + + assertEquals( + SubgroupOrigin.Ref(kind = 30304, id = stranger), + db.dkgSessionDao().getSessionById("for-a-stranger")?.origin() + ) + } + private suspend fun seedRooms() { val nostrEventId = "c".repeat(64) db.nostrEventDao().upsert( @@ -363,7 +419,8 @@ class SubgroupDaoJvmTest { private fun session( id: String, chatRoomId: String = parentRoom, - parentChatRoomId: String? = null + parentChatRoomId: String? = null, + origin: SubgroupOrigin.Ref? = null ) = DkgSession( id = id, chatRoomId = chatRoomId, @@ -376,5 +433,7 @@ class SubgroupDaoJvmTest { round1Random = "aa".repeat(32), round2AuxRandom = "bb".repeat(32), parentChatRoomId = parentChatRoomId, + originKind = origin?.kind, + originId = origin?.id, ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt index c30df4c8..9bd951c0 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupCeremonyInParentRoomTest.kt @@ -35,6 +35,7 @@ import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.Relays import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.subgroup.tags.SubgroupOriginTag /** * A subgroup's key ceremony, held in the room of the group making it. @@ -111,7 +112,10 @@ class SubgroupCeremonyInParentRoomTest { private val quorum = 2 - private suspend fun openCeremony(room: LocalChatRoom) = ChillDkgRitualManager.proposeRitual( + private suspend fun openCeremony( + room: LocalChatRoom, + origin: SubgroupOrigin.Ref? = null + ) = ChillDkgRitualManager.proposeRitual( database = parent, localChatRoom = room, userPublicKey = user, @@ -119,9 +123,80 @@ class SubgroupCeremonyInParentRoomTest { threshold = quorum, parentChatRoomId = parentRoomId, participantPublicKeys = admins, - subject = "Translation" + subject = "Translation", + origin = origin ) + /** + * What the room is *for* travels the same road the name does, and has to: + * the certificate is proposed after the ceremony and by whoever can, so an + * admin who never saw the picker reads it off their own session. + */ + @Test + fun `what the subgroup is for travels on the proposal and lands on both sessions`(): Unit = + runBlocking { + val forTheProject = SubgroupOrigin.Ref(kind = 30330, id = "a1".padEnd(64, '0')) + + val room = parentRoom() + val session = openCeremony(room, origin = forTheProject) + + assertEquals(forTheProject, session.origin(), "the coordinator's own copy") + + val proposal = assertNotNull( + innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL } + ) + assertEquals(forTheProject, DkgRitualEvents.parseOrigin(proposal.tags)) + + // Only on the proposal, as the parent and the name are. A later + // message with a say in what an opened ceremony is for would be a + // second answer to a settled question. + innerEvents(parent).filterNot { it.kind == DkgRitualEvents.PROPOSAL }.forEach { + assertNull( + DkgRitualEvents.parseOrigin(it.tags), + "kind ${it.kind} repeats what the subgroup is for" + ) + } + + ChillDkgRitualManager.processRitualPayload( + database = receiver, + localChatRoom = receiverRoom(), + innerEvent = proposal.asEvent(), + userPublicKey = alice, + nostrPrivateKey = aliceKeys.privKey!! + ) + + val joined = assertNotNull( + receiver.dkgSessionDao().getLatestSessionForChatRoom(parentRoomId) + ) + assertEquals( + forTheProject, + joined.origin(), + "a picked admin can say what the room is for without asking the coordinator" + ) + } + + /** + * A subgroup for nothing in particular is an ordinary subgroup, and this is + * the path the group's own "Add subgroup" takes. It must write no tag at all + * rather than an empty one, or a reader gets half a reference. + */ + @Test + fun `a ceremony for nothing in particular carries no origin`(): Unit = runBlocking { + val room = parentRoom() + val session = openCeremony(room) + + assertNull(session.origin()) + + val proposal = assertNotNull( + innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL } + ) + assertNull(DkgRitualEvents.parseOrigin(proposal.tags)) + assertTrue( + proposal.tags.none { it[0] == SubgroupOriginTag.TAG_NAME }, + "an origin tag was written for a subgroup that is for nothing" + ) + } + private suspend fun innerEvents(database: MantraDatabase) = database.marmotInnerEventDao() .getByChatRoomAndKinds(parentRoomId, DkgRitualEvents.ALL.toList()) From 7640461345a9ec94db86ff69f7796d8217b930ff Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:48:10 +0200 Subject: [PATCH 25/28] feat(subgroups): say what the room is for, where a quorum is deciding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the phase the origin tag was for. A parent admin approving a birth certificate is putting the group's signature to a room id produced by a ceremony most of them were not in; until now the screen offered them a name and a head count, which say how big the thing is and not what it does. The proposal now reads "#Setswana Luke · for the project Gospel of John · run by 4 members", and that middle clause is the only line that says what is being agreed to. `SubgroupManager.Subgroup` gains the parsed origin beside the name and admins it already parses, so every existing reader keeps working and the room's subgroup list can say what each child is for -- as an overline, because the supporting line is already carrying whether this device can open the room and both are worth a line. Names are resolved where the device holds the row and shown as a kind plus a short hash where it does not, which is `describe`'s existing bargain. That case is a fact rather than a gap: these entities live in the parent's tables, so a certificate is allowed to name a row the reader will never hold, and a test asserts the honest version rather than letting it read as a broken screen. An unrecognised kind is named by its number, or on the card left out entirely -- describing something wrongly is worse than not describing it. Resolution reuses the four `getX(id)` repository functions that already exist rather than adding a query, and happens once per load in the view model, because a composable cannot await a database. In `ChatRoomDetailViewModel` the list is read into a local first: resolving inside the argument list would make the map and the cards depend on evaluation order. The card's four strings are four complete sentences rather than a kind word interpolated into one, since a translated fragment assembled at runtime is a sentence no translator ever saw. Phase 6 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 4 + .../compose/managers/SubgroupManager.kt | 12 +++ .../mantra/compose/text/ProposedEvent.kt | 35 +++++++ .../ui/composable/ChatRoomDetailScreen.kt | 50 ++++++++++ .../ui/view/model/ChatRoomDetailViewModel.kt | 42 +++++++- .../ui/view/model/FrostSigningViewModel.kt | 33 +++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 10 ++ .../mantra/compose/text/ProposedEventTest.kt | 99 +++++++++++++++++++ 8 files changed, 284 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index cf280ebb..29213b0d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,6 +32,10 @@ Add chapter Add dialect Add to group + For the artifact %1$s + For the dialect %1$s + For the project %1$s + For the translation %1$s Give this a room of its own Add translation After this there is no turning back. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index faf20171..6f8ef1dd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -769,6 +769,17 @@ object SubgroupManager { val room: LocalChatRoom?, /** The child's key state, when this device holds one. */ val keyState: GroupKeyState?, + /** + * The dialect, artifact, translation or project this subgroup was made + * for, or null when it was made for nothing in particular. + * + * Off the certificate, like the name and the admins beside it, and + * carrying their limits: it is the *founding* intention, it is not + * checked, and it routinely names a row this device does not hold. A + * reader that cannot resolve it says so rather than hiding it -- see + * `ProposedEvent.summarize`. + */ + val origin: SubgroupOrigin.Ref?, ) { /** Whether this device can open the subgroup, rather than only know of it. */ val isJoined: Boolean get() = room != null @@ -823,6 +834,7 @@ object SubgroupManager { adminPublicKeys = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags), room = database.chatRoomDao().findChatRoomById(chatRoomId), keyState = database.groupKeyStateDao().getByChatRoomId(chatRoomId), + origin = SubgroupBirthCertificateEvent.parseOrigin(event.tags), ) } .sortedByDescending { it.certificate.createdAt } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 00999643..35cfd4e6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -3,6 +3,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent @@ -277,6 +278,11 @@ object ProposedEvent { label = "A new subgroup of this group", detail = listOfNotNull( SubgroupBirthCertificateEvent.parseName(event.tags), + // What the room is *for*, which is the most useful sentence that + // can be put in front of a parent admin here: a name and a count + // say how big the thing is, and this says what it does. It is the + // whole reason the certificate carries an origin. + SubgroupBirthCertificateEvent.parseOrigin(event.tags)?.let { describeOrigin(it, resolve) }, SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags) .size .takeIf { it > 0 } @@ -287,6 +293,35 @@ object ProposedEvent { else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } + /** + * What a subgroup is for, in words: "for the project Gospel of John". + * + * Resolved where this device holds the row and shown as a kind and a short + * hash where it does not, which is [describe]'s bargain and is the honest + * answer here. A certificate routinely names a row the reader will never + * hold -- the entities live in the parent's tables and a subgroup's members + * need never have been in the parent -- so "for a project this device does + * not have" has to read as a fact rather than as a broken screen. + * + * An unrecognised kind is named by its number for the same reason + * [summarize] keeps the raw kind: refusing to describe something is better + * than describing it wrongly. + */ + private fun describeOrigin( + origin: SubgroupOrigin.Ref, + resolve: (HexKey) -> String? + ): String { + val what = when (origin.kind) { + DialectEvent.KIND -> "dialect" + ArtifactEvent.KIND -> "artifact" + TranslationArtifactVersionEvent.KIND -> "translation" + ProjectEvent.KIND -> "project" + else -> "kind ${origin.kind}" + } + + return "for the $what ${describe(origin.id, resolve)}" + } + /** * [id] as a name if [resolve] has one, and as a short hash otherwise. * 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 609005de..25cbbe29 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 @@ -105,6 +105,11 @@ import mantra.composeapp.generated.resources.subgroups import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager @@ -115,6 +120,10 @@ import mantra.composeapp.generated.resources.add_dialect import mantra.composeapp.generated.resources.create_project import mantra.composeapp.generated.resources.delete_group import mantra.composeapp.generated.resources.dialects +import mantra.composeapp.generated.resources.for_the_artifact +import mantra.composeapp.generated.resources.for_the_dialect +import mantra.composeapp.generated.resources.for_the_project +import mantra.composeapp.generated.resources.for_the_translation import mantra.composeapp.generated.resources.direct_message_detail import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.invite_new_member @@ -733,6 +742,7 @@ fun ChatRoomDetailScreen( ) { subgroup -> SubgroupCard( subgroup = subgroup, + originNames = chatRoomDetailUIState.subgroupOriginNames, onOpen = { // Into the child where this device has // it; at the certificate where it does @@ -1319,6 +1329,7 @@ internal fun GroupKeyStateSheetContent( @Composable private fun SubgroupCard( subgroup: SubgroupManager.Subgroup, + originNames: Map, onOpen: () -> Unit ) { Card(onClick = onOpen) { @@ -1329,6 +1340,15 @@ private fun SubgroupCard( trailingContent = { Icon(Icons.Default.ChevronRight, contentDescription = Decorative) }, + // What the room is for, above its name, where the certificate says. + // The supporting line below is already carrying whether this device + // can open the room, which is a different question and both are worth + // a line. + overlineContent = subgroup.origin?.let { origin -> + describeSubgroupOrigin(origin, originNames)?.let { described -> + { Text(described) } + } + }, headlineContent = { Text( text = subgroup.room?.chatRoom?.subject @@ -1355,6 +1375,36 @@ private fun SubgroupCard( } } +/** + * "For the project Gospel of John", or null for a kind this build does not know. + * + * Four complete sentences rather than a kind word interpolated into one, because + * a translated fragment assembled at runtime is a sentence no translator ever saw. + * + * An entity this device does not hold is named by a short hash, which is what + * the rest of the app does with something it has not seen. A certificate is + * allowed to name a row the reader will never have -- see + * `SubgroupManager.Subgroup.origin` -- so that case is a fact to state rather + * than a gap to hide. An unrecognised kind returns null and the line is simply + * absent: describing something wrongly is worse than not describing it. + */ +@Composable +private fun describeSubgroupOrigin( + origin: SubgroupOrigin.Ref, + originNames: Map +): String? { + val name = originNames[origin.id] ?: origin.id.take(12) + + return when (origin.kind) { + DialectEvent.KIND -> stringResource(Res.string.for_the_dialect, name) + ArtifactEvent.KIND -> stringResource(Res.string.for_the_artifact, name) + TranslationArtifactVersionEvent.KIND -> + stringResource(Res.string.for_the_translation, name) + ProjectEvent.KIND -> stringResource(Res.string.for_the_project, name) + else -> null + } +} + private fun String.inComparableGroups(): String = chunked(8).joinToString(" ") /** One labelled value of a key state, whole and comparable. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index b286f767..c1fe9aa5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -18,6 +18,11 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -75,6 +80,11 @@ class ChatRoomDetailViewModel( // Initiate new chat... and navigate to new chat... ChatRoomDetailUIState.Error("Missing chat room") } else { + // Read into a local so the names are resolved from exactly the + // list the cards are drawn from. Resolving inside the argument + // list would make the two depend on evaluation order. + val subgroups = chatRepository.subgroupsOf(chatRoomId) + ChatRoomDetailUIState.Loaded( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), @@ -82,7 +92,8 @@ class ChatRoomDetailViewModel( projects = mantraRepository.getProjects(chatRoomId), groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), - subgroups = chatRepository.subgroupsOf(chatRoomId), + subgroups = subgroups, + subgroupOriginNames = originNamesIn(subgroups), parents = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), @@ -99,6 +110,35 @@ class ChatRoomDetailViewModel( } } + /** + * What each subgroup's origin is called, where this device holds the row. + * + * Only the rows the certificates actually name, and only from this device's + * own tables. An entity that is not here is left out, and the card shows a + * short hash -- which is truthful rather than a gap, because a certificate is + * allowed to name something the reader has never seen: these entities live in + * the parent's tables, and a parent member reading a certificate may hold the + * room without holding the row. + */ + private suspend fun originNamesIn( + subgroups: List + ): Map = buildMap { + subgroups.mapNotNull { it.origin }.forEach { origin -> + if (containsKey(origin.id)) return@forEach + + val name = when (origin.kind) { + DialectEvent.KIND -> mantraRepository.getDialect(origin.id)?.name + ArtifactEvent.KIND -> mantraRepository.getArtifact(origin.id)?.name + TranslationArtifactVersionEvent.KIND -> + mantraRepository.getTranslation(origin.id)?.name + ProjectEvent.KIND -> mantraRepository.getProject(origin.id)?.name + else -> null + } + + name?.let { put(origin.id, it) } + } + } + /** * Asks this group's quorum to accept [parentChatRoomId] as a parent. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt index e533a1e3..4872ee8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -20,7 +20,11 @@ import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ArtifactEvent +import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository @@ -152,6 +156,35 @@ class FrostSigningViewModel( } project(membership.projectId()) } + + // What a subgroup is being made for. This is the one branch + // where the resolution is the point of the whole proposal: a + // parent admin is putting the group's signature to a room id + // produced by a ceremony most of them were not in, and "for the + // translation Setswana Luke" is the only line on the screen that + // says what they are agreeing to. + // + // Unresolvable is a normal answer, not a gap. The entity lives + // in this room's tables, so a parent admin usually does hold it + // -- but a certificate can be read on a device that does not, + // and `summarize` then shows the kind and a short hash rather + // than pretending. + SubgroupBirthCertificateEvent.KIND -> { + val origin = SubgroupBirthCertificateEvent.parseOrigin(event.tags) + val id = origin?.id + + if (id != null && !containsKey(id)) { + val name = when (origin.kind) { + DialectEvent.KIND -> mantraRepository.getDialect(id)?.name + ArtifactEvent.KIND -> mantraRepository.getArtifact(id)?.name + TranslationArtifactVersionEvent.KIND -> + mantraRepository.getTranslation(id)?.name + ProjectEvent.KIND -> mantraRepository.getProject(id)?.name + else -> null + } + name?.let { put(id, it) } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 319aae57..0bf14202 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -44,6 +44,16 @@ sealed interface ChatRoomDetailUIState { * signed and the room being created. */ val subgroups: List = emptyList(), + /** + * What each subgroup's origin is called, where this device holds the row. + * + * Resolved once in the view model rather than per card, because a + * composable cannot await a database. Keyed by the entity's id, and an id + * that is missing here is shown as a short hash -- which is what the rest + * of the app does with something it has not seen, and is the honest answer: + * a certificate can name a row this device will never hold. + */ + val subgroupOriginNames: Map = emptyMap(), /** * The groups this room is a subgroup of, empty when it is nobody's * child. diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 1915ed16..9e1bc053 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -2,6 +2,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.DialectEvent import press.mantra.compose.nostr.nip30303.ProjectEvent import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import kotlin.test.Test @@ -10,6 +11,7 @@ import kotlin.test.assertTrue import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage @@ -345,6 +347,103 @@ class ProposedEventTest { certificate = event(SubgroupBirthCertificateEvent.KIND, content = parentChatRoomId) ) + // --- what a subgroup is for, on the certificate a quorum is asked to sign --- + + /** The parent doing the certifying. Its own id, as the author would be. */ + private val certifyingParent = "9".repeat(64) + + /** + * The line that pays for the origin tag. + * + * A parent admin approving a certificate is putting the group's signature to + * a room id produced by a ceremony most of them were not in. A name and a + * head count say how big the thing is; this says what it does, and it is the + * only line on the screen that does. + */ + @Test + fun `a certificate says what the subgroup is for, by name where the row is held`() { + val projectId = "a1".padEnd(64, '0') + + val summary = ProposedEvent.summarize( + event = event( + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = "d".repeat(64), + parentChatRoomId = certifyingParent, + thresholdPublicKey = "02" + "e".repeat(64), + adminPublicKeys = listOf("1".repeat(64), "2".repeat(64), "3".repeat(64)), + name = "#Setswana Luke", + origin = SubgroupOrigin.Ref(kind = ProjectEvent.KIND, id = projectId) + ), + content = "d".repeat(64) + ), + resolve = { if (it == projectId) "Gospel of John" else null } + ) + + assertEquals("A new subgroup of this group", summary.label) + assertEquals( + "#Setswana Luke · for the project Gospel of John · run by 3 members", + summary.detail + ) + } + + /** + * Unresolvable is a fact to state, not a gap to hide. A certificate may name + * a row the reader will never hold -- these entities live in the parent's + * tables and a subgroup's members need never have been in the parent -- so + * the kind is still said and the id is truncated, which is what the rest of + * the app does with something it has not seen. + */ + @Test + fun `an unresolvable origin still says what kind of thing it is`() { + val summary = ProposedEvent.summarize( + event = event( + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = "d".repeat(64), + parentChatRoomId = certifyingParent, + thresholdPublicKey = "02" + "e".repeat(64), + adminPublicKeys = listOf("1".repeat(64)), + name = "#Sesotho", + origin = SubgroupOrigin.Ref( + kind = DialectEvent.KIND, + id = "beef".padEnd(64, '0') + ) + ), + content = "d".repeat(64) + ) + ) + + assertTrue( + summary.detail.contains("for the dialect beef00000000"), + "the detail did not name the kind and the short hash: ${summary.detail}" + ) + } + + /** + * A subgroup for nothing in particular says nothing about an origin, rather + * than saying "for nothing". That is the group's own "Add subgroup", and it + * is also every certificate signed before the tag existed. + */ + @Test + fun `a subgroup for nothing in particular reads as it always did`() { + val summary = ProposedEvent.summarize( + event = event( + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = "d".repeat(64), + parentChatRoomId = certifyingParent, + thresholdPublicKey = "02" + "e".repeat(64), + adminPublicKeys = listOf("1".repeat(64), "2".repeat(64)), + name = "#Translators" + ), + content = "d".repeat(64) + ) + ) + + assertEquals("#Translators · run by 2 members", summary.detail) + } + private fun event( kind: Int, tags: Array> = emptyArray(), From 5f6811271726a8186fe6796f6eee2f36ef8dba1d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:55:21 +0200 Subject: [PATCH 26/28] feat(subgroups): and the other direction -- what has been made for this The phase that makes the link worth having. Until now a certificate could say what a room was for and nothing could ask the question backwards, so a member looking at a project had no way to find the room the group made for it. `subgroupsFor` is a filter over `subgroupsOf` rather than a query of its own, because the certificates are already the record and a second read could disagree with the first about which of them verify. The case that earns its test is a sibling subgroup for something else: a filter on the wrong field would include it and still read as a working screen. `ceremoniesFor` is the half that prevents a real race. Two admins open a project's screen, neither can see the other's ceremony, and the group gets two ceremonies and two rooms for one project -- the screen that invites the act is the screen that cannot otherwise see the act in progress. Finished and failed are both left out, for different reasons: a complete ceremony is a subgroup that was made and belongs in the list above, and a failed one is nothing to offer back. The stage filter is in Kotlin rather than in the `WHERE`, because `DkgRitualStage` goes through a converter and a SQL comparison would be the query deciding what the converter's output looks like. Nothing is refused. A group may want two rooms for one artifact -- two teams, two bodies of work -- and deciding otherwise would be this device deciding a group's structure. So the button changes its words to "give this another room" instead of disappearing, and the deliberate second room stays one tap away while the accidental one becomes hard to make by mistake. The section is one `LazyListScope` extension for all four screens rather than four copies, which also replaces the four button blocks Phase 3 added. Four copies of "has this been given a room of its own" would drift into four answers. It hides itself entirely when there is nothing made, nothing running and nothing this device could start, rather than leaving a heading over a gap. Phase 7 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 3 + .../compose/database/dao/DkgSessionDao.kt | 24 +++ .../repository/DatabaseChatRepository.kt | 13 ++ .../compose/managers/SubgroupManager.kt | 54 +++++ .../compose/repository/ChatRepository.kt | 31 +++ .../ui/composable/ArtifactDetailScreen.kt | 52 +---- .../ui/composable/DialectDetailScreen.kt | 52 +---- .../ui/composable/ProjectDetailScreen.kt | 52 +---- .../TranslationArtifactVersionDetailScreen.kt | 52 +---- .../composable/widgets/SubgroupsForEntity.kt | 191 ++++++++++++++++++ .../ui/view/model/ArtifactDetailViewModel.kt | 26 +++ .../ui/view/model/DialectDetailViewModel.kt | 28 +++ .../ui/view/model/ProjectDetailViewModel.kt | 26 +++ ...anslationArtifactVersionDetailViewModel.kt | 26 +++ .../ui/view/state/ArtifactDetailUIState.kt | 23 +++ .../ui/view/state/DialectDetailUIState.kt | 23 +++ .../ui/view/state/ProjectDetailUIState.kt | 23 +++ ...TranslationArtifactVersionDetailUIState.kt | 23 +++ .../managers/SubgroupManagerJvmTest.kt | 104 +++++++++- 19 files changed, 656 insertions(+), 170 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 29213b0d..9c097604 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -32,11 +32,13 @@ Add chapter Add dialect Add to group + A key ceremony for a room is already under way. Answer it in the group rather than starting another. For the artifact %1$s For the dialect %1$s For the project %1$s For the translation %1$s Give this a room of its own + Give this another room Add translation After this there is no turning back. All broadcasts are queued so that we can manage data usage on metered connections. @@ -191,6 +193,7 @@ No projects exists in this group. No artifacts are written in this dialect yet. Nothing has been translated into this dialect yet. + Rooms of its own No translations yet. No versions. Not now diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt index 4f542730..5df82bff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/DkgSessionDao.kt @@ -120,6 +120,30 @@ interface DkgSessionDao { participantPublicKeys: String ): DkgSession? + /** + * Every ceremony in [chatRoomId] opened for one particular thing, newest + * first. + * + * Both halves of the origin are matched, because a row holding one of them is + * a row written by a version that disagreed with this one -- see + * `DkgSession.origin`. + * + * The stage is deliberately not in the `WHERE`. `DkgRitualStage` is stored + * through a converter, so a SQL comparison against it would be this query + * deciding what the converter's output looks like; the caller filters in + * Kotlin, where the enum is the enum. + */ + @Query( + "SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " + + "originKind = :originKind AND originId = :originId " + + "ORDER BY createdAt DESC" + ) + suspend fun getSessionsForOrigin( + chatRoomId: String, + originKind: Int, + originId: String + ): List + /** * Every ceremony this room holds, newest first. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 70e05e89..5d9b647c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -8,7 +8,9 @@ import press.mantra.compose.managers.ChronicleManager import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.database.model.DkgSession import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -102,6 +104,17 @@ class DatabaseChatRepository( } + override suspend fun subgroupsFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = + SubgroupManager.subgroupsFor(database, chatRoomId, origin) + + override suspend fun subgroupCeremoniesFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = SubgroupManager.ceremoniesFor(database, chatRoomId, origin) + override suspend fun subgroupsOf(chatRoomId: String): List = try { SubgroupManager.subgroupsOf(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 6f8ef1dd..ba97285c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -839,6 +839,60 @@ object SubgroupManager { } .sortedByDescending { it.certificate.createdAt } + /** + * The subgroups [parentChatRoomId] has made for one particular thing. + * + * The other direction of the certificate: [subgroupsOf] answers "what has + * this group made", and this answers "what has been made for this project". + * It is a filter over that list rather than a query of its own, because the + * certificates are already the record and a second read could disagree with + * the first about which of them verify. + * + * Empty is the ordinary answer and says only that this device holds no + * certificate naming that entity. A subgroup made for it by a parent whose + * room this device is not in is invisible here, as it is everywhere else. + */ + suspend fun subgroupsFor( + database: MantraDatabase, + parentChatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = + subgroupsOf(database, parentChatRoomId).filter { it.origin == origin } + + /** + * Ceremonies under way in [parentChatRoomId] for one particular thing. + * + * The race this exists for: two admins open a project's screen, both see no + * room, and the group gets two ceremonies and two rooms for one project. The + * screen that invites the act is the screen that cannot otherwise see the act + * in progress, so it is shown what is already running. + * + * Nothing is refused on the strength of it. A group may legitimately want two + * rooms for one artifact -- two teams, two bodies of work -- and deciding + * otherwise would be this device deciding a group's structure. What this + * prevents is the *accidental* second one, which is the only one anybody + * regrets. + * + * Finished and failed are both left out, and for different reasons. A + * complete ceremony is a subgroup that was made, and [subgroupsFor] is where + * it shows; a failed one is nothing, and offering it back would be offering a + * ceremony nobody can finish. + */ + suspend fun ceremoniesFor( + database: MantraDatabase, + parentChatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = + database.dkgSessionDao() + .getSessionsForOrigin( + chatRoomId = parentChatRoomId, + originKind = origin.kind, + originId = origin.id + ) + .filter { + it.stage != DkgRitualStage.FAILED && it.stage != DkgRitualStage.COMPLETE + } + /** * Every group that has certified a room as its child, verified, in canonical * order -- empty for a room that is nobody's child. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 9dc7189f..4bb3d0f2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -3,12 +3,14 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GroupSignedEvent import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -69,6 +71,25 @@ interface ChatRepository { */ suspend fun subgroupsOf(chatRoomId: String): List + /** + * The subgroups a room has made for one particular dialect, artifact, + * translation or project, and the ceremonies under way for it. + * + * The other direction of a birth certificate. See + * `SubgroupManager.subgroupsFor` and `SubgroupManager.ceremoniesFor` -- the + * second is what stops two admins opening two ceremonies for one thing + * because neither screen could see the other's. + */ + suspend fun subgroupsFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List + + suspend fun subgroupCeremoniesFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List + /** * Every group that has certified this room as its child, verified, in * canonical order -- empty if it is nobody's child. @@ -330,6 +351,16 @@ interface ChatRepository { chatRoomId: String ): GroupSignedEvent? = null + override suspend fun subgroupsFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = emptyList() + + override suspend fun subgroupCeremoniesFor( + chatRoomId: String, + origin: SubgroupOrigin.Ref + ): List = emptyList() + override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index f0150cb7..77a2426c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.widgets.Decorative @@ -297,48 +298,15 @@ fun ArtifactDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (artifactDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(artifact) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(artifact), + subgroups = artifactDetailUIState.subgroups, + ceremonies = artifactDetailUIState.subgroupCeremonies, + canAddSubgroup = artifactDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } // Details diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt index c636103e..e1008dd7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DialectDetailScreen.kt @@ -45,6 +45,7 @@ import mantra.composeapp.generated.resources.translations import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute @@ -232,48 +233,15 @@ fun DialectDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (dialectDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(dialect) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(dialect), + subgroups = dialectDetailUIState.subgroups, + ceremonies = dialectDetailUIState.subgroupCeremonies, + canAddSubgroup = dialectDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } item { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt index e39f5838..b69ccd05 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt @@ -52,6 +52,7 @@ import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.Profile import press.mantra.compose.extensions.shortened import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -263,48 +264,15 @@ fun ProjectDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (projectDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(project) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(project), + subgroups = projectDetailUIState.subgroups, + ceremonies = projectDetailUIState.subgroupCeremonies, + canAddSubgroup = projectDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } // Who it belongs to diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 65ccf4c0..c6c4cc31 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.widgets.subgroupsForEntitySection import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -232,48 +233,15 @@ fun TranslationArtifactVersionDetailScreen( } } - item { HorizontalDivider() } - - item { - // Hidden rather than disabled where it cannot work, - // the way the room's own subgroup entry is: a device - // with no share of the key cannot open the - // certificate session at all, and a NIP-17 room has - // no admins to be a parent's. - if (translationDetailUIState.canAddSubgroup) { - TextButton( - onClick = { - val candidate = SubgroupOrigin.of(translation) - - onNavigateToRoute.invoke( - SelectSubgroupAdminsRoute( - activeUserPublicKey = activeUserPublicKey, - parentChatRoomId = candidate.parentChatRoomId, - relayHint = relayHint, - name = candidate.name, - originKind = candidate.origin.kind, - originId = candidate.origin.id, - ) - ) - } - ) { - Icon( - Icons.Default.AccountTree, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.itemGap - ) - ) - Text( - stringResource( - Res.string.give_this_a_room_of_its_own - ) - ) - } - } - } + subgroupsForEntitySection( + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + candidate = SubgroupOrigin.of(translation), + subgroups = translationDetailUIState.subgroups, + ceremonies = translationDetailUIState.subgroupCeremonies, + canAddSubgroup = translationDetailUIState.canAddSubgroup, + onNavigateToRoute = onNavigateToRoute, + ) item { HorizontalDivider() } // Details diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt new file mode 100644 index 00000000..5ab2f662 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SubgroupsForEntity.kt @@ -0,0 +1,191 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.Modifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_key_ceremony_for_a_room_is_under_way +import mantra.composeapp.generated.resources.a_subgroup +import mantra.composeapp.generated.resources.certified_not_yet_created +import mantra.composeapp.generated.resources.created_you_are_not_a_member +import mantra.composeapp.generated.resources.give_this_a_room_of_its_own +import mantra.composeapp.generated.resources.give_this_another_room +import mantra.composeapp.generated.resources.rooms_of_its_own +import mantra.composeapp.generated.resources.run_by_members +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.theme.spacing + +/** + * The rooms a group has made for one thing, and the way to ask for another. + * + * Written once for four screens, which is the point: a dialect, an artifact, a + * translation and a project all answer "has this been given a room of its own" + * identically, and four copies of the answer would drift into four. It is a + * `LazyListScope` extension rather than a composable so each screen's own + * `LazyColumn` keeps one scroll and one set of keys. + * + * ### What is shown and what is not refused + * + * A group may legitimately want two rooms for one artifact -- two teams, two + * bodies of work -- so a second is never refused. What is prevented is the + * *accidental* second: two admins open this screen at the same time, both see + * nothing, and the group gets two ceremonies and two rooms for one project. + * [ceremonies] is what stops that, by making the act in progress visible on the + * screen that invites the act. The button changes its words rather than + * disappearing, so the deliberate second room is still one tap away. + * + * A subgroup with no room on this device is still listed. That is the normal + * position of a member who is not in the child, and of everybody between the + * parent certifying it and somebody creating it, so it opens at the certificate + * instead -- which is the whole of what is known and is checkable. + */ +fun LazyListScope.subgroupsForEntitySection( + activeUserPublicKey: HexKey, + relayHint: String?, + candidate: SubgroupOrigin.Candidate, + subgroups: List, + ceremonies: List, + canAddSubgroup: Boolean, + onNavigateToRoute: (Route) -> Unit, +) { + // Nothing made, nothing running and nothing this device could start: the + // section would be a heading over an empty space. Hidden entirely rather + // than shown empty, the way the room's own subgroup entry is -- a member who + // holds no share of the key cannot act on any of it. + if (subgroups.isEmpty() && ceremonies.isEmpty() && !canAddSubgroup) return + + item { HorizontalDivider() } + + item { + Text( + text = stringResource(Res.string.rooms_of_its_own), + style = MaterialTheme.typography.labelMedium + ) + } + + items( + items = subgroups, + key = { subgroup -> subgroup.chatRoomId } + ) { subgroup -> + Card( + onClick = { + onNavigateToRoute.invoke( + subgroup.room?.let { + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = it.chatRoom.id, + relayHint = relayHint + ) + } ?: NostrEventDetailRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = subgroup.certificate.id + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + }, + // The room where this device holds one, and only otherwise the + // certificate's: the certificate's name is what the parent + // approved at founding, frozen, and the child may since have been + // renamed. Where the room is here it is the live answer. + headlineContent = { + Text( + text = subgroup.room?.chatRoom?.subject + ?: subgroup.name + ?: stringResource(Res.string.a_subgroup) + ) + }, + supportingContent = { + Text( + text = when { + subgroup.isJoined -> stringResource( + Res.string.run_by_members, + subgroup.adminPublicKeys.size.toString() + ) + subgroup.keyState == null -> + stringResource(Res.string.certified_not_yet_created) + else -> stringResource(Res.string.created_you_are_not_a_member) + } + ) + } + ) + } + } + + // One line however many are running. Two ceremonies for one thing is already + // the thing this is warning about, and listing them separately would make + // the warning look like a feature. + if (ceremonies.isNotEmpty()) { + item { + Card { + ListItem( + leadingContent = { + Icon(Icons.Default.HourglassTop, contentDescription = Decorative) + }, + headlineContent = { + Text(stringResource(Res.string.a_key_ceremony_for_a_room_is_under_way)) + } + ) + } + } + } + + if (canAddSubgroup) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = candidate.parentChatRoomId, + relayHint = relayHint, + name = candidate.name, + originKind = candidate.origin.kind, + originId = candidate.origin.id, + ) + ) + } + ) { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + // "Another" once there is one, so the button is not inviting + // something that already exists. Changed rather than hidden, + // because a second room is a thing a group may genuinely want. + Text( + stringResource( + if (subgroups.isEmpty() && ceremonies.isEmpty()) { + Res.string.give_this_a_room_of_its_own + } else { + Res.string.give_this_another_room + } + ) + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt index 339f469a..35c02657 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ArtifactDetailViewModel.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -42,12 +44,18 @@ class ArtifactDetailViewModel( artifactDetailUIState = if (artifact == null) { ArtifactDetailUIState.Error("Couldn't find the artifact") } else { + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(artifact) + ArtifactDetailUIState.Loaded( artifact = artifact, versions = mantraRepository.getArtifactVersions(artifactId), chapters = mantraRepository.getChaptersForArtifact(artifactId), translations = mantraRepository.getTranslationsForArtifact(artifactId), canAddSubgroup = canAddSubgroupFor(artifact), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } @@ -70,6 +78,24 @@ class ArtifactDetailViewModel( chatRepository.canSign(parentChatRoomId) } + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "ArtifactDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt index c5e1b6b0..d4de4e47 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DialectDetailViewModel.kt @@ -1,5 +1,9 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupOrigin +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.traits.SubgroupOriginEntity +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -60,17 +64,41 @@ class DialectDetailViewModel( // the dialect actually lives in. val room = chatRepository.getChatRoomByIdentifier(dialect.chatRoomId) + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(dialect) + DialectDetailUIState.Loaded( dialect = dialect, artifacts = mantraRepository.getArtifactsForDialect(dialectId), translations = mantraRepository.getTranslationsForDialect(dialectId), canAddSubgroup = room?.chatRoom?.mlsGroupState != null && chatRepository.canSign(dialect.chatRoomId), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } } + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "DialectDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt index f386d59f..ef6b571d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -47,6 +49,10 @@ class ProjectDetailViewModel( projectDetailUIState = if (project == null) { ProjectDetailUIState.Error("Couldn't find the project") } else { + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(project) + ProjectDetailUIState.Loaded( project = project, sourceVersions = mantraRepository.getProjectArtifactVersions(projectId), @@ -59,6 +65,8 @@ class ProjectDetailViewModel( ), canSign = frostSigningRepository.canSign(chatRoomId), canAddSubgroup = canAddSubgroupFor(project), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } @@ -86,6 +94,24 @@ class ProjectDetailViewModel( nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it } }.toMap() + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "ProjectDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt index 293a0bda..89dad6da 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/TranslationArtifactVersionDetailViewModel.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.model +import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.database.model.DkgSession import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,6 +65,10 @@ class TranslationArtifactVersionDetailViewModel( ) } + // Read before the state is built so both halves come from one + // call, and so the room is resolved once rather than twice. + val (madeFor, ceremoniesFor) = subgroupsAndCeremoniesFor(translation) + TranslationArtifactVersionDetailUIState.Loaded( translation = translation, chapters = chapters, @@ -74,6 +80,8 @@ class TranslationArtifactVersionDetailViewModel( ), canSign = frostSigningRepository.canSign(chatRoomId), canAddSubgroup = canAddSubgroupFor(translation), + subgroups = madeFor, + subgroupCeremonies = ceremoniesFor, ) } } @@ -162,6 +170,24 @@ class TranslationArtifactVersionDetailViewModel( } } + + /** + * The rooms made for [entity] and the ceremonies under way for it. + * + * Both off the entity's own room, which `SubgroupOrigin.of` is the one + * authority on -- a subgroup made for this thing is a child of the room the + * thing lives in, and asking any other room would answer about somebody + * else's children. + */ + private suspend fun subgroupsAndCeremoniesFor( + entity: SubgroupOriginEntity + ): Pair, List> { + val candidate = SubgroupOrigin.of(entity) + + return chatRepository.subgroupsFor(candidate.parentChatRoomId, candidate.origin) to + chatRepository.subgroupCeremoniesFor(candidate.parentChatRoomId, candidate.origin) + } + companion object { private const val TAG = "TranslationArtifactVersionDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt index 6fd75a0c..e2469e1e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ArtifactDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter @@ -27,6 +29,27 @@ sealed interface ArtifactDetailUIState { * subgroup button rather than before it. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this artifact. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this artifact. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one artifact, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): ArtifactDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt index 0c5c3adf..09468ed4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DialectDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.MantraTranslationArtifactVersion @@ -37,6 +39,27 @@ sealed interface DialectDetailUIState { * missing and what to do about it. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this dialect. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this dialect. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one dialect, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): DialectDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt index c2c228b8..cb3c0513 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraProject @@ -40,6 +42,27 @@ sealed interface ProjectDetailUIState { * than derived at the call site so there is one spelling of one rule. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this project. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this project. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one project, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): ProjectDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt index 05896461..14f3d4f6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/TranslationArtifactVersionDetailUIState.kt @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationChapter @@ -46,6 +48,27 @@ sealed interface TranslationArtifactVersionDetailUIState { * `ChatRoomDetailUIState.canAddSubgroup` states. */ val canAddSubgroup: Boolean = false, + /** + * The rooms this group has made for this translation. + * + * The other direction of a birth certificate, and the reason the + * certificate carries an origin at all. Empty says only that this device + * holds no certificate naming it -- a room made for it by a group this + * device is not in is invisible here, as everywhere else. + */ + val subgroups: List = emptyList(), + + /** + * Key ceremonies already under way for this translation. + * + * Shown rather than refused. Two admins opening this screen at the same + * time both see no room and both start one, which is the race the whole + * pair exists for -- the screen that invites the act is the screen that + * cannot otherwise see the act in progress. A group may still want two + * rooms for one translation, and that is the group's business. + */ + val subgroupCeremonies: List = emptyList(), + ): TranslationArtifactVersionDetailUIState data class Error( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index ca78097c..f210a2c8 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -1,5 +1,6 @@ package press.mantra.compose.managers +import press.mantra.compose.managers.SubgroupOrigin import androidx.room3.Room import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 @@ -133,6 +134,103 @@ class SubgroupManagerJvmTest { assertNull(subgroup.keyState) } + // --- the other direction: what was made for one thing --- + + /** + * The reverse index, which is the reason the certificate carries an origin. + * A room certifies every subgroup it ever makes, so asking "what has been + * made for this project" is a filter and not a lookup -- and the case that + * matters is a sibling subgroup for something else, which a filter on the + * wrong field would include. + */ + @Test + fun `a parent lists only the subgroups made for one thing`(): Unit = runBlocking { + seedRoom(parentRoomId) + + val theProject = SubgroupOrigin.Ref(kind = 30330, id = "a1".padEnd(64, '0')) + val theDialect = SubgroupOrigin.Ref(kind = 30304, id = "b2".padEnd(64, '0')) + + record(certificate(origin = theProject)) + record(certificate(subgroupMaterial = siblingMaterial, origin = theDialect)) + + assertEquals( + listOf(childRoomId), + SubgroupManager.subgroupsFor(db, parentRoomId, theProject).map { it.chatRoomId } + ) + assertEquals( + listOf(siblingRoomId), + SubgroupManager.subgroupsFor(db, parentRoomId, theDialect).map { it.chatRoomId } + ) + } + + /** + * A subgroup made for nothing in particular belongs to no entity's list. It + * is the group's own "Add subgroup", and every certificate signed before the + * origin tag existed is one. + */ + @Test + fun `a subgroup for nothing in particular is in no entity's list`(): Unit = runBlocking { + seedRoom(parentRoomId) + record(certificate()) + + assertEquals(1, SubgroupManager.subgroupsOf(db, parentRoomId).size) + assertTrue( + SubgroupManager.subgroupsFor( + db, + parentRoomId, + SubgroupOrigin.Ref(kind = 30330, id = "a1".padEnd(64, '0')) + ).isEmpty() + ) + } + + /** + * The race this pair exists for. Two admins open a project's screen, neither + * can see the other's ceremony, and the group gets two rooms for one project. + * A finished one is not in flight -- it is a subgroup that was made -- and a + * failed one is nothing to offer back. + */ + @Test + fun `only unfinished ceremonies for that thing count as in flight`(): Unit = runBlocking { + seedRoom(parentRoomId) + + val theProject = SubgroupOrigin.Ref(kind = 30330, id = "a1".padEnd(64, '0')) + val somethingElse = SubgroupOrigin.Ref(kind = 30304, id = "b2".padEnd(64, '0')) + + suspend fun ceremony(id: String, stage: DkgRitualStage, origin: SubgroupOrigin.Ref?) = + db.dkgSessionDao().upsert( + DkgSession( + id = id, + chatRoomId = parentRoomId, + coordinatorPublicKey = user, + userPublicKey = user, + threshold = 2, + participantCount = 3, + stage = stage, + hostPublicKey = user, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + parentChatRoomId = parentRoomId, + originKind = origin?.kind, + originId = origin?.id, + ) + ) + + ceremony("running", DkgRitualStage.COLLECTING_HOST_KEYS, theProject) + ceremony("done", DkgRitualStage.COMPLETE, theProject) + ceremony("failed", DkgRitualStage.FAILED, theProject) + ceremony("elsewhere", DkgRitualStage.COLLECTING_HOST_KEYS, somethingElse) + ceremony("for-nothing", DkgRitualStage.COLLECTING_HOST_KEYS, null) + + assertEquals( + listOf("running"), + SubgroupManager.ceremoniesFor(db, parentRoomId, theProject).map { it.id } + ) + assertEquals( + listOf("elsewhere"), + SubgroupManager.ceremoniesFor(db, parentRoomId, somethingElse).map { it.id } + ) + } + @Test fun `a child whose room this device holds reports it`(): Unit = runBlocking { seedRoom(parentRoomId) @@ -1113,7 +1211,8 @@ class SubgroupManagerJvmTest { author: KeyMaterial = parentMaterial, signer: KeyMaterial = author, signature: String? = null, - parentChatRoomId: String = roomOf(author) + parentChatRoomId: String = roomOf(author), + origin: SubgroupOrigin.Ref? = null ): Event { val subgroupChatRoomId = roomOf(subgroupMaterial) val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags( @@ -1122,7 +1221,8 @@ class SubgroupManagerJvmTest { thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(), adminPublicKeys = adminPublicKeys, name = name, - path = path + path = path, + origin = origin ) val pubKey = SharedKeyDerivation From e5d966bc6afd00fc606b8bf1c15622aca751fad2 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 01:56:59 +0200 Subject: [PATCH 27/28] docs(subgroups): what the implementation chose differently, and one floor that cannot move The deviations table every doc in here carries, and it is the most-read part of them. Seven entries, of which three are worth the reader's time. `proposeBirthCertificate` was planned to take an `origin` parameter; it already takes the whole `DkgSession`, so it reads `key.origin()` and nothing in the repository or the ritual view model changed. A parameter would have been a second source for one fact, and the proposer's copy of it at that. Phase 3 put a button on four screens and Phase 7 replaced all four with one `LazyListScope` extension, so they were written twice. The giveaway was already in Phase 0.3, which argued the dialect's entry point had to be a screen because a card cannot *grow* into a list -- the same argument applied to all four, and the section should have been planned where the buttons were. And Phase 8's own claim was wrong: the M3 adaptive floor cannot be raised by this work. The floors count `MaterialTheme.breakpoint`, `BoxWithConstraints`, the pane scaffolds and the navigation components; `readableContent()` and `NavigateBackButton` are neither, so a screen that follows every rule in CLAUDE.md adds nothing to either count. Raising a floor to the number the tree happens to sit at would make it fail the next time something unrelated was removed. The floors guard two-pane layout and the navigation shell, which a detail screen is not, and they stay where phase 6 of the conformance work left them. Recorded rather than quietly dropped, because the next plan will want to make the same assumption. Pointers from docs/README.md, from docs/subgroups.md beside the multiple-parents line, and from docs/project-feature.md -- where a project's own room was the first thing that file said it lacked, and which now says the rows stay in the parent's tables all the same. Phase 8 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 --- docs/README.md | 1 + docs/project-feature.md | 7 ++++++ docs/subgroups-for-entities.md | 39 +++++++++++++++++++++++++++++----- docs/subgroups.md | 7 ++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index f47439a6..af3580e2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ silent, or a decision that looked arbitrary and was not. | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | | [subgroups.md](./subgroups.md) | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived | | [subgroups-multiple-parents.md](./subgroups-multiple-parents.md) | adding a parent to a group that already exists — two signings and no ceremony, why the child's consent is the whole gate, and the certificate a group can truthfully sign about itself | +| [subgroups-for-entities.md](./subgroups-for-entities.md) | giving a dialect, an artifact, a translation or a project a room of its own — why the link belongs on the birth certificate rather than in a column, and the screen a dialect needed before it could be asked | | [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | | [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them | diff --git a/docs/project-feature.md b/docs/project-feature.md index 8c06efc8..c68f916d 100644 --- a/docs/project-feature.md +++ b/docs/project-feature.md @@ -1071,6 +1071,13 @@ want to remove within a week. - **Creator or owner on a membership.** One tag and one column away, deliberately left out — see Phase 1. - **Removal** until Phase 8, **cross-room** until Phase 9. +- **A room of the project's own** — built since, in + [subgroups-for-entities.md](./subgroups-for-entities.md). A project can be + given a differently-keyed subgroup of its room, and the parent's quorum signs + which project the room is for. It does not move the project: the rows stay in + the parent's tables, signed by the parent's quorum, and the child's members may + hold none of them. Giving the child its own copy is the cross-room question + above and is still Phase 9. - **Project-level approval or progress.** The server has `artifactApproval`, `artifactVersionApproval` and `translationArtifactVersionApproval`, none of which exists here either. A diff --git a/docs/subgroups-for-entities.md b/docs/subgroups-for-entities.md index 959297f5..4604095f 100644 --- a/docs/subgroups-for-entities.md +++ b/docs/subgroups-for-entities.md @@ -16,6 +16,28 @@ Read [subgroups.md](./subgroups.md) first — the four steps, what the birth certificate covers, and why the child's key is fresh. Nothing here changes any of that. Written against the code as it stood at `5875655a`. +**Built**, eight phases, one commit each. The phases below are kept as the +reasoning they were, because the code reads better against the argument it came +from than against a summary of itself. Where the implementation chose +differently the table says so, and it did so seven times worth reading: + +| what the plan said | what it turned out to be | +|---|---| +| schema 23 is the next free number | 23, 24 and 25 are all taken on an unmerged branch. Taken anyway, and Phase 0.2 says why: a number is free relative to the merge base, and pre-allocating 26 moves the renumbering rather than avoiding it | +| `proposeBirthCertificate` takes an `origin` parameter, passed down from the view model | it already takes the whole `DkgSession`, so it reads `key.origin()`. A parameter would have been a second source for one fact, and the proposer's copy of it at that. Nothing in the repository or the ritual view model changed | +| Phase 3 puts a button on each of four screens | Phase 7 replaced all four with one `LazyListScope` extension. The buttons were written twice, and the section should have been planned in Phase 3 — the giveaway was there in Phase 0.3, which already argued the entry point had to *grow* into a list | +| `ArtifactDetailUIState` gains a capability flag | that, and a `ChatRepository` in the screen *and* the view model. `ProjectDetailViewModel` needed one too, which was not anticipated: `canSign` alone cannot answer whether a room is a Marmot one | +| Phase 8 raises the M3 adaptive floor | it cannot. The floors count `MaterialTheme.breakpoint`, `BoxWithConstraints`, the pane scaffolds and the navigation components; `readableContent()` and `NavigateBackButton` are neither. A new screen conforming to the rules adds nothing to either count, and raising a floor the work did not earn would make it regress for an unrelated reason | +| the card says what a subgroup is for in its supporting line | an overline. The supporting line was already carrying whether this device can open the room, which is a different question and both are worth a line | +| the empty-dialect test deletes the rows | neither DAO has a `@Delete`, and adding one for a test is a schema surface nobody asked for. It seeds a third dialect with nothing in it, which is also the truer fixture — a room defines a dialect before it has anything in it | + +One more worth stating because it was a *drafting* error rather than a design +one. The first draft of this plan cited `DialectScaffold`, `AfricanLanguages` and +`docs/african-dialect-catalogue.md` as precedent. None of them are in this tree — +that work is on `claude/african-dialect-convenience-de2f69`, unmerged — so the +identity rule is now stated from the schema rather than cited, and Phase 1's +parallel is `TranslationScaffold` and `ChapterScaffolding`, which do exist here. + ## What the four have in common | entity | kind | name | room | @@ -494,11 +516,18 @@ One commit. - A pointer from `docs/subgroups.md` beside the multiple-parents one. - A line in `docs/project-feature.md` under "what this does not do", since a project's room is the first thing that file says it lacks. -- The M3 floors move. `docs/material-design-conformance.md` states "at least 12 - adaptive API uses, at least 2 navigation components", checked from *below* - because they regress by being removed. Phase 2 adds a screen with - `readableContent()` and a `NavigateBackButton`, so the floor can be raised — - and should be, or a screen deleted later takes the guarantee with it silently. +- **The M3 floors do not move, and the reason is worth keeping.** The plan said + Phase 2's new screen would let the adaptive floor be raised. It does not: + `FLOOR_ADAPTIVE_APIS` counts `MaterialTheme.breakpoint`, + `BoxWithConstraints`, `currentWindowAdaptiveInfo`, the pane scaffolds and + `listPaneWidthFor`, and `FLOOR_NAVIGATION_COMPONENTS` counts the navigation + components. `readableContent()` and `NavigateBackButton` are in neither list. + + So a screen that follows every rule in CLAUDE.md adds nothing to either count, + and raising a floor to the number the tree happens to sit at would make it fail + the next time something unrelated was removed. The floors guard two-pane layout + and the navigation shell; a detail screen is neither. Both stay where phase 6 + of the conformance work left them. ## Verifying diff --git a/docs/subgroups.md b/docs/subgroups.md index 1ffdf8ad..905fb710 100644 --- a/docs/subgroups.md +++ b/docs/subgroups.md @@ -42,6 +42,13 @@ A subgroup is an ordinary Marmot room with an ordinary threshold key, made by an ordinary ChillDKG ceremony, plus a **birth certificate** — the parent group's signature over the child's room id — carried on the child's key state. +A certificate can also say what the room is *for* — a dialect, an artifact, a +translation or a project in the parent's own tables — which is one optional tag +and is the whole of [subgroups-for-entities.md](./subgroups-for-entities.md). +Nothing below changes for it: `certifies` never reads it, and a certificate with +no origin at all is valid, which it has to be, since every one signed before that +tag existed has none. + The key state grows two tags for it, not one, and only the first is an artefact: | tag | what it is | From 2f542408c96cac05621918aedb46e0257e80179f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 3 Oct 2026 03:35:38 +0200 Subject: [PATCH 28/28] docs(subgroups): the dialect catalogue is in the tree now, so cite it again Written while the catalogue was on an unmerged branch, so three citations were replaced with the reasoning stated directly. It landed on mantra during the build and the merge brought it in, so `DialectScaffold`, `AfricanLanguages` and docs/african-dialect-catalogue.md are all here. The restated reasoning stays rather than turning back into links -- a plan that explains its own rule survives the file it borrowed it from being moved -- but two cross-references are worth having. `DialectScaffold` is the closest thing in the tree to `SubgroupOrigin`, and `DialectScaffold.pendingIn` is Phase 7's `ceremoniesFor` solving the same race one layer up: read what is already in flight, so two admins looking at the same screen do not propose the same thing twice. Eighty dialects there, two rooms for one project here. Co-Authored-By: Claude Opus 5 --- docs/subgroups-for-entities.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/subgroups-for-entities.md b/docs/subgroups-for-entities.md index 4604095f..7efc6300 100644 --- a/docs/subgroups-for-entities.md +++ b/docs/subgroups-for-entities.md @@ -32,11 +32,22 @@ differently the table says so, and it did so seven times worth reading: | the empty-dialect test deletes the rows | neither DAO has a `@Delete`, and adding one for a test is a schema surface nobody asked for. It seeds a third dialect with nothing in it, which is also the truer fixture — a room defines a dialect before it has anything in it | One more worth stating because it was a *drafting* error rather than a design -one. The first draft of this plan cited `DialectScaffold`, `AfricanLanguages` and -`docs/african-dialect-catalogue.md` as precedent. None of them are in this tree — -that work is on `claude/african-dialect-convenience-de2f69`, unmerged — so the -identity rule is now stated from the schema rather than cited, and Phase 1's -parallel is `TranslationScaffold` and `ChapterScaffolding`, which do exist here. +one, and because it resolved itself. The first draft cited `DialectScaffold`, +`AfricanLanguages` and [african-dialect-catalogue.md](./african-dialect-catalogue.md) +as precedent; at the time none of them were in the tree, so the citations were +replaced with the reasoning stated directly. The dialect catalogue landed on +`mantra` while this was being built, so all three are here now and the +cross-references below are real again — but the restated reasoning is kept +rather than turned back into links, because a plan that explains its own rule +survives the file it borrowed it from being moved. + +Two of them are worth following. `DialectScaffold` is the closest thing in the +tree to Phase 1's `SubgroupOrigin`: pure, in `managers/`, derived from the rows +rather than beside them. And `DialectScaffold.pendingIn` is Phase 7's +`ceremoniesFor` solving the same problem one layer up — it reads the proposals +already in flight so two admins looking at the same list do not propose the same +forty dialects twice. The subgroup version of that race is two rooms for one +project instead of eighty dialects, which is rarer and more expensive. ## What the four have in common @@ -237,8 +248,8 @@ object SubgroupOrigin { } ``` -Parallel to `TranslationScaffold` and `ChapterScaffolding`: `managers/`, pure, -tested in `commonTest`. `name` is trimmed and handed over bare — the `#` is added +Parallel to `DialectScaffold`, `TranslationScaffold` and `ChapterScaffolding`: +`managers/`, pure, tested in `commonTest`. `name` is trimmed and handed over bare — the `#` is added by `MarmotGroupName.of` where the name is used, twice, idempotently, exactly as the typed name is today. @@ -492,7 +503,9 @@ ceremonies for it: reads the same list. A subgroup whose room this device does not hold still shows, because `Subgroup.room` is already nullable and already drawn that way. - **In flight.** `DkgSession` rows in that room with that origin whose stage is - neither `FAILED` nor `COMPLETE`. Two admins both open the project's screen, + neither `FAILED` nor `COMPLETE`. The same move `DialectScaffold.pendingIn` + makes for the dialect catalogue, and for the same reason. Two admins both open + the project's screen, both see no room, and the group gets two ceremonies and two rooms for one project — a race this feature would hit on first use, because the screen that invites the act is the screen that cannot see the act in progress. Making the